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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ context:

更多行为说明见 [context-compact.md](../context-compact.md)。

## /cwd 工作区切换

- `/cwd` 不再是“修改当前 session 执行目录”,而是“切换到另一个工作区实例”。
- `/cwd` 不带参数时,只显示当前工作区路径。
- `/cwd <path>` 会先把目标解析为存在的绝对目录;若目标与本次启动工作区相同,则返回 no-op 提示。
- 若当前 Agent 正在运行或正在 compact,`/cwd` 会直接拒绝切换,不会强制中断当前任务。
- 切换成功时,NeoCode 会启动一个新的 `--workdir <path>` 进程,并退出当前进程。
- 新进程进入目标工作区后,会使用该工作区自己的 session 分桶,并从 fresh draft 开始,而不是迁移当前输入框或当前会话。

## CLI 工作区覆盖

NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区:
Expand Down
3 changes: 2 additions & 1 deletion docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优

- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir`
- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改
- `/cwd` 现改为进程级工作区切换:成功时会拉起一个新的 `--workdir` 进程,并进入目标工作区自己的 session 分桶
- `session.Workdir` 仍表示历史会话中记录的执行目录字段,但 `/cwd` 不再通过当前进程内更新该字段来模拟工作区切换
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读
9 changes: 8 additions & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
30 changes: 25 additions & 5 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import (
var launchRootProgram = defaultRootProgramLauncher
var newRootProgram = app.NewProgram

type workspaceRelaunchModel interface {
PendingWorkspaceWorkdir() string
}

// GlobalFlags 描述 CLI 根命令当前支持的全局参数。
type GlobalFlags struct {
Workdir string
Expand Down Expand Up @@ -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
}
51 changes: 51 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
4 changes: 2 additions & 2 deletions internal/provider/openai/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
10 changes: 9 additions & 1 deletion internal/tools/bash/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
goruntime "runtime"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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 {
Expand All @@ -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()

Expand Down
10 changes: 10 additions & 0 deletions internal/tools/bash/test_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package bash

import goruntime "runtime"

func defaultShell() string {
if goruntime.GOOS == "windows" {
return "powershell"
}
return "bash"
}
Loading
Loading