diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 07d3e01b..cce008b3 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -331,6 +331,15 @@ context: 更多行为说明见 [context-compact.md](../context-compact.md)。 +## /cwd 工作区切换 + +- `/cwd` 不再是“修改当前 session 执行目录”,而是“切换到另一个工作区实例”。 +- `/cwd` 不带参数时,只显示当前工作区路径。 +- `/cwd ` 会先把目标解析为存在的绝对目录;若目标与本次启动工作区相同,则返回 no-op 提示。 +- 若当前 Agent 正在运行或正在 compact,`/cwd` 会直接拒绝切换,不会强制中断当前任务。 +- 切换成功时,NeoCode 会启动一个新的 `--workdir ` 进程,并退出当前进程。 +- 新进程进入目标工作区后,会使用该工作区自己的 session 分桶,并从 fresh draft 开始,而不是迁移当前输入框或当前会话。 + ## CLI 工作区覆盖 NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区: diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index fb22d26d..b6f3f748 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -40,5 +40,6 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 - session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` - 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` -- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 +- `/cwd` 现改为进程级工作区切换:成功时会拉起一个新的 `--workdir` 进程,并进入目标工作区自己的 session 分桶 +- `session.Workdir` 仍表示历史会话中记录的执行目录字段,但 `/cwd` 不再通过当前进程内更新该字段来模拟工作区切换 - 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 29fa456a..5f5c1d53 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -22,6 +22,7 @@ import ( "neo-code/internal/tools/filesystem" "neo-code/internal/tools/webfetch" "neo-code/internal/tui" + tuiinfra "neo-code/internal/tui/infra" ) const utf8CodePage = 65001 @@ -110,7 +111,13 @@ func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error return nil, err } - tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection) + tuiApp, err := tui.New( + &bundle.Config, + bundle.ConfigManager, + bundle.Runtime, + bundle.ProviderSelection, + tuiinfra.NewProcessWorkspaceSwitcher(), + ) if err != nil { return nil, err } diff --git a/internal/cli/root.go b/internal/cli/root.go index b48d8600..28ea1a38 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,6 +13,10 @@ import ( var launchRootProgram = defaultRootProgramLauncher var newRootProgram = app.NewProgram +type workspaceRelaunchModel interface { + PendingWorkspaceWorkdir() string +} + // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { Workdir string @@ -50,10 +54,26 @@ func NewRootCommand() *cobra.Command { // defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { - program, err := newRootProgram(ctx, opts) - if err != nil { - return err + current := opts + for { + program, err := newRootProgram(ctx, current) + if err != nil { + return err + } + model, err := program.Run() + if err != nil { + return err + } + + relaunchModel, ok := model.(workspaceRelaunchModel) + if !ok { + return nil + } + + nextWorkdir := strings.TrimSpace(relaunchModel.PendingWorkspaceWorkdir()) + if nextWorkdir == "" { + return nil + } + current.Workdir = nextWorkdir } - _, err = program.Run() - return err } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 06538080..bfd6cf01 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -43,6 +43,7 @@ func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { } cmd := NewRootCommand() + cmd.SetArgs([]string{}) if err := cmd.ExecuteContext(context.Background()); err != nil { t.Fatalf("ExecuteContext() error = %v", err) } @@ -61,6 +62,7 @@ func TestNewRootCommandReturnsLauncherError(t *testing.T) { } cmd := NewRootCommand() + cmd.SetArgs([]string{}) err := cmd.ExecuteContext(context.Background()) if !errors.Is(err, expected) { t.Fatalf("expected launcher error %v, got %v", expected, err) @@ -119,6 +121,35 @@ func TestDefaultRootProgramLauncherReturnsNewProgramError(t *testing.T) { } } +func TestDefaultRootProgramLauncherRelaunchesWithRequestedWorkdir(t *testing.T) { + originalNewProgram := newRootProgram + t.Cleanup(func() { newRootProgram = originalNewProgram }) + + calls := make([]app.BootstrapOptions, 0, 2) + newRootProgram = func(ctx context.Context, opts app.BootstrapOptions) (*tea.Program, error) { + calls = append(calls, opts) + if len(calls) == 1 { + model := relaunchModel{workdir: `D:\鏂板伐浣滃尯`} + return tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(io.Discard)), nil + } + 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) + } + if len(calls) != 2 { + t.Fatalf("expected two program launches, got %d", len(calls)) + } + if calls[0].Workdir != `D:\鏃у伐浣滃尯` { + t.Fatalf("expected first launch to use original workdir, got %q", calls[0].Workdir) + } + if calls[1].Workdir != `D:\鏂板伐浣滃尯` { + t.Fatalf("expected relaunch to use requested workdir, got %q", calls[1].Workdir) + } +} + type quitModel struct{} func (quitModel) Init() tea.Cmd { @@ -132,3 +163,23 @@ func (quitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (quitModel) View() string { return "" } + +type relaunchModel struct { + workdir string +} + +func (m relaunchModel) Init() tea.Cmd { + return tea.Quit +} + +func (m relaunchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m relaunchModel) View() string { + return "" +} + +func (m relaunchModel) PendingWorkspaceWorkdir() string { + return m.workdir +} diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index d16edc56..caab6f98 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -550,8 +550,8 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { ` events := make(chan providertypes.StreamEvent, 1) err = p.consumeStream(ctx, strings.NewReader(sseData), events) - if err != nil && !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled or nil, got: %v", err) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected context.Canceled, ErrStreamInterrupted, or nil, got: %v", err) } } diff --git a/internal/tools/bash/executor_test.go b/internal/tools/bash/executor_test.go index 45071e3a..d3ab03e0 100644 --- a/internal/tools/bash/executor_test.go +++ b/internal/tools/bash/executor_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path/filepath" + goruntime "runtime" "strings" "testing" "time" @@ -147,7 +148,7 @@ func TestShellCommand(t *testing.T) { {name: "pwsh", shell: "pwsh", binary: "powershell"}, {name: "bash", shell: "bash", binary: "bash"}, {name: "sh", shell: "sh", binary: "sh"}, - {name: "fallback", shell: "unknown", binary: defaultShell()}, + {name: "fallback", shell: "unknown", binary: fallbackShell()}, } for _, tt := range tests { @@ -165,6 +166,13 @@ func TestShellCommand(t *testing.T) { } } +func fallbackShell() string { + if goruntime.GOOS == "windows" { + return "powershell" + } + return "sh" +} + func TestResolveWorkdir(t *testing.T) { t.Parallel() diff --git a/internal/tools/bash/test_helpers_test.go b/internal/tools/bash/test_helpers_test.go new file mode 100644 index 00000000..72a266f3 --- /dev/null +++ b/internal/tools/bash/test_helpers_test.go @@ -0,0 +1,10 @@ +package bash + +import goruntime "runtime" + +func defaultShell() string { + if goruntime.GOOS == "windows" { + return "powershell" + } + return "bash" +} diff --git a/internal/tools/bash/tool_test.go b/internal/tools/bash/tool_test.go index e2202ca4..31888ee9 100644 --- a/internal/tools/bash/tool_test.go +++ b/internal/tools/bash/tool_test.go @@ -3,9 +3,7 @@ package bash import ( "context" "encoding/json" - "os" - "path/filepath" - goruntime "runtime" + "errors" "strings" "testing" "time" @@ -13,135 +11,149 @@ import ( "neo-code/internal/tools" ) +type scriptedSecurityExecutor struct { + calls int + lastCall tools.ToolCallInput + lastCommand string + lastRequestedWorkdir string + result tools.ToolResult + err error +} + +func (e *scriptedSecurityExecutor) Execute( + ctx context.Context, + call tools.ToolCallInput, + command string, + requestedWorkdir string, +) (tools.ToolResult, error) { + e.calls++ + e.lastCall = call + e.lastCommand = command + e.lastRequestedWorkdir = requestedWorkdir + return e.result, e.err +} + func TestToolExecute(t *testing.T) { - workspace := t.TempDir() - subdir := filepath.Join(workspace, "sub") - if err := os.MkdirAll(subdir, 0o755); err != nil { - t.Fatalf("mkdir subdir: %v", err) - } + t.Parallel() - tests := []struct { - name string - command string - workdir string - callWorkdir string - expectErr string - expectContent string - }{ - { - name: "captures stdout", - command: safeEchoCommand(), - callWorkdir: workspace, - expectContent: "hello", - }, - { - name: "rejects workdir escape", - command: safeEchoCommand(), - workdir: "..", - callWorkdir: workspace, - expectErr: "workdir escapes workspace root", - }, - { - name: "rejects empty command", - command: "", - callWorkdir: workspace, - expectErr: "command is empty", - }, - { - name: "runs inside nested workdir", - command: safePwdCommand(), - workdir: "sub", - callWorkdir: workspace, - expectContent: normalizeOutputPath(subdir), - }, - { - name: "uses tool root when call workdir is empty", - command: safePwdCommand(), - callWorkdir: "", - expectContent: normalizeOutputPath(workspace), + workspace := t.TempDir() + executor := &scriptedSecurityExecutor{ + result: tools.ToolResult{ + Name: "bash", + Content: "hello", + Metadata: map[string]any{ + "workdir": workspace, + }, }, } + tool := NewWithExecutor(workspace, defaultShell(), 3*time.Second, executor) - tool := New(workspace, defaultShell(), 3*time.Second) - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - args, err := json.Marshal(map[string]string{ - "command": tt.command, - "workdir": tt.workdir, - }) - if err != nil { - t.Fatalf("marshal args: %v", err) - } - - result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ - Name: tool.Name(), - Arguments: args, - Workdir: tt.callWorkdir, - }) + args, err := json.Marshal(map[string]string{ + "command": "echo hello", + "workdir": "sub", + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } - if tt.expectErr != "" { - if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) - } - return - } - if execErr != nil { - t.Fatalf("unexpected error: %v", execErr) - } - if !strings.Contains(normalizeOutputPath(result.Content), normalizeOutputPath(tt.expectContent)) { - t.Fatalf("expected content containing %q, got %q", tt.expectContent, result.Content) - } - if result.IsError { - t.Fatalf("expected IsError=false, got true") - } - }) + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if executor.calls != 1 { + t.Fatalf("expected executor to be called once, got %d", executor.calls) + } + if executor.lastCommand != "echo hello" { + t.Fatalf("expected command to be forwarded, got %q", executor.lastCommand) + } + if executor.lastRequestedWorkdir != "sub" { + t.Fatalf("expected requested workdir to be forwarded, got %q", executor.lastRequestedWorkdir) + } + if executor.lastCall.Workdir != workspace { + t.Fatalf("expected call workdir to be preserved, got %q", executor.lastCall.Workdir) + } + if result.Content != "hello" { + t.Fatalf("expected executor result content, got %q", result.Content) + } + if result.IsError { + t.Fatalf("expected non-error result") } } func TestToolExecuteErrorFormattingAndTruncation(t *testing.T) { - workspace := t.TempDir() - tool := New(workspace, defaultShell(), 3*time.Second) + t.Parallel() + workspace := t.TempDir() tests := []struct { name string arguments []byte + executorResult tools.ToolResult + executorErr error expectErr string expectContent []string expectMetadata bool - expectTruncate bool + expectCalls int }{ { name: "invalid json arguments", arguments: []byte(`{invalid`), expectErr: "invalid character", expectContent: []string{"tool error", "tool: bash", "reason: invalid arguments"}, + expectCalls: 0, }, { name: "command failure returns formatted error", - arguments: mustMarshalArgs(t, map[string]string{"command": failingCommand(), "workdir": ""}), - expectErr: commandFailureFragment(), + arguments: mustMarshalArgs(t, map[string]string{"command": "bad", "workdir": ""}), + executorResult: tools.NewErrorResult( + "bash", + tools.NormalizeErrorReason("bash", errors.New("exit status 1")), + "boom", + map[string]any{"workdir": workspace}, + ), + executorErr: errors.New("exit status 1"), + expectErr: "exit status 1", expectContent: []string{ "tool error", "tool: bash", "reason:", - commandFailureOutput(), + "boom", }, expectMetadata: true, + expectCalls: 1, }, { name: "large output is truncated", - arguments: mustMarshalArgs(t, map[string]string{"command": largeOutputCommand(), "workdir": ""}), + arguments: mustMarshalArgs(t, map[string]string{"command": "large", "workdir": ""}), + executorResult: tools.ApplyOutputLimit( + tools.ToolResult{ + Name: "bash", + Content: strings.Repeat("x", tools.DefaultOutputLimitBytes+100), + Metadata: map[string]any{ + "workdir": workspace, + }, + }, + tools.DefaultOutputLimitBytes, + ), expectContent: []string{ "...[truncated]", }, expectMetadata: true, - expectTruncate: true, + expectCalls: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + executor := &scriptedSecurityExecutor{ + result: tt.executorResult, + err: tt.executorErr, + } + tool := NewWithExecutor(workspace, defaultShell(), 3*time.Second, executor) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ Name: tool.Name(), Arguments: tt.arguments, @@ -156,6 +168,9 @@ func TestToolExecuteErrorFormattingAndTruncation(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + if executor.calls != tt.expectCalls { + t.Fatalf("expected executor calls=%d, got %d", tt.expectCalls, executor.calls) + } for _, fragment := range tt.expectContent { if !strings.Contains(result.Content, fragment) { t.Fatalf("expected content to contain %q, got %q", fragment, result.Content) @@ -164,9 +179,6 @@ func TestToolExecuteErrorFormattingAndTruncation(t *testing.T) { if tt.expectMetadata && result.Metadata["workdir"] == "" { t.Fatalf("expected workdir metadata, got %#v", result.Metadata) } - if truncated, _ := result.Metadata["truncated"].(bool); truncated != tt.expectTruncate { - t.Fatalf("expected truncated=%v, got %#v", tt.expectTruncate, result.Metadata["truncated"]) - } }) } } @@ -180,54 +192,3 @@ func mustMarshalArgs(t *testing.T, value any) []byte { } return data } - -func safeEchoCommand() string { - if goruntime.GOOS == "windows" { - return "Write-Output 'hello'" - } - return "printf 'hello'" -} - -func safePwdCommand() string { - if goruntime.GOOS == "windows" { - return "(Get-Location).Path" - } - return "pwd" -} - -func failingCommand() string { - if goruntime.GOOS == "windows" { - return "Write-Output 'boom'; exit 1" - } - return "printf 'boom\\n'; exit 1" -} - -func commandFailureFragment() string { - return "exit status 1" -} - -func commandFailureOutput() string { - return "boom" -} - -func largeOutputCommand() string { - if goruntime.GOOS == "windows" { - return "$text = 'x' * 70000; Write-Output $text" - } - return "i=0; while [ $i -lt 70000 ]; do printf x; i=$((i+1)); done" -} - -func defaultShell() string { - if goruntime.GOOS == "windows" { - return "powershell" - } - return "sh" -} - -func normalizeOutputPath(value string) string { - trimmed := strings.TrimSpace(value) - if goruntime.GOOS == "windows" { - return strings.ToLower(strings.ReplaceAll(trimmed, "/", `\`)) - } - return strings.ReplaceAll(trimmed, `\`, "/") -} diff --git a/internal/tui/bootstrap/builder.go b/internal/tui/bootstrap/builder.go index 739b6970..fe6d1cb7 100644 --- a/internal/tui/bootstrap/builder.go +++ b/internal/tui/bootstrap/builder.go @@ -17,23 +17,30 @@ type ProviderService interface { SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) } +// WorkspaceSwitcher 定义 TUI 触发工作区进程切换所需的最小能力。 +type WorkspaceSwitcher interface { + SwitchWorkspace(ctx context.Context, workdir string) error +} + // Options 定义 bootstrap 装配输入。 type Options struct { - Config *config.Config - ConfigManager *config.Manager - Runtime agentruntime.Runtime - ProviderService ProviderService - Mode Mode - Factory ServiceFactory + Config *config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + WorkspaceSwitcher WorkspaceSwitcher + Mode Mode + Factory ServiceFactory } // Container 表示完成装配后供 TUI Core 使用的依赖集合。 type Container struct { - Config config.Config - ConfigManager *config.Manager - Runtime agentruntime.Runtime - ProviderService ProviderService - Mode Mode + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + WorkspaceSwitcher WorkspaceSwitcher + Mode Mode } // Build 执行 TUI bootstrap 装配,并返回可注入到 App/Core 的容器。 @@ -47,6 +54,9 @@ func Build(options Options) (Container, error) { if options.ProviderService == nil { return Container{}, fmt.Errorf("tui bootstrap: provider service is nil") } + if options.WorkspaceSwitcher == nil { + return Container{}, fmt.Errorf("tui bootstrap: workspace switcher is nil") + } mode := NormalizeMode(options.Mode) cfg := resolveConfigSnapshot(options.Config, options.ConfigManager) @@ -72,12 +82,21 @@ func Build(options Options) (Container, error) { return Container{}, fmt.Errorf("tui bootstrap: provider factory returned nil") } + workspaceSwitcher, err := factory.BuildWorkspaceSwitcher(mode, options.WorkspaceSwitcher) + if err != nil { + return Container{}, fmt.Errorf("tui bootstrap: build workspace switcher: %w", err) + } + if workspaceSwitcher == nil { + return Container{}, fmt.Errorf("tui bootstrap: workspace switcher factory returned nil") + } + return Container{ - Config: cfg, - ConfigManager: options.ConfigManager, - Runtime: runtimeSvc, - ProviderService: providerSvc, - Mode: mode, + Config: cfg, + ConfigManager: options.ConfigManager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + WorkspaceSwitcher: workspaceSwitcher, + Mode: mode, }, nil } diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..52fd0acb 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" @@ -67,6 +68,45 @@ func (s *testProviderService) SetCurrentModel(ctx context.Context, modelID strin return config.ProviderSelection{}, nil } +type testWorkspaceSwitcher struct{} + +func (s *testWorkspaceSwitcher) SwitchWorkspace(ctx context.Context, workdir string) error { + return nil +} + +type testFactory struct { + useRuntime bool + runtimeResult agentruntime.Runtime + runtimeErr error + useProvider bool + providerResult ProviderService + providerErr error + useWorkspaceSwitcher bool + workspaceSwitcherResult WorkspaceSwitcher + workspaceSwitcherErr error +} + +func (f testFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + if !f.useRuntime { + return current, nil + } + return f.runtimeResult, f.runtimeErr +} + +func (f testFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + if !f.useProvider { + return current, nil + } + return f.providerResult, f.providerErr +} + +func (f testFactory) BuildWorkspaceSwitcher(mode Mode, current WorkspaceSwitcher) (WorkspaceSwitcher, error) { + if !f.useWorkspaceSwitcher { + return current, nil + } + return f.workspaceSwitcherResult, f.workspaceSwitcherErr +} + func TestBuild(t *testing.T) { t.Run("success", func(t *testing.T) { manager := &config.Manager{} @@ -74,9 +114,10 @@ func TestBuild(t *testing.T) { providerSvc := &testProviderService{} container, err := Build(Options{ - ConfigManager: manager, - Runtime: runtime, - ProviderService: providerSvc, + ConfigManager: manager, + Runtime: runtime, + ProviderService: providerSvc, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, }) if err != nil { t.Fatalf("Build() error = %v", err) @@ -88,9 +129,10 @@ func TestBuild(t *testing.T) { t.Run("nil config manager", func(t *testing.T) { _, err := Build(Options{ - ConfigManager: nil, - Runtime: &testRuntime{}, - ProviderService: &testProviderService{}, + ConfigManager: nil, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, }) if err == nil { t.Fatal("expected error for nil config manager") @@ -100,9 +142,10 @@ func TestBuild(t *testing.T) { t.Run("nil runtime", func(t *testing.T) { manager := &config.Manager{} _, err := Build(Options{ - ConfigManager: manager, - Runtime: nil, - ProviderService: &testProviderService{}, + ConfigManager: manager, + Runtime: nil, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, }) if err == nil { t.Fatal("expected error for nil runtime") @@ -112,14 +155,142 @@ func TestBuild(t *testing.T) { t.Run("nil provider service", func(t *testing.T) { manager := &config.Manager{} _, err := Build(Options{ - ConfigManager: manager, - Runtime: &testRuntime{}, - ProviderService: nil, + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: nil, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, }) if err == nil { t.Fatal("expected error for nil provider service") } }) + + t.Run("nil workspace switcher", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: nil, + }) + if err == nil { + t.Fatal("expected error for nil workspace switcher") + } + }) + + t.Run("runtime factory error", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, + Factory: testFactory{ + useRuntime: true, + runtimeErr: errors.New("runtime factory failed"), + }, + }) + if err == nil || err.Error() != "tui bootstrap: build runtime: runtime factory failed" { + t.Fatalf("expected runtime factory error, got %v", err) + } + }) + + t.Run("runtime factory returns nil", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, + Factory: testFactory{ + useRuntime: true, + runtimeResult: nil, + useProvider: true, + providerResult: &testProviderService{}, + useWorkspaceSwitcher: true, + workspaceSwitcherResult: &testWorkspaceSwitcher{}, + }, + }) + if err == nil || err.Error() != "tui bootstrap: runtime factory returned nil" { + t.Fatalf("expected nil runtime error, got %v", err) + } + }) + + t.Run("provider factory error", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, + Factory: testFactory{ + useProvider: true, + providerErr: errors.New("provider factory failed"), + }, + }) + if err == nil || err.Error() != "tui bootstrap: build provider service: provider factory failed" { + t.Fatalf("expected provider factory error, got %v", err) + } + }) + + t.Run("provider factory returns nil", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, + Factory: testFactory{ + useRuntime: true, + runtimeResult: &testRuntime{}, + useProvider: true, + providerResult: nil, + useWorkspaceSwitcher: true, + workspaceSwitcherResult: &testWorkspaceSwitcher{}, + }, + }) + if err == nil || err.Error() != "tui bootstrap: provider factory returned nil" { + t.Fatalf("expected nil provider error, got %v", err) + } + }) + + t.Run("workspace switcher factory error", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, + Factory: testFactory{ + useWorkspaceSwitcher: true, + workspaceSwitcherErr: errors.New("workspace switcher factory failed"), + }, + }) + if err == nil || err.Error() != "tui bootstrap: build workspace switcher: workspace switcher factory failed" { + t.Fatalf("expected workspace switcher factory error, got %v", err) + } + }) + + t.Run("workspace switcher factory returns nil", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + WorkspaceSwitcher: &testWorkspaceSwitcher{}, + Factory: testFactory{ + useRuntime: true, + runtimeResult: &testRuntime{}, + useProvider: true, + providerResult: &testProviderService{}, + useWorkspaceSwitcher: true, + workspaceSwitcherResult: nil, + }, + }) + if err == nil || err.Error() != "tui bootstrap: workspace switcher factory returned nil" { + t.Fatalf("expected nil workspace switcher error, got %v", err) + } + }) } func TestResolveConfigSnapshot(t *testing.T) { @@ -140,6 +311,10 @@ func TestResolveConfigSnapshot(t *testing.T) { if cfg.Workdir != "/test" { t.Errorf("expected Workdir /test, got %s", cfg.Workdir) } + cfg.Workdir = "/mutated" + if inputCfg.Workdir != "/test" { + t.Fatalf("expected resolveConfigSnapshot to return clone, got source %q", inputCfg.Workdir) + } }) } diff --git a/internal/tui/bootstrap/factory.go b/internal/tui/bootstrap/factory.go index 9077cdc5..7a9ceef5 100644 --- a/internal/tui/bootstrap/factory.go +++ b/internal/tui/bootstrap/factory.go @@ -4,12 +4,14 @@ import ( agentruntime "neo-code/internal/runtime" ) -// ServiceFactory 定义 runtime/provider 的可切换装配策略。 +// 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) + // BuildWorkspaceSwitcher 根据 mode 返回实际注入到 TUI 的工作区切换服务。 + BuildWorkspaceSwitcher(mode Mode, current WorkspaceSwitcher) (WorkspaceSwitcher, error) } type passthroughFactory struct{} @@ -23,3 +25,8 @@ func (passthroughFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) func (passthroughFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { return current, nil } + +// BuildWorkspaceSwitcher 默认直接透传已有工作区切换服务,不做替换。 +func (passthroughFactory) BuildWorkspaceSwitcher(mode Mode, current WorkspaceSwitcher) (WorkspaceSwitcher, error) { + return current, nil +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index f290ce30..51d9cc09 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -2,6 +2,7 @@ package tui import ( "context" + "strings" "time" "github.com/charmbracelet/bubbles/filepicker" @@ -45,7 +46,7 @@ type runFinishedMsg = tuistate.RunFinishedMsg type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg type compactFinishedMsg = tuistate.CompactFinishedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg -type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg +type workspaceSwitchResultMsg = tuistate.WorkspaceSwitchResultMsg type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg type ProviderController interface { @@ -56,11 +57,14 @@ type ProviderController interface { SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) } +type WorkspaceSwitcher = tuibootstrap.WorkspaceSwitcher + // appServices 聚合 App 需要的服务依赖,避免与渲染状态混在同一层级。 type appServices struct { - configManager *config.Manager - providerSvc ProviderController - runtime agentruntime.Runtime + configManager *config.Manager + providerSvc ProviderController + runtime agentruntime.Runtime + workspaceSwitcher WorkspaceSwitcher } // appComponents 聚合 Bubble Tea 组件与渲染器。 @@ -85,6 +89,7 @@ type appComponents struct { type appRuntimeState struct { codeCopyBlocks map[int]string pendingCopyID int + pendingWorkdir string nowFn func() time.Time lastInputEditAt time.Time lastPasteLikeAt time.Time @@ -111,12 +116,24 @@ type App struct { styles styles } -func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { +// PendingWorkspaceWorkdir 返回本次退出后需要重建到的新工作区。 +func (a App) PendingWorkspaceWorkdir() string { + return strings.TrimSpace(a.pendingWorkdir) +} + +func New( + cfg *config.Config, + configManager *config.Manager, + runtime agentruntime.Runtime, + providerSvc ProviderController, + workspaceSwitcher WorkspaceSwitcher, +) (App, error) { return NewWithBootstrap(tuibootstrap.Options{ - Config: cfg, - ConfigManager: configManager, - Runtime: runtime, - ProviderService: providerSvc, + Config: cfg, + ConfigManager: configManager, + Runtime: runtime, + ProviderService: providerSvc, + WorkspaceSwitcher: workspaceSwitcher, }) } @@ -135,6 +152,7 @@ func newApp(container tuibootstrap.Container) (App, error) { configManager := container.ConfigManager runtime := container.Runtime providerSvc := container.ProviderService + workspaceSwitcher := container.WorkspaceSwitcher uiStyles := newStyles() markdownRenderer, err := newMarkdownRenderer() @@ -209,9 +227,10 @@ func newApp(container tuibootstrap.Container) (App, error) { Focus: panelInput, }, appServices: appServices{ - configManager: configManager, - providerSvc: providerSvc, - runtime: runtime, + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + workspaceSwitcher: workspaceSwitcher, }, appComponents: appComponents{ keys: keys, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index e9face16..df0b1440 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -165,24 +165,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) - return a, tea.Batch(cmds...) - } - - a.state.ExecutionError = "" - 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...) + case workspaceSwitchResultMsg: + if cmd := a.applyWorkspaceSwitchResult(typed); cmd != nil { + cmds = append(cmds, cmd) } - a.appendActivity("workspace", typed.Notice, "", false) return a, tea.Batch(cmds...) case workspaceCommandResultMsg: if typed.Command == "" && typed.Err != nil { @@ -308,7 +294,13 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te effectiveTyped = tea.KeyMsg{Type: tea.KeyEnter, Paste: true} } else { input := strings.TrimSpace(a.input.Value()) - if input == "" || a.isBusy() { + if input == "" { + return a, tea.Batch(cmds...) + } + if a.isBusy() { + if a.rejectBusyWorkspaceSwitch(input) { + return a, tea.Batch(cmds...) + } return a, tea.Batch(cmds...) } @@ -353,7 +345,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if isWorkspaceSlashCommand(input) { a.state.StatusText = statusApplyingCommand a.state.ExecutionError = "" - cmds = append(cmds, runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, input)) + cmds = append(cmds, runWorkspaceSwitchCommand(a.state.CurrentWorkdir, a.configManager.Get().Workdir, input)) return a, tea.Batch(cmds...) } a.state.StatusText = statusApplyingCommand @@ -599,7 +591,7 @@ func (a *App) refreshMessages() error { a.activeMessages = session.Messages a.clearActivities() a.state.ActiveSessionTitle = session.Title - a.state.CurrentWorkdir = tuiworkspace.SelectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) + a.state.CurrentWorkdir = a.configManager.Get().Workdir a.refreshRuntimeSourceSnapshot() return nil } @@ -772,9 +764,6 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool if strings.TrimSpace(mapped.Model) != "" { a.state.CurrentModel = mapped.Model } - if strings.TrimSpace(mapped.Workdir) != "" { - a.state.CurrentWorkdir = mapped.Workdir - } return false } @@ -1567,26 +1556,21 @@ func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, work ) } -func runSessionWorkdirCommand( - runtime agentruntime.Runtime, - sessionID string, - currentWorkdir string, - raw string, -) tea.Cmd { +// runWorkspaceSwitchCommand 负责解析 `/cwd`,并在需要时启动新工作区进程。 +func runWorkspaceSwitchCommand(currentWorkdir string, startupWorkdir string, raw string) tea.Cmd { + result := tuicommands.ExecuteWorkspaceSwitchCommand( + currentWorkdir, + startupWorkdir, + raw, + parseWorkspaceSlashCommand, + tuiworkspace.ResolveWorkspacePath, + ) return func() tea.Msg { - result := tuicommands.ExecuteSessionWorkdirCommand( - runtime, - sessionID, - currentWorkdir, - raw, - parseWorkspaceSlashCommand, - tuiworkspace.ResolveWorkspacePath, - tuiworkspace.SelectSessionWorkdir, - ) - return sessionWorkdirResultMsg{ - Notice: result.Notice, - Workdir: result.Workdir, - Err: result.Err, + return workspaceSwitchResultMsg{ + Notice: result.Notice, + Workdir: result.Workdir, + Relaunched: result.Relaunch, + Err: result.Err, } } } @@ -1604,3 +1588,47 @@ func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { func (a App) isBusy() bool { return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) } + +// rejectBusyWorkspaceSwitch 负责在运行中拒绝 `/cwd`,并给出统一提示。 +func (a *App) rejectBusyWorkspaceSwitch(input string) bool { + if !isWorkspaceSlashCommand(input) { + return false + } + + errText := "workspace switch is unavailable while agent is busy" + a.state.ExecutionError = errText + a.state.StatusText = errText + a.appendActivity("workspace", "Workspace switch rejected", errText, true) + return true +} + +// applyWorkspaceSwitchResult 负责消费工作区切换结果,并在成功拉起新进程后退出当前 TUI。 +func (a *App) applyWorkspaceSwitchResult(result workspaceSwitchResultMsg) tea.Cmd { + if result.Err != nil { + a.state.ExecutionError = result.Err.Error() + a.state.StatusText = result.Err.Error() + a.appendActivity("workspace", "Workspace switch failed", result.Err.Error(), true) + return nil + } + + a.state.ExecutionError = "" + a.state.StatusText = result.Notice + if !result.Relaunched { + nextWorkdir := strings.TrimSpace(result.Workdir) + if nextWorkdir != "" && nextWorkdir != strings.TrimSpace(a.state.CurrentWorkdir) { + a.state.CurrentWorkdir = nextWorkdir + 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 nil + } + } + a.appendActivity("workspace", result.Notice, "", false) + return nil + } + + a.appendActivity("workspace", result.Notice, "", false) + a.pendingWorkdir = strings.TrimSpace(result.Workdir) + return tea.Quit +} diff --git a/internal/tui/core/app/workspace_switch_test.go b/internal/tui/core/app/workspace_switch_test.go new file mode 100644 index 00000000..6c79466c --- /dev/null +++ b/internal/tui/core/app/workspace_switch_test.go @@ -0,0 +1,589 @@ +package tui + +import ( + "context" + "errors" + "os" + "path/filepath" + "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" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" +) + +type workspaceSwitchTestRuntime struct { + sessions []agentsession.Summary + loadedSessions map[string]agentsession.Session + loadErr error + sessionContext any + sessionUsage any + runSnapshot any +} + +func (r *workspaceSwitchTestRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *workspaceSwitchTestRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *workspaceSwitchTestRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r *workspaceSwitchTestRuntime) CancelActiveRun() bool { + return false +} + +func (r *workspaceSwitchTestRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *workspaceSwitchTestRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return append([]agentsession.Summary(nil), r.sessions...), nil +} + +func (r *workspaceSwitchTestRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + if r.loadErr != nil { + return agentsession.Session{}, r.loadErr + } + if r.loadedSessions == nil { + return agentsession.Session{}, nil + } + return r.loadedSessions[id], nil +} + +func (r *workspaceSwitchTestRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r *workspaceSwitchTestRuntime) GetSessionContext(ctx context.Context, sessionID string) (any, error) { + return r.sessionContext, nil +} + +func (r *workspaceSwitchTestRuntime) GetSessionUsage(ctx context.Context, sessionID string) (any, error) { + return r.sessionUsage, nil +} + +func (r *workspaceSwitchTestRuntime) GetRunSnapshot(ctx context.Context, runID string) (any, error) { + return r.runSnapshot, nil +} + +type workspaceSwitchTestProvider struct{} + +func (s *workspaceSwitchTestProvider) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s *workspaceSwitchTestProvider) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s *workspaceSwitchTestProvider) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *workspaceSwitchTestProvider) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *workspaceSwitchTestProvider) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +type workspaceSwitchTestSwitcher struct { + calls int + lastWorkdir string + err error +} + +func (s *workspaceSwitchTestSwitcher) SwitchWorkspace(ctx context.Context, workdir string) error { + s.calls++ + s.lastWorkdir = workdir + return s.err +} + +func TestAppRejectsWorkspaceSwitchWhenBusy(t *testing.T) { + app, switcher := newWorkspaceSwitchTestApp(t) + app.state.IsAgentRunning = true + app.input.SetValue("/cwd next") + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated := model.(App) + + if switcher.calls != 0 { + t.Fatalf("expected busy path to skip switcher, got %d calls", switcher.calls) + } + if !strings.Contains(updated.state.ExecutionError, "workspace switch is unavailable") { + t.Fatalf("expected busy rejection error, got %q", updated.state.ExecutionError) + } + if len(updated.activities) == 0 || updated.activities[len(updated.activities)-1].Title != "Workspace switch rejected" { + t.Fatalf("expected rejection activity, got %+v", updated.activities) + } +} + +func TestAppWorkspaceSwitchSuccessQuitsCurrentProgram(t *testing.T) { + app, switcher := newWorkspaceSwitchTestApp(t) + target := t.TempDir() + app.input.SetValue("/cwd " + target) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("expected workspace switch command") + } + updated := model.(App) + + msg := cmd() + result, ok := msg.(workspaceSwitchResultMsg) + if !ok { + t.Fatalf("expected workspace switch result msg, got %T", msg) + } + if !result.Relaunched || result.Workdir != target { + t.Fatalf("unexpected switch result: %+v", result) + } + if switcher.calls != 0 { + t.Fatalf("expected relaunch path to defer restart outside TUI, got %d switcher calls", switcher.calls) + } + + model, quitCmd := updated.Update(msg) + updated = model.(App) + if !strings.Contains(updated.state.StatusText, "Switching workspace") { + t.Fatalf("expected switching notice, got %q", updated.state.StatusText) + } + if quitCmd == nil { + t.Fatalf("expected quit command after successful relaunch") + } + if _, ok := quitCmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg from quit command") + } + if updated.PendingWorkspaceWorkdir() != target { + t.Fatalf("expected pending relaunch workdir %q, got %q", target, updated.PendingWorkspaceWorkdir()) + } +} + +func TestAppWorkspaceSwitchFailureKeepsCurrentProgram(t *testing.T) { + app, _ := newWorkspaceSwitchTestApp(t) + target := t.TempDir() + originalWorkdir := app.state.CurrentWorkdir + app.input.SetValue("/cwd " + target) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("expected workspace switch command") + } + updated := model.(App) + + msg := cmd() + result, ok := msg.(workspaceSwitchResultMsg) + if !ok { + t.Fatalf("expected workspace switch result msg, got %T", msg) + } + if result.Err != nil { + t.Fatalf("expected relaunch request to be deferred without start error, got %+v", result) + } + + model, quitCmd := updated.Update(msg) + updated = model.(App) + if quitCmd == nil { + t.Fatalf("expected relaunch request to quit current program") + } + if _, ok := quitCmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg from quit command") + } + if updated.state.CurrentWorkdir != originalWorkdir { + t.Fatalf("expected displayed workdir to remain %q before relaunch, got %q", originalWorkdir, updated.state.CurrentWorkdir) + } + if updated.PendingWorkspaceWorkdir() != target { + t.Fatalf("expected pending relaunch workdir %q, got %q", target, updated.PendingWorkspaceWorkdir()) + } + if updated.state.ExecutionError != "" { + t.Fatalf("expected no immediate relaunch error, got %q", updated.state.ExecutionError) + } +} + +func TestAppWorkspaceSwitchNoopDoesNotRelaunch(t *testing.T) { + app, switcher := newWorkspaceSwitchTestApp(t) + currentWorkdir := app.state.CurrentWorkdir + app.input.SetValue("/cwd .") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("expected workspace command for noop path") + } + updated := model.(App) + + msg := cmd() + result, ok := msg.(workspaceSwitchResultMsg) + if !ok { + t.Fatalf("expected workspace switch result msg, got %T", msg) + } + if result.Relaunched { + t.Fatalf("expected noop path to skip relaunch, got %+v", result) + } + if switcher.calls != 0 { + t.Fatalf("expected noop path to skip switcher, got %d calls", switcher.calls) + } + + model, quitCmd := updated.Update(msg) + updated = model.(App) + if quitCmd != nil { + t.Fatalf("expected noop path to stay in current program") + } + if updated.state.CurrentWorkdir != currentWorkdir { + t.Fatalf("expected workdir to remain %q, got %q", currentWorkdir, updated.state.CurrentWorkdir) + } + if !strings.Contains(updated.state.StatusText, "Workspace already set") { + t.Fatalf("expected noop status notice, got %q", updated.state.StatusText) + } +} + +func TestAppRefreshMessagesKeepsStartupWorkdir(t *testing.T) { + runtime := &workspaceSwitchTestRuntime{ + sessions: []agentsession.Summary{ + {ID: "session-1", Title: "Existing Session"}, + }, + loadedSessions: map[string]agentsession.Session{ + "session-1": { + ID: "session-1", + Title: "Existing Session", + Messages: []providertypes.Message{ + {Role: roleUser, Content: "hello"}, + }, + }, + }, + sessionContext: tuiservices.RuntimeSessionContextSnapshot{ + SessionID: "session-1", + Provider: "openai", + Model: "gpt-5.4", + Workdir: "/session/subdir", + Mode: "act", + }, + } + + app, _, cfg := newWorkspaceSwitchTestAppWithRuntime(t, runtime) + if app.state.ActiveSessionID != "session-1" { + t.Fatalf("expected active session to be restored, got %q", app.state.ActiveSessionID) + } + if app.state.CurrentWorkdir != cfg.Workdir { + t.Fatalf("expected current workdir to stay on startup workdir %q, got %q", cfg.Workdir, app.state.CurrentWorkdir) + } + if app.state.RunContext.Workdir != "/session/subdir" { + t.Fatalf("expected run context workdir from runtime snapshot, got %q", app.state.RunContext.Workdir) + } + if app.state.ActiveSessionTitle != "Existing Session" { + t.Fatalf("expected session title to be restored, got %q", app.state.ActiveSessionTitle) + } +} + +func TestRuntimeEventRunContextDoesNotOverrideCurrentWorkdir(t *testing.T) { + app, _, cfg := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + currentWorkdir := cfg.Workdir + targetWorkdir := filepath.Join(t.TempDir(), "runtime-subdir") + + handled := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ + Type: agentruntime.EventType(tuiservices.RuntimeEventRunContext), + RunID: "run-1", + SessionID: "session-1", + Payload: tuiservices.RuntimeRunContextPayload{ + Provider: "openai", + Model: "gpt-5.4-mini", + Workdir: targetWorkdir, + Mode: "act", + }, + }) + if handled { + t.Fatalf("expected run context handler to keep transcript clean") + } + if app.state.CurrentWorkdir != currentWorkdir { + t.Fatalf("expected current workdir to remain %q, got %q", currentWorkdir, app.state.CurrentWorkdir) + } + if app.state.RunContext.Workdir != targetWorkdir { + t.Fatalf("expected run context workdir %q, got %q", targetWorkdir, app.state.RunContext.Workdir) + } + if app.state.ActiveRunID != "run-1" { + t.Fatalf("expected active run id to update, got %q", app.state.ActiveRunID) + } +} + +func TestAppRejectBusyWorkspaceSwitchIgnoresNonWorkspaceInput(t *testing.T) { + app, _, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + + if app.rejectBusyWorkspaceSwitch("/model") { + t.Fatalf("expected non-workspace slash command to be ignored") + } + if app.state.ExecutionError != "" { + t.Fatalf("expected execution error to stay empty, got %q", app.state.ExecutionError) + } +} + +func TestAppApplyWorkspaceSwitchResultUpdatesDisplayedWorkspace(t *testing.T) { + app, _, cfg := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + nextWorkdir := t.TempDir() + if err := os.WriteFile(filepath.Join(nextWorkdir, "README.md"), []byte("workspace"), 0o644); err != nil { + t.Fatalf("write workspace file: %v", err) + } + app.state.CurrentWorkdir = filepath.Join(cfg.Workdir, "session-subdir") + + cmd := app.applyWorkspaceSwitchResult(workspaceSwitchResultMsg{ + Notice: "Workspace already set to " + nextWorkdir, + Workdir: nextWorkdir, + Relaunched: false, + }) + if cmd != nil { + t.Fatalf("expected no quit command for non-relaunch path") + } + if app.state.CurrentWorkdir != nextWorkdir { + t.Fatalf("expected workdir to update to %q, got %q", nextWorkdir, app.state.CurrentWorkdir) + } + if app.fileBrowser.CurrentDirectory != nextWorkdir { + t.Fatalf("expected file browser directory %q, got %q", nextWorkdir, app.fileBrowser.CurrentDirectory) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title == "" { + t.Fatalf("expected workspace activity to be appended") + } +} + +func TestAppApplyWorkspaceSwitchResultRefreshFailure(t *testing.T) { + app, _, cfg := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + missingWorkdir := filepath.Join(t.TempDir(), "missing") + app.state.CurrentWorkdir = filepath.Join(cfg.Workdir, "session-subdir") + + cmd := app.applyWorkspaceSwitchResult(workspaceSwitchResultMsg{ + Notice: "Workspace already set to " + missingWorkdir, + Workdir: missingWorkdir, + Relaunched: false, + }) + if cmd != nil { + t.Fatalf("expected no quit command when refresh fails") + } + if !strings.Contains(app.state.ExecutionError, "missing") { + t.Fatalf("expected missing directory error, got %q", app.state.ExecutionError) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Failed to refresh workspace files" { + t.Fatalf("expected refresh failure activity, got %+v", app.activities) + } +} + +func TestRunWorkspaceSwitchCommandReturnsValidationError(t *testing.T) { + cmd := runWorkspaceSwitchCommand("", "", "/cwd nowhere") + if cmd == nil { + t.Fatalf("expected workspace switch command") + } + msg := cmd() + result, ok := msg.(workspaceSwitchResultMsg) + if !ok { + t.Fatalf("expected workspace switch result msg, got %T", msg) + } + if result.Err == nil { + t.Fatalf("expected validation error, got %+v", result) + } +} + +func TestRefreshMessagesClearsDraftStateWithoutActiveSession(t *testing.T) { + app, _, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + app.activeMessages = []providertypes.Message{{Role: roleUser, Content: "hello"}} + app.activities = []tuistate.ActivityEntry{{Title: "existing"}} + app.state.ActiveSessionID = "" + + if err := app.refreshMessages(); err != nil { + t.Fatalf("refreshMessages() error = %v", err) + } + if len(app.activeMessages) != 0 { + t.Fatalf("expected active messages to clear, got %+v", app.activeMessages) + } + if len(app.activities) != 0 { + t.Fatalf("expected activities to clear, got %+v", app.activities) + } +} + +func TestRefreshMessagesReturnsLoadError(t *testing.T) { + app, _, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{ + loadErr: errors.New("load failed"), + }) + app.state.ActiveSessionID = "session-1" + + err := app.refreshMessages() + if err == nil || err.Error() != "load failed" { + t.Fatalf("expected load error, got %v", err) + } +} + +func TestStartDraftSessionRestoresStartupWorkdir(t *testing.T) { + app, _, cfg := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + app.state.ActiveSessionID = "session-1" + app.state.ActiveSessionTitle = "Existing Session" + app.state.IsCompacting = true + app.state.CurrentTool = "filesystem" + app.state.ActiveRunID = "run-1" + app.state.RunContext = tuistate.ContextWindowState{RunID: "run-1", Workdir: "/session/subdir"} + app.state.TokenUsage = tuistate.TokenUsageState{RunTotalTokens: 123} + app.state.CurrentWorkdir = filepath.Join(cfg.Workdir, "session-subdir") + app.input.SetValue("draft text") + app.state.InputText = "draft text" + app.activeMessages = []providertypes.Message{{Role: roleUser, Content: "hello"}} + app.activities = []tuistate.ActivityEntry{{Title: "existing"}} + + app.startDraftSession() + + if app.state.ActiveSessionID != "" { + t.Fatalf("expected draft session id to be cleared, got %q", app.state.ActiveSessionID) + } + if app.state.CurrentWorkdir != cfg.Workdir { + t.Fatalf("expected current workdir to reset to startup workdir %q, got %q", cfg.Workdir, app.state.CurrentWorkdir) + } + if app.state.RunContext != (tuistate.ContextWindowState{}) { + t.Fatalf("expected run context to reset, got %+v", app.state.RunContext) + } + if app.state.TokenUsage != (tuistate.TokenUsageState{}) { + t.Fatalf("expected token usage to reset, got %+v", app.state.TokenUsage) + } + if app.input.Value() != "" || app.state.InputText != "" { + t.Fatalf("expected draft input to be cleared") + } +} + +func TestSyncConfigStatePreservesExplicitCurrentWorkdir(t *testing.T) { + app, _, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + app.state.CurrentWorkdir = "/explicit-workspace" + + app.syncConfigState(config.Config{ + SelectedProvider: "provider-b", + CurrentModel: "model-b", + Workdir: "/config-workspace", + }) + + if app.state.CurrentProvider != "provider-b" || app.state.CurrentModel != "model-b" { + t.Fatalf("expected provider and model to sync, got provider=%q model=%q", app.state.CurrentProvider, app.state.CurrentModel) + } + if app.state.CurrentWorkdir != "/explicit-workspace" { + t.Fatalf("expected explicit workdir to be preserved, got %q", app.state.CurrentWorkdir) + } +} + +func TestSyncConfigStateSetsWorkdirWhenEmpty(t *testing.T) { + app, _, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + app.state.CurrentWorkdir = "" + + app.syncConfigState(config.Config{ + SelectedProvider: "provider-b", + CurrentModel: "model-b", + Workdir: "/config-workspace", + }) + + if app.state.CurrentWorkdir != "/config-workspace" { + t.Fatalf("expected blank workdir to sync from config, got %q", app.state.CurrentWorkdir) + } +} + +func TestAppConstructorsAndInit(t *testing.T) { + cfg, manager := newWorkspaceSwitchTestConfigManager(t) + runtime := &workspaceSwitchTestRuntime{} + switcher := &workspaceSwitchTestSwitcher{} + + app, err := New(&cfg, manager, runtime, &workspaceSwitchTestProvider{}, switcher) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if app.workspaceSwitcher != switcher { + t.Fatalf("expected workspace switcher to be injected") + } + + appWithBootstrap, err := NewWithBootstrap(tuibootstrap.Options{ + Config: &cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: &workspaceSwitchTestProvider{}, + WorkspaceSwitcher: switcher, + }) + if err != nil { + t.Fatalf("NewWithBootstrap() error = %v", err) + } + if appWithBootstrap.state.CurrentWorkdir != cfg.Workdir { + t.Fatalf("expected bootstrap app workdir %q, got %q", cfg.Workdir, appWithBootstrap.state.CurrentWorkdir) + } + if cmd := appWithBootstrap.Init(); cmd == nil { + t.Fatalf("expected Init() to return startup commands") + } +} + +func TestNewWithBootstrapReturnsBuildError(t *testing.T) { + cfg, manager := newWorkspaceSwitchTestConfigManager(t) + + _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: &cfg, + ConfigManager: manager, + Runtime: &workspaceSwitchTestRuntime{}, + ProviderService: &workspaceSwitchTestProvider{}, + }) + if err == nil { + t.Fatalf("expected bootstrap error when workspace switcher is missing") + } +} + +func TestHandleRuntimeEventUnknownTypeStillPinsSession(t *testing.T) { + app, _, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + app.state.ActiveSessionID = "" + + handled := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ + Type: agentruntime.EventType("unknown"), + SessionID: "session-unknown", + }) + if handled { + t.Fatalf("expected unknown event type to be ignored") + } + if app.state.ActiveSessionID != "session-unknown" { + t.Fatalf("expected active session to pin from first runtime event, got %q", app.state.ActiveSessionID) + } +} + +func newWorkspaceSwitchTestApp(t *testing.T) (App, *workspaceSwitchTestSwitcher) { + app, switcher, _ := newWorkspaceSwitchTestAppWithRuntime(t, &workspaceSwitchTestRuntime{}) + return app, switcher +} + +func newWorkspaceSwitchTestAppWithRuntime( + t *testing.T, + runtime *workspaceSwitchTestRuntime, +) (App, *workspaceSwitchTestSwitcher, config.Config) { + t.Helper() + + cfg, manager := newWorkspaceSwitchTestConfigManager(t) + + switcher := &workspaceSwitchTestSwitcher{} + app, err := newApp(tuibootstrap.Container{ + Config: cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: &workspaceSwitchTestProvider{}, + WorkspaceSwitcher: switcher, + Mode: tuibootstrap.ModeLive, + }) + if err != nil { + t.Fatalf("newApp() error = %v", err) + } + return app, switcher, cfg +} + +func newWorkspaceSwitchTestConfigManager(t *testing.T) (config.Config, *config.Manager) { + t.Helper() + + cfg := config.DefaultConfig().Clone() + cfg.Workdir = t.TempDir() + + manager := config.NewManager(config.NewLoader(t.TempDir(), &cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) + } + return cfg, manager +} diff --git a/internal/tui/core/commands/workspace.go b/internal/tui/core/commands/workspace.go index 08be31d1..9c5ebd87 100644 --- a/internal/tui/core/commands/workspace.go +++ b/internal/tui/core/commands/workspace.go @@ -1,70 +1,80 @@ package commands import ( - "context" "fmt" + "path/filepath" + goruntime "runtime" "strings" - - agentsession "neo-code/internal/session" ) -// SessionWorkdirSetter 定义设置会话工作目录所需的最小 runtime 能力。 -type SessionWorkdirSetter interface { - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) -} - -// SessionWorkdirCommandResult 表示工作目录命令执行结果。 -type SessionWorkdirCommandResult struct { - Notice string - Workdir string - Err error +// WorkspaceSwitchCommandResult 表示 `/cwd` 命令解析后的统一结果。 +type WorkspaceSwitchCommandResult struct { + Notice string + Workdir string + Relaunch bool + Err error } -// ExecuteSessionWorkdirCommand 执行 /cwd 命令的核心流程,返回统一结果结构。 -func ExecuteSessionWorkdirCommand( - runtime SessionWorkdirSetter, - sessionID string, +// ExecuteWorkspaceSwitchCommand 负责解析 `/cwd`,并产出是否需要重启到新工作区的结果。 +func ExecuteWorkspaceSwitchCommand( currentWorkdir string, + startupWorkdir string, raw string, parseCommand func(string) (string, error), resolveWorkspacePath func(string, string) (string, error), - selectSessionWorkdir func(string, string) string, -) SessionWorkdirCommandResult { +) WorkspaceSwitchCommandResult { requested, err := parseCommand(raw) if err != nil { - return SessionWorkdirCommandResult{Err: err} + return WorkspaceSwitchCommandResult{Err: err} } + currentWorkdir = strings.TrimSpace(currentWorkdir) + startupWorkdir = strings.TrimSpace(startupWorkdir) + activeWorkspaceRoot := startupWorkdir + if activeWorkspaceRoot == "" { + activeWorkspaceRoot = currentWorkdir + } if strings.TrimSpace(requested) == "" { - workdir := strings.TrimSpace(currentWorkdir) + workdir := activeWorkspaceRoot if workdir == "" { - return SessionWorkdirCommandResult{Err: fmt.Errorf("usage: /cwd ")} + return WorkspaceSwitchCommandResult{Err: fmt.Errorf("usage: /cwd ")} } - return SessionWorkdirCommandResult{ + return WorkspaceSwitchCommandResult{ 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, + resolvedWorkdir, err := resolveWorkspacePath(activeWorkspaceRoot, requested) + if err != nil { + return WorkspaceSwitchCommandResult{Err: err} + } + if sameWorkspacePath(resolvedWorkdir, activeWorkspaceRoot) { + return WorkspaceSwitchCommandResult{ + Notice: fmt.Sprintf("[System] Workspace already set to %s.", resolvedWorkdir), + Workdir: resolvedWorkdir, } } - session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) - if err != nil { - return SessionWorkdirCommandResult{Err: err} + return WorkspaceSwitchCommandResult{ + Notice: fmt.Sprintf("[System] Switching workspace to %s.", resolvedWorkdir), + Workdir: resolvedWorkdir, + Relaunch: true, + } +} + +// sameWorkspacePath 负责在跨平台场景下比较两个工作区路径是否指向同一目录。 +func sameWorkspacePath(left string, right string) bool { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + if left == "" || right == "" { + return false } - workdir := selectSessionWorkdir(session.Workdir, currentWorkdir) - return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), - Workdir: workdir, + normalizedLeft := filepath.Clean(left) + normalizedRight := filepath.Clean(right) + if goruntime.GOOS == "windows" { + return strings.EqualFold(normalizedLeft, normalizedRight) } + return normalizedLeft == normalizedRight } diff --git a/internal/tui/core/commands/workspace_test.go b/internal/tui/core/commands/workspace_test.go index 4ed48934..a4694e5f 100644 --- a/internal/tui/core/commands/workspace_test.go +++ b/internal/tui/core/commands/workspace_test.go @@ -1,32 +1,17 @@ package commands import ( - "context" "errors" "os" "path/filepath" + goruntime "runtime" "strings" "testing" - agentsession "neo-code/internal/session" tuiworkspace "neo-code/internal/tui/core/workspace" ) -type stubSessionWorkdirSetter struct { - session agentsession.Session - err error - calls int -} - -func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - s.calls++ - if s.err != nil { - return agentsession.Session{}, s.err - } - return s.session, nil -} - -func TestExecuteSessionWorkdirCommand(t *testing.T) { +func TestExecuteWorkspaceSwitchCommand(t *testing.T) { parse := func(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "/bad" { @@ -42,14 +27,14 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { } t.Run("parse error", func(t *testing.T) { - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "/bad", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + result := ExecuteWorkspaceSwitchCommand("", "", "/bad", parse, tuiworkspace.ResolveWorkspacePath) 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) + result := ExecuteWorkspaceSwitchCommand("", "", "/cwd", parse, tuiworkspace.ResolveWorkspacePath) if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { t.Fatalf("expected usage error, got %+v", result) } @@ -57,51 +42,92 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Run("empty requested with current workdir", func(t *testing.T) { current := t.TempDir() - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", current, "/cwd", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + result := ExecuteWorkspaceSwitchCommand(current, current, "/cwd", parse, tuiworkspace.ResolveWorkspacePath) 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) } + if result.Relaunch { + t.Fatalf("expected no relaunch for query only") + } }) - t.Run("draft session resolves requested path", func(t *testing.T) { + t.Run("requested path resolves to relaunch target", 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) + + result := ExecuteWorkspaceSwitchCommand(base, base, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath) 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) + if !result.Relaunch || result.Workdir != target { + t.Fatalf("expected relaunch to %q, got %+v", target, result) } }) - 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("requested path matching startup workdir is noop", func(t *testing.T) { + startup := t.TempDir() + current := filepath.Join(startup, "session-subdir") + if err := ensureDir(current); err != nil { + t.Fatalf("mkdir current: %v", err) + } + + result := ExecuteWorkspaceSwitchCommand(current, startup, "/cwd .", parse, tuiworkspace.ResolveWorkspacePath) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if result.Relaunch { + t.Fatalf("expected noop when switching to startup workdir, got %+v", result) + } + if result.Workdir != startup { + t.Fatalf("expected startup workdir %q, got %q", startup, result.Workdir) + } + if !strings.Contains(result.Notice, "Workspace already set") { + t.Fatalf("unexpected notice: %q", result.Notice) } }) - t.Run("runtime empty workdir fallback", func(t *testing.T) { - current := t.TempDir() - stub := &stubSessionWorkdirSetter{session: agentsession.Session{ID: "session-1", Workdir: ""}} - result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + t.Run("relative path resolves from startup workdir", func(t *testing.T) { + startup := t.TempDir() + current := filepath.Join(startup, "session-subdir") + target := filepath.Join(startup, "next-workspace") + if err := ensureDir(current); err != nil { + t.Fatalf("mkdir current: %v", err) + } + if err := ensureDir(target); err != nil { + t.Fatalf("mkdir target: %v", err) + } + + result := ExecuteWorkspaceSwitchCommand(current, startup, "/cwd next-workspace", parse, tuiworkspace.ResolveWorkspacePath) 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.Relaunch || result.Workdir != target { + t.Fatalf("expected startup-relative relaunch to %q, got %+v", target, result) } }) } +func TestSameWorkspacePath(t *testing.T) { + left := filepath.Clean("/tmp/project") + right := filepath.Clean("/tmp/project") + if goruntime.GOOS == "windows" { + left = `C:\Repo` + right = `c:\repo` + } + if !sameWorkspacePath(left, right) { + t.Fatalf("expected %q and %q to match", left, right) + } + if sameWorkspacePath(left, "") { + t.Fatalf("expected blank path to never match") + } +} + func ensureDir(path string) error { return os.MkdirAll(path, 0o755) } diff --git a/internal/tui/docs/LAYERING.md b/internal/tui/docs/LAYERING.md index 4ff7b040..e28897f5 100644 --- a/internal/tui/docs/LAYERING.md +++ b/internal/tui/docs/LAYERING.md @@ -75,9 +75,9 @@ ## Iteration 6 补充(Bootstrap 落地) -- `internal/tui/bootstrap` 已提供 `Build` 装配入口,统一完成 `ConfigManager + Runtime + ProviderService` 注入。 +- `internal/tui/bootstrap` 已提供 `Build` 装配入口,统一完成 `ConfigManager + Runtime + ProviderService + WorkspaceSwitcher` 注入。 - 支持 `Mode`(`live/offline/mock`)与 `ServiceFactory` 扩展点,可在不修改 `core` 的情况下替换注入实现。 -- `internal/tui.New(...)` 保持兼容签名,对外作为薄封装;实际装配路径为 `New -> bootstrap.Build -> newApp`。 +- `internal/tui.New(...)` 继续作为薄封装暴露给上层;实际装配路径为 `New -> bootstrap.Build -> newApp`,并显式接收 `WorkspaceSwitcher`。 ## Iteration 7 补充(Runtime Source 收敛) diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go index 1e9775ec..31609b87 100644 --- a/internal/tui/infra/infra_test.go +++ b/internal/tui/infra/infra_test.go @@ -271,7 +271,7 @@ func TestDefaultWorkspaceCommandExecutor(t *testing.T) { cfg := config.Config{ Workdir: workdir, Shell: shellName, - ToolTimeoutSec: 1, + ToolTimeoutSec: 10, } if _, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", " "); err == nil { @@ -328,11 +328,11 @@ func workspaceExecutorCommands() (shell string, success string, noOutput string, "Write-Output 'OK'", "$null = 1", "Write-Error 'failed'; exit 2", - "Start-Sleep -Seconds 2" + "Start-Sleep -Seconds 12" } return "bash", "printf 'OK\\n'", "true", "echo failed 1>&2; exit 2", - "sleep 2" + "sleep 12" } diff --git a/internal/tui/infra/workspace_switch.go b/internal/tui/infra/workspace_switch.go new file mode 100644 index 00000000..0f46551e --- /dev/null +++ b/internal/tui/infra/workspace_switch.go @@ -0,0 +1,84 @@ +package infra + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +type executableResolver func() (string, error) +type processStarter func(processLaunchSpec) error + +type processLaunchSpec struct { + Path string + Args []string + Dir string + Env []string +} + +// ProcessWorkspaceSwitcher 负责以当前可执行文件重新拉起 NeoCode,并切换到新工作区。 +type ProcessWorkspaceSwitcher struct { + resolveExecutable executableResolver + startProcess processStarter +} + +// NewProcessWorkspaceSwitcher 创建默认的进程级工作区切换器。 +func NewProcessWorkspaceSwitcher() *ProcessWorkspaceSwitcher { + return &ProcessWorkspaceSwitcher{ + resolveExecutable: os.Executable, + startProcess: startWorkspaceProcess, + } +} + +// SwitchWorkspace 拉起带 `--workdir` 参数的新进程,并在成功启动后返回。 +func (s *ProcessWorkspaceSwitcher) SwitchWorkspace(ctx context.Context, workdir string) error { + if err := ctx.Err(); err != nil { + return err + } + + targetWorkdir := strings.TrimSpace(workdir) + if targetWorkdir == "" { + return fmt.Errorf("workspace switch: workdir is empty") + } + if s == nil { + return fmt.Errorf("workspace switch: switcher is nil") + } + if s.resolveExecutable == nil { + return fmt.Errorf("workspace switch: executable resolver is nil") + } + if s.startProcess == nil { + return fmt.Errorf("workspace switch: process starter is nil") + } + + executablePath, err := s.resolveExecutable() + if err != nil { + return fmt.Errorf("workspace switch: resolve executable: %w", err) + } + if err := ctx.Err(); err != nil { + return err + } + + spec := processLaunchSpec{ + Path: executablePath, + Args: []string{"--workdir", targetWorkdir}, + Dir: targetWorkdir, + Env: os.Environ(), + } + if err := s.startProcess(spec); err != nil { + return fmt.Errorf("workspace switch: start process: %w", err) + } + return nil +} + +// startWorkspaceProcess 使用当前终端句柄启动新的 NeoCode 进程。 +func startWorkspaceProcess(spec processLaunchSpec) error { + cmd := exec.Command(spec.Path, spec.Args...) + cmd.Dir = spec.Dir + cmd.Env = append([]string(nil), spec.Env...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Start() +} diff --git a/internal/tui/infra/workspace_switch_test.go b/internal/tui/infra/workspace_switch_test.go new file mode 100644 index 00000000..c922e825 --- /dev/null +++ b/internal/tui/infra/workspace_switch_test.go @@ -0,0 +1,144 @@ +package infra + +import ( + "context" + "errors" + "os" + "reflect" + "testing" +) + +func TestProcessWorkspaceSwitcherSwitchWorkspace(t *testing.T) { + t.Run("constructor wires defaults", func(t *testing.T) { + switcher := NewProcessWorkspaceSwitcher() + if switcher == nil { + t.Fatalf("expected switcher instance") + } + if switcher.resolveExecutable == nil || switcher.startProcess == nil { + t.Fatalf("expected constructor to wire default dependencies") + } + }) + + t.Run("empty workdir is rejected", func(t *testing.T) { + switcher := &ProcessWorkspaceSwitcher{} + err := switcher.SwitchWorkspace(context.Background(), " ") + if err == nil || err.Error() != "workspace switch: workdir is empty" { + t.Fatalf("expected empty workdir error, got %v", err) + } + }) + + t.Run("nil receiver is rejected", func(t *testing.T) { + var switcher *ProcessWorkspaceSwitcher + err := switcher.SwitchWorkspace(context.Background(), "/repo") + if err == nil || err.Error() != "workspace switch: switcher is nil" { + t.Fatalf("expected nil switcher error, got %v", err) + } + }) + + t.Run("missing executable resolver is rejected", func(t *testing.T) { + switcher := &ProcessWorkspaceSwitcher{} + err := switcher.SwitchWorkspace(context.Background(), "/repo") + if err == nil || err.Error() != "workspace switch: executable resolver is nil" { + t.Fatalf("expected missing resolver error, got %v", err) + } + }) + + t.Run("missing process starter is rejected", func(t *testing.T) { + switcher := &ProcessWorkspaceSwitcher{ + resolveExecutable: func() (string, error) { return "/tmp/neocode", nil }, + } + err := switcher.SwitchWorkspace(context.Background(), "/repo") + if err == nil || err.Error() != "workspace switch: process starter is nil" { + t.Fatalf("expected missing process starter error, got %v", err) + } + }) + + t.Run("resolver error is wrapped", func(t *testing.T) { + switcher := &ProcessWorkspaceSwitcher{ + resolveExecutable: func() (string, error) { return "", errors.New("resolve failed") }, + startProcess: func(spec processLaunchSpec) error { return nil }, + } + err := switcher.SwitchWorkspace(context.Background(), "/repo") + if err == nil || err.Error() != "workspace switch: resolve executable: resolve failed" { + t.Fatalf("expected wrapped resolver error, got %v", err) + } + }) + + t.Run("builds relaunch spec", func(t *testing.T) { + var captured processLaunchSpec + switcher := &ProcessWorkspaceSwitcher{ + resolveExecutable: func() (string, error) { return "/tmp/neocode", nil }, + startProcess: func(spec processLaunchSpec) error { + captured = spec + return nil + }, + } + + err := switcher.SwitchWorkspace(context.Background(), " /repo ") + if err != nil { + t.Fatalf("SwitchWorkspace() error = %v", err) + } + if captured.Path != "/tmp/neocode" { + t.Fatalf("expected executable path, got %+v", captured) + } + if captured.Dir != "/repo" { + t.Fatalf("expected workdir dir to be used, got %+v", captured) + } + if !reflect.DeepEqual(captured.Args, []string{"--workdir", "/repo"}) { + t.Fatalf("unexpected args: %+v", captured.Args) + } + if len(captured.Env) == 0 { + t.Fatalf("expected environment to be inherited") + } + }) + + t.Run("starter error is wrapped", func(t *testing.T) { + switcher := &ProcessWorkspaceSwitcher{ + resolveExecutable: func() (string, error) { return "/tmp/neocode", nil }, + startProcess: func(spec processLaunchSpec) error { + return errors.New("boom") + }, + } + + err := switcher.SwitchWorkspace(context.Background(), "/repo") + if err == nil || err.Error() != "workspace switch: start process: boom" { + t.Fatalf("expected wrapped start error, got %v", err) + } + }) + + t.Run("canceled context short circuits", func(t *testing.T) { + switcher := &ProcessWorkspaceSwitcher{ + resolveExecutable: func() (string, error) { + t.Fatal("resolver should not be called for canceled context") + return "", nil + }, + startProcess: func(spec processLaunchSpec) error { + t.Fatal("starter should not be called for canceled context") + return nil + }, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := switcher.SwitchWorkspace(ctx, "/repo"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + }) +} + +func TestStartWorkspaceProcessRejectsMissingExecutable(t *testing.T) { + spec := processLaunchSpec{ + Path: filepathBase("definitely-not-a-real-neocode-binary"), + Args: []string{"--workdir", os.TempDir()}, + Dir: os.TempDir(), + Env: os.Environ(), + } + + if err := startWorkspaceProcess(spec); err == nil { + t.Fatalf("expected missing executable to fail") + } +} + +func filepathBase(name string) string { + return name +} diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index cde184c9..d856ff7a 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -39,6 +39,16 @@ type stubProvider struct { err error } +type stubWorkspaceSwitcher struct { + lastWorkdir string + err error +} + +func (s *stubWorkspaceSwitcher) SwitchWorkspace(ctx context.Context, workdir string) error { + s.lastWorkdir = workdir + return s.err +} + func (s *stubProvider) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { return s.selection, s.err } @@ -149,6 +159,115 @@ func TestCommandCmds(t *testing.T) { if got, ok := workspaceMsg.(string); !ok || got != "git status:clean" { t.Fatalf("expected workspace command msg, got %T %#v", workspaceMsg, workspaceMsg) } + + switcher := &stubWorkspaceSwitcher{} + switchMsg := RunWorkspaceSwitchCmd( + switcher, + WorkspaceSwitchRequest{ + Notice: "switching", + Workdir: "/repo", + Relaunched: true, + }, + func(notice string, workdir string, relaunched bool, err error) tea.Msg { + return struct { + Notice string + Workdir string + Relaunched bool + Err error + }{Notice: notice, Workdir: workdir, Relaunched: relaunched, Err: err} + }, + )() + gotSwitch, ok := switchMsg.(struct { + Notice string + Workdir string + Relaunched bool + Err error + }) + if !ok || gotSwitch.Notice != "switching" || gotSwitch.Workdir != "/repo" || !gotSwitch.Relaunched { + t.Fatalf("unexpected workspace switch msg: %T %#v", switchMsg, switchMsg) + } + if switcher.lastWorkdir != "/repo" { + t.Fatalf("expected switcher to receive workdir, got %q", switcher.lastWorkdir) + } + + switchMsg = RunWorkspaceSwitchCmd( + nil, + WorkspaceSwitchRequest{ + Notice: "noop", + Workdir: "/same", + Relaunched: false, + }, + func(notice string, workdir string, relaunched bool, err error) tea.Msg { + return struct { + Notice string + Workdir string + Relaunched bool + Err error + }{Notice: notice, Workdir: workdir, Relaunched: relaunched, Err: err} + }, + )() + gotSwitch, ok = switchMsg.(struct { + Notice string + Workdir string + Relaunched bool + Err error + }) + if !ok || gotSwitch.Err != nil || gotSwitch.Relaunched { + t.Fatalf("expected non-relaunch path to return cleanly, got %#v", switchMsg) + } + + switchMsg = RunWorkspaceSwitchCmd( + nil, + WorkspaceSwitchRequest{ + Notice: "relaunch", + Workdir: "/repo", + Relaunched: true, + }, + func(notice string, workdir string, relaunched bool, err error) tea.Msg { + return struct { + Notice string + Workdir string + Relaunched bool + Err error + }{Notice: notice, Workdir: workdir, Relaunched: relaunched, Err: err} + }, + )() + gotSwitch, ok = switchMsg.(struct { + Notice string + Workdir string + Relaunched bool + Err error + }) + if !ok || gotSwitch.Err == nil || !gotSwitch.Relaunched { + t.Fatalf("expected nil switcher error for relaunch path, got %#v", switchMsg) + } + + switcher.err = errors.New("launch failed") + switchMsg = RunWorkspaceSwitchCmd( + switcher, + WorkspaceSwitchRequest{ + Notice: "switching", + Workdir: "/repo", + Relaunched: true, + }, + func(notice string, workdir string, relaunched bool, err error) tea.Msg { + return struct { + Notice string + Workdir string + Relaunched bool + Err error + }{Notice: notice, Workdir: workdir, Relaunched: relaunched, Err: err} + }, + )() + gotSwitch, ok = switchMsg.(struct { + Notice string + Workdir string + Relaunched bool + Err error + }) + if !ok || gotSwitch.Err == nil || gotSwitch.Err.Error() != "launch failed" { + t.Fatalf("expected relaunch error to bubble up, got %#v", switchMsg) + } } func TestFileServices(t *testing.T) { diff --git a/internal/tui/services/workspace_switch_service.go b/internal/tui/services/workspace_switch_service.go new file mode 100644 index 00000000..5e3c8f62 --- /dev/null +++ b/internal/tui/services/workspace_switch_service.go @@ -0,0 +1,39 @@ +package services + +import ( + "context" + "errors" + + tea "github.com/charmbracelet/bubbletea" +) + +// WorkspaceSwitcher 定义工作区进程切换所需的最小能力。 +type WorkspaceSwitcher interface { + SwitchWorkspace(ctx context.Context, workdir string) error +} + +// WorkspaceSwitchRequest 描述一次工作区切换请求的静态参数。 +type WorkspaceSwitchRequest struct { + Notice string + Workdir string + Relaunched bool +} + +// RunWorkspaceSwitchCmd 执行工作区切换,并将结果映射为 UI 消息。 +func RunWorkspaceSwitchCmd( + switcher WorkspaceSwitcher, + request WorkspaceSwitchRequest, + toMsg func(string, string, bool, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + if !request.Relaunched { + return toMsg(request.Notice, request.Workdir, false, nil) + } + if switcher == nil { + return toMsg(request.Notice, request.Workdir, true, errors.New("workspace switcher is nil")) + } + + err := switcher.SwitchWorkspace(context.Background(), request.Workdir) + return toMsg(request.Notice, request.Workdir, true, err) + } +} diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 9016281d..fbf506e0 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -38,11 +38,12 @@ type LocalCommandResultMsg struct { ModelChanged bool } -// SessionWorkdirResultMsg 表示会话工作目录命令结果。 -type SessionWorkdirResultMsg struct { - Notice string - Workdir string - Err error +// WorkspaceSwitchResultMsg 表示工作区切换命令的执行结果。 +type WorkspaceSwitchResultMsg struct { + Notice string + Workdir string + Relaunched bool + Err error } // WorkspaceCommandResultMsg 表示工作区命令执行结果。 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 64690b5e..c81ab358 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -9,10 +9,17 @@ import ( type App = tuiapp.App type ProviderController = tuiapp.ProviderController +type WorkspaceSwitcher = tuiapp.WorkspaceSwitcher // 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) +func New( + cfg *config.Config, + configManager *config.Manager, + runtime agentruntime.Runtime, + providerSvc ProviderController, + workspaceSwitcher WorkspaceSwitcher, +) (App, error) { + return tuiapp.New(cfg, configManager, runtime, providerSvc, workspaceSwitcher) } // NewWithBootstrap 保留对外注入入口,内部转发到 core/app。 diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index f5e7e6c8..05b28ef7 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -16,9 +16,13 @@ func TestProviderControllerTypeAlias(t *testing.T) { var _ ProviderController = ProviderController(nil) } +func TestWorkspaceSwitcherTypeAlias(t *testing.T) { + var _ WorkspaceSwitcher = WorkspaceSwitcher(nil) +} + func TestNewForwardsToCore(t *testing.T) { t.Run("nil config", func(t *testing.T) { - _, err := New(nil, &config.Manager{}, &runtime.Service{}, nil) + _, err := New(nil, &config.Manager{}, &runtime.Service{}, nil, nil) if err == nil { t.Error("expected error for nil runtime") }