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 01/26] =?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 02/26] =?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 03/26] =?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 04/26] =?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 05/26] =?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 06/26] =?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 07/26] =?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 08/26] =?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 09/26] =?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 10/26] =?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 11/26] 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 12/26] =?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 13/26] 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 14/26] 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 15/26] 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 16/26] =?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 17/26] 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 18/26] 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 19/26] =?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 20/26] =?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 21/26] =?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 22/26] =?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 23/26] =?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 24/26] =?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 25/26] =?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 26/26] 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) + } +}