From 2010ea618fb0f354315b039b0f85379a59dd2d48 Mon Sep 17 00:00:00 2001 From: mochow13 Date: Mon, 28 Sep 2026 02:04:36 +0200 Subject: [PATCH 1/2] refactor(repl): isolate stream handling Give the stream subsystem an explicit package boundary so persistence, headless execution, and REPL interactions use stable handler APIs rather than private stream state. --- internal/cli/repl/command_handlers.go | 4 +- internal/cli/repl/command_handlers_test.go | 27 +- internal/cli/repl/handlers.go | 48 +- internal/cli/repl/handlers_test.go | 92 ++-- internal/cli/repl/headless_progress.go | 3 +- internal/cli/repl/headless_run.go | 29 +- internal/cli/repl/headless_run_test.go | 3 +- internal/cli/repl/repl.go | 25 +- internal/cli/repl/repl_test.go | 7 +- internal/cli/repl/session_replay.go | 19 +- internal/cli/repl/session_replay_test.go | 27 +- internal/cli/repl/session_state.go | 92 ++-- .../{stream_handler.go => stream/handler.go} | 194 +++++-- .../handler_test.go} | 72 +-- .../permission.go} | 37 +- internal/cli/repl/stream/permission_test.go | 342 +++++++++++++ internal/cli/repl/stream/render.go | 475 ++++++++++++++++++ .../render_test.go} | 36 +- internal/cli/repl/stream/segments.go | 123 +++++ internal/cli/repl/stream/stream_test.go | 123 +++++ internal/cli/repl/stream_ask_user.go | 21 +- internal/cli/repl/stream_ask_user_test.go | 4 +- internal/cli/repl/stream_msgs_test.go | 22 - internal/cli/repl/stream_permission_test.go | 323 ------------ internal/cli/repl/stream_render.go | 450 ----------------- internal/cli/repl/stream_segments.go | 37 -- internal/cli/repl/stream_segments_test.go | 23 +- internal/cli/repl/turn_memory.go | 25 +- internal/cli/repl/turn_memory_test.go | 93 ++-- 29 files changed, 1555 insertions(+), 1221 deletions(-) rename internal/cli/repl/{stream_handler.go => stream/handler.go} (55%) rename internal/cli/repl/{stream_handler_test.go => stream/handler_test.go} (90%) rename internal/cli/repl/{stream_permission.go => stream/permission.go} (87%) create mode 100644 internal/cli/repl/stream/permission_test.go create mode 100644 internal/cli/repl/stream/render.go rename internal/cli/repl/{stream_render_test.go => stream/render_test.go} (87%) create mode 100644 internal/cli/repl/stream/segments.go create mode 100644 internal/cli/repl/stream/stream_test.go delete mode 100644 internal/cli/repl/stream_segments.go diff --git a/internal/cli/repl/command_handlers.go b/internal/cli/repl/command_handlers.go index ef90329..3d5858b 100644 --- a/internal/cli/repl/command_handlers.go +++ b/internal/cli/repl/command_handlers.go @@ -440,12 +440,12 @@ func (m *replModel) handleShowThinkingCommand(input string) replModel { switch arg { case "on": m.showThinking = true - m.stream.handler.showThinking = true + m.stream.handler.SetShowThinking(true) m.saveShowThinking(true) m.output.AddStyledLine(" ✓ Thinking tokens shown", repltheme.HighlightStyle) case "off": m.showThinking = false - m.stream.handler.showThinking = false + m.stream.handler.SetShowThinking(false) m.saveShowThinking(false) m.output.AddStyledLine(" ✓ Thinking tokens hidden", repltheme.HighlightStyle) default: diff --git a/internal/cli/repl/command_handlers_test.go b/internal/cli/repl/command_handlers_test.go index 7e8655f..f7d01a1 100644 --- a/internal/cli/repl/command_handlers_test.go +++ b/internal/cli/repl/command_handlers_test.go @@ -17,6 +17,7 @@ import ( replcommands "github.com/mochow13/keen-code/internal/cli/repl/commands" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" replwidgets "github.com/mochow13/keen-code/internal/cli/repl/widgets" "github.com/mochow13/keen-code/internal/config" keenmcp "github.com/mochow13/keen-code/internal/mcp" @@ -1286,15 +1287,15 @@ func TestStartAdversaryModelSelectionRestoresPrimaryThinkingEffort(t *testing.T) func TestHandleShowThinkingCommand_On(t *testing.T) { m := newTestModel() m.showThinking = false - m.stream.handler.showThinking = false + m.stream.handler.SetShowThinking(false) result := m.handleShowThinkingCommand("/show-thinking on") if !result.showThinking { t.Error("expected showThinking to be true after /show-thinking on") } - if !result.stream.handler.showThinking { - t.Error("expected stream.handler.showThinking to be true after /show-thinking on") + if !result.stream.handler.ShowThinking() { + t.Error("expected stream.handler.ShowThinking() to be true after /show-thinking on") } if !strings.Contains(result.output.Join(), "Thinking tokens shown") { t.Fatalf("expected confirmation message, got %q", result.output.Join()) @@ -1309,8 +1310,8 @@ func TestHandleShowThinkingCommand_Off(t *testing.T) { if result.showThinking { t.Error("expected showThinking to be false after /show-thinking off") } - if result.stream.handler.showThinking { - t.Error("expected stream.handler.showThinking to be false after /show-thinking off") + if result.stream.handler.ShowThinking() { + t.Error("expected stream.handler.ShowThinking() to be false after /show-thinking off") } if !strings.Contains(result.output.Join(), "Thinking tokens hidden") { t.Fatalf("expected confirmation message, got %q", result.output.Join()) @@ -1327,7 +1328,7 @@ func TestHandleShowThinkingCommand_NoArgShowsStatus(t *testing.T) { m2 := newTestModel() m2.showThinking = false - m2.stream.handler.showThinking = false + m2.stream.handler.SetShowThinking(false) result2 := m2.handleShowThinkingCommand("/show-thinking") if !strings.Contains(result2.output.Join(), "hidden") { @@ -1383,7 +1384,7 @@ func TestHandleEnterKey_BtwCommandStartsStream(t *testing.T) { m.ctx.cfg = &config.ResolvedConfig{APIKey: "key", Model: "model"} m.agentCore = newAgentCore(&mockLLMClient{}, "", m.ctx.cfg) m.agentCore.AppendMessage(agentcore.Message{Role: agentcore.RoleUser, Content: "context message"}) - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) m.textarea.SetValue("/btw what is this?") newM, cmd := m.handleEnterKey() @@ -1406,7 +1407,7 @@ func TestHandleEnterKey_BtwCommandDuringActiveStream(t *testing.T) { m := newTestModel() m.ctx.cfg = &config.ResolvedConfig{APIKey: "key", Model: "model"} m.agentCore = newAgentCore(&mockLLMClient{}, "", m.ctx.cfg) - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) m.stream.handler.Start(eventCh, "Loading...") m.textarea.SetValue("/btw quick question") @@ -1423,7 +1424,7 @@ func TestHandleEnterKey_BtwCommandDuringActiveStream(t *testing.T) { func TestHandleEnterKey_BtwCommandNoQuestion(t *testing.T) { m := newTestModel() - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) m.textarea.SetValue("/btw") newM, cmd := m.handleEnterKey() @@ -1448,7 +1449,7 @@ func TestHandleEnterKey_BtwCommandNoQuestion(t *testing.T) { func TestHandleEnterKey_BtwCommandNoQuestionShowsUsage(t *testing.T) { m := newTestModel() - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) m.textarea.SetValue("/btw") newM, cmd := m.handleEnterKey() @@ -1524,7 +1525,7 @@ func (f *fakeMCPRuntime) CallTool(_ context.Context, _, _ string, _ map[string]a func TestHandleEnterKey_BtwCommandClientNotReady(t *testing.T) { m := newTestModel() m.ctx.cfg = &config.ResolvedConfig{} - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) m.textarea.SetValue("/btw question") newM, cmd := m.handleEnterKey() @@ -1550,7 +1551,7 @@ func TestHandleEnterKey_BtwCommandClientNotReady(t *testing.T) { func TestCancelBtwStream_ClearsState(t *testing.T) { m := newTestModel() m.btw.showSpinner = true - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) m.btw.streamHandler.Start(eventCh, "Loading...") m.btw.lines = []string{"some lines"} @@ -1567,7 +1568,7 @@ func TestCancelBtwStream_ClearsState(t *testing.T) { func TestCancelBtwStream_CancelsContext(t *testing.T) { m := newTestModel() - m.btw.streamHandler = NewStreamHandler(nil) + m.btw.streamHandler = replstream.NewStreamHandler(nil) cancelled := false m.btw.streamCancel = func() { cancelled = true diff --git a/internal/cli/repl/handlers.go b/internal/cli/repl/handlers.go index adcb633..08e61b1 100644 --- a/internal/cli/repl/handlers.go +++ b/internal/cli/repl/handlers.go @@ -12,6 +12,7 @@ import ( replcommands "github.com/mochow13/keen-code/internal/cli/repl/commands" reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" replwidgets "github.com/mochow13/keen-code/internal/cli/repl/widgets" ) @@ -68,7 +69,7 @@ func (m *replModel) handleLLMDone() (replModel, tea.Cmd) { if m.compaction.active && m.compaction.mode != compactionAutomatic { return m.handleCompactionDone() } - segments := cloneStreamSegments(m.stream.handler.segments) + segments := m.stream.handler.Snapshot() m.recordHistoricalToolActivity(segments) m.stopLoading() m.clearStreamCancel() @@ -100,7 +101,7 @@ func (m *replModel) handleLLMIncomplete(err error) (replModel, tea.Cmd) { if m.compaction.active && m.compaction.mode != compactionAutomatic { return m.handleCompactionError(err) } - segments := cloneStreamSegments(m.stream.handler.segments) + segments := m.stream.handler.Snapshot() m.recordHistoricalToolActivity(segments) partialResponse := m.stream.handler.GetResponse() m.stopLoading() @@ -133,7 +134,7 @@ func (m *replModel) handleLLMError(err error) (replModel, tea.Cmd) { if m.compaction.active && m.compaction.mode != compactionAutomatic { return m.handleCompactionError(err) } - segments := cloneStreamSegments(m.stream.handler.segments) + segments := m.stream.handler.Snapshot() m.recordHistoricalToolActivity(segments) partialResponse := m.stream.handler.GetResponse() m.stopLoading() @@ -203,7 +204,7 @@ func (m *replModel) handleAutoCompactionApplied(event *agentcore.AutoCompactionE } m.flushStreamRender() - segments := cloneStreamSegments(m.stream.handler.segments) + segments := m.stream.handler.Snapshot() m.recordHistoricalToolActivity(segments) var turnMemory *agentcore.TurnMemory @@ -246,30 +247,17 @@ func (m *replModel) handleAutoCompactionStopped() (replModel, tea.Cmd) { return *m, m.waitForAsyncEvent() } -func finalAssistantRun(segments []streamSegment) string { - start := len(segments) - for start > 0 && segments[start-1].kind == segmentAssistant { - start-- - } - var content strings.Builder - for _, segment := range segments[start:] { - content.WriteString(segment.content) - } - return content.String() +func finalAssistantRun(segments []replstream.Segment) string { + return replstream.FinalAssistantRun(segments) } -func hasNonTextActivity(segments []streamSegment) bool { - for _, segment := range segments { - if segment.kind != segmentAssistant && segment.kind != segmentReasoning { - return true - } - } - return false +func hasNonTextActivity(segments []replstream.Segment) bool { + return replstream.HasNonTextActivity(segments) } func (m *replModel) handleCompactionDone() (replModel, tea.Cmd) { m.flushStreamRender() - segments := cloneStreamSegments(m.stream.handler.segments) + segments := m.stream.handler.Snapshot() responseLines, summary := m.stream.handler.HandleDone() if hasNonTextActivity(segments) { summary = finalAssistantRun(segments) @@ -508,7 +496,7 @@ func (m *replModel) handleAskUserKeyMsg(msg tea.KeyPressMsg) (replModel, tea.Cmd } } if s.active() { - m.stream.handler.SetAskUser(s) + m.stream.handler.SetAskUser(askUserCard(s)) } else { m.appendResolvedAskUserSegment() } @@ -524,7 +512,7 @@ func (m *replModel) handleAskUserPasteMsg(msg tea.PasteMsg) (replModel, tea.Cmd) s.input.Focus() var cmd tea.Cmd s.input, cmd = s.input.Update(msg) - m.stream.handler.SetAskUser(s) + m.stream.handler.SetAskUser(askUserCard(s)) m.updateViewportContent() m.scrollToBottomIfFollowing() return *m, cmd @@ -803,7 +791,7 @@ func (m *replModel) interruptStream(message string) { m.stopLoading() - segments := cloneStreamSegments(m.stream.handler.segments) + segments := m.stream.handler.Snapshot() m.recordHistoricalToolActivity(segments) partialResponse := m.stream.handler.GetResponse() turnMemory := m.consumeTurnMemory() @@ -914,7 +902,7 @@ func (m *replModel) handlePermissionKeyMsg(msg tea.KeyPressMsg) (replModel, tea. func (m replModel) handleLLMStreamMsg(msg tea.Msg) (replModel, tea.Cmd, bool) { if streamMsg, ok := msg.(mainStreamMsg); ok { - if m.stream.handler == nil || m.stream.handler.eventCh != streamMsg.eventCh { + if m.stream.handler == nil || m.stream.handler.EventChannel() != streamMsg.eventCh { return m, nil, true } if streamMsg.closed { @@ -1055,7 +1043,7 @@ func (m replModel) handleBtwStreamMsg(msg tea.Msg) (replModel, tea.Cmd, bool) { switch msg := msg.(type) { case btwChunkMsg: m.btw.streamHandler.HandleChunk(string(msg)) - return m, tea.Batch(m.afterStreamUpdate(), waitForBtwEvent(m.btw.streamHandler.eventCh)), true + return m, tea.Batch(m.afterStreamUpdate(), waitForBtwEvent(m.btw.streamHandler.EventChannel())), true case btwDoneMsg: m.flushStreamRender() responseLines, _ := m.btw.streamHandler.HandleDone() @@ -1093,19 +1081,19 @@ func (m replModel) handleAdversaryStreamMsg(msg tea.Msg) (replModel, tea.Cmd, bo switch msg := msg.(type) { case adversaryChunkMsg: m.adversary.streamHandler.HandleChunk(string(msg)) - return m, tea.Batch(m.afterStreamUpdate(), waitForAdversaryEvent(m.adversary.streamHandler.eventCh)), true + return m, tea.Batch(m.afterStreamUpdate(), waitForAdversaryEvent(m.adversary.streamHandler.EventChannel())), true case adversaryToolStartMsg: m.flushStreamRender() m.adversary.streamHandler.HandleToolStart(msg.toolCall) m.updateViewportContent() m.scrollToBottomIfFollowing() - return m, waitForAdversaryEvent(m.adversary.streamHandler.eventCh), true + return m, waitForAdversaryEvent(m.adversary.streamHandler.EventChannel()), true case adversaryToolEndMsg: m.flushStreamRender() m.adversary.streamHandler.HandleToolEnd(msg.toolCall) m.updateViewportContent() m.scrollToBottomIfFollowing() - return m, waitForAdversaryEvent(m.adversary.streamHandler.eventCh), true + return m, waitForAdversaryEvent(m.adversary.streamHandler.EventChannel()), true case adversaryDoneMsg: m.flushStreamRender() responseLines, _ := m.adversary.streamHandler.HandleDone() diff --git a/internal/cli/repl/handlers_test.go b/internal/cli/repl/handlers_test.go index 0200731..02de851 100644 --- a/internal/cli/repl/handlers_test.go +++ b/internal/cli/repl/handlers_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" @@ -24,7 +25,7 @@ import ( ) func TestHandleLLMChunk(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") m := replModel{ @@ -87,7 +88,7 @@ func TestContextStatus_UpdatesOnUsageEvent(t *testing.T) { } func TestHandleLLMDoneDrainsSubagentActivity(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) sh.Start(make(chan agentcore.StreamEvent), "Loading...") activity := make(chan agentcore.ToolActivity, 2) activity <- agentcore.ToolActivity{RunID: "run-1", CallID: "tool-1", Agent: "worker", Event: agentcore.StreamEvent{ @@ -118,7 +119,7 @@ func TestHandleLLMDoneDrainsSubagentActivity(t *testing.T) { } func TestHandleLLMDone(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") sh.HandleChunk("response line 1\nresponse line 2") @@ -157,7 +158,7 @@ func TestHandleLLMDone(t *testing.T) { } func TestHandleLLMError(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -195,7 +196,7 @@ func TestHandleKeyMsg_Enter(t *testing.T) { m := replModel{ textarea: ta, width: 80, - stream: streamState{handler: NewStreamHandler(nil)}, + stream: streamState{handler: replstream.NewStreamHandler(nil)}, ctx: &replContext{}, output: reploutput.NewOutputBuilder(80, ""), } @@ -296,7 +297,7 @@ func TestHandleKeyMsg_CtrlC_WithAskUserCancelsQuestionnaireFirst(t *testing.T) { m := newTestModel() m.stream.handler.Start(make(chan agentcore.StreamEvent), "Loading...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) streamCanceled := false m.stream.cancel = func() { streamCanceled = true } @@ -723,7 +724,7 @@ func TestUpdateNormalMode_ModelSelectionPasteGoesToAPIKeyInput(t *testing.T) { } func TestHandleLLMChunk_MultipleCalls(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") m := replModel{ @@ -746,7 +747,7 @@ func TestHandleLLMChunk_MultipleCalls(t *testing.T) { } func TestHandleLLMDone_EmptyResponse(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -770,7 +771,7 @@ func TestHandleLLMDone_EmptyResponse(t *testing.T) { } func TestHandleLLMError_ResetsHandler(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") sh.HandleChunk("partial content") @@ -797,7 +798,7 @@ func TestHandleLLMError_ResetsHandler(t *testing.T) { func TestHandleLLMError_MaterializesMessageAndTurnMemory(t *testing.T) { workingDir := t.TempDir() - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") sh.HandleChunk("partial response") @@ -837,7 +838,7 @@ func TestHandleLLMError_MaterializesMessageAndTurnMemory(t *testing.T) { } func TestHandleLLMError_ContextCanceled_DoesNotAddErrorLine(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") sh.HandleChunk("partial content") @@ -867,7 +868,7 @@ func TestHandleLLMError_ContextCanceled_DoesNotAddErrorLine(t *testing.T) { } func TestHandleToolStart(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -897,33 +898,34 @@ func TestHandleToolStart(t *testing.T) { t.Error("expected non-nil cmd from handleToolStart") } - if len(sh.segments) != 1 { - t.Errorf("expected 1 stream segment in handler, got %d", len(sh.segments)) + if len(sh.Snapshot()) != 1 { + t.Errorf("expected 1 stream segment in handler, got %d", len(sh.Snapshot())) } - if sh.segments[0].kind != segmentToolStart { - t.Errorf("expected first segment kind %q, got %q", segmentToolStart, sh.segments[0].kind) + if sh.Snapshot()[0].Kind != replstream.SegmentToolStart { + t.Errorf("expected first segment kind %q, got %q", replstream.SegmentToolStart, sh.Snapshot()[0].Kind) } } func TestHandleAskUserToolActivityIsRecordedWithoutGenericStatus(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) sh.Start(make(chan agentcore.StreamEvent), "Loading...") m := replModel{stream: streamState{handler: sh}, width: 80, output: reploutput.NewOutputBuilder(80, "")} call := &agentcore.ToolCall{Name: tools.AskUserToolName, Input: map[string]any{"questions": []any{map[string]any{"question": "Pick", "options": []any{"one", "two"}}}}} m.handleToolStart(call) m.handleToolEnd(&agentcore.ToolCall{Name: tools.AskUserToolName, Input: call.Input, Output: map[string]any{"tool": tools.AskUserToolName, "answers": []string{"one"}}}) - if len(sh.segments) != 2 { - t.Fatalf("expected ask_user start and end segments, got %#v", sh.segments) + if len(sh.Snapshot()) != 2 { + t.Fatalf("expected ask_user start and end segments, got %#v", sh.Snapshot()) } - if got := sh.renderTranscriptLines(); len(got) != 0 { - t.Fatalf("expected no generic ask_user status, got %q", got) + lines := sh.TranscriptLines() + if len(lines) != 0 { + t.Fatalf("expected no generic ask_user status, got %q", lines) } } func TestHandleToolStart_BashKeepsSpinnerActive(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -947,13 +949,13 @@ func TestHandleToolStart_BashKeepsSpinnerActive(t *testing.T) { if cmd == nil { t.Error("expected non-nil cmd from handleToolStart") } - if len(sh.segments) != 1 || sh.segments[0].kind != segmentBash { + if len(sh.Snapshot()) != 1 || sh.Snapshot()[0].Kind != replstream.SegmentBash { t.Fatalf("expected a bash segment to be added") } } func TestHandleToolEnd(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -980,17 +982,17 @@ func TestHandleToolEnd(t *testing.T) { t.Error("expected non-nil cmd from handleToolEnd") } - if len(sh.segments) != 1 { - t.Errorf("expected 1 stream segment in handler, got %d", len(sh.segments)) + if len(sh.Snapshot()) != 1 { + t.Errorf("expected 1 stream segment in handler, got %d", len(sh.Snapshot())) } - if sh.segments[0].kind != segmentToolEnd { - t.Errorf("expected first segment kind %q, got %q", segmentToolEnd, sh.segments[0].kind) + if sh.Snapshot()[0].Kind != replstream.SegmentToolEnd { + t.Errorf("expected first segment kind %q, got %q", replstream.SegmentToolEnd, sh.Snapshot()[0].Kind) } } func TestHandleToolEnd_WithError(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -1016,21 +1018,21 @@ func TestHandleToolEnd_WithError(t *testing.T) { t.Error("expected non-nil cmd from handleToolEnd") } - if len(sh.segments) != 1 { - t.Errorf("expected 1 stream segment in handler, got %d", len(sh.segments)) + if len(sh.Snapshot()) != 1 { + t.Errorf("expected 1 stream segment in handler, got %d", len(sh.Snapshot())) } - if sh.segments[0].kind != segmentToolEnd { - t.Errorf("expected first segment kind %q, got %q", segmentToolEnd, sh.segments[0].kind) + if sh.Snapshot()[0].Kind != replstream.SegmentToolEnd { + t.Errorf("expected first segment kind %q, got %q", replstream.SegmentToolEnd, sh.Snapshot()[0].Kind) } - if sh.segments[0].toolCall == nil || sh.segments[0].toolCall.Error != "connection failed" { + if sh.Snapshot()[0].ToolCall == nil || sh.Snapshot()[0].ToolCall.Error != "connection failed" { t.Errorf("expected tool end segment with error details") } } func TestHandleLLMStreamMsg_ToolEnd_ReturnsSpinnerTick(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -1060,7 +1062,7 @@ func TestHandleLLMStreamMsg_ToolEnd_ReturnsSpinnerTick(t *testing.T) { } func TestHandleBtwStreamMsg_Chunk(t *testing.T) { - btwSh := NewStreamHandler(nil) + btwSh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) btwSh.Start(eventCh, "Loading...") @@ -1082,7 +1084,7 @@ func TestHandleBtwStreamMsg_Chunk(t *testing.T) { } func TestHandleBtwStreamMsg_Done(t *testing.T) { - btwSh := NewStreamHandler(nil) + btwSh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) btwSh.Start(eventCh, "Loading...") btwSh.HandleChunk("answer text") @@ -1109,7 +1111,7 @@ func TestHandleBtwStreamMsg_Done(t *testing.T) { } func TestHandleBtwStreamMsg_Error(t *testing.T) { - btwSh := NewStreamHandler(nil) + btwSh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) btwSh.Start(eventCh, "Loading...") @@ -1135,7 +1137,7 @@ func TestHandleBtwStreamMsg_Error(t *testing.T) { } func TestHandleBtwStreamMsg_InactiveHandlerSwallowsMessages(t *testing.T) { - btwSh := NewStreamHandler(nil) + btwSh := replstream.NewStreamHandler(nil) m := newTestModel() m.btw.streamHandler = btwSh @@ -1339,7 +1341,7 @@ func TestHandleLLMIncompleteClearsAskUser(t *testing.T) { m := newTestModel() m.stream.handler.Start(make(chan agentcore.StreamEvent), "Working...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) updated, _ := m.handleLLMIncomplete(errors.New("response truncated")) if updated.askUser.active() { @@ -1351,7 +1353,7 @@ func TestHandleLLMErrorClearsAskUser(t *testing.T) { m := newTestModel() m.stream.handler.Start(make(chan agentcore.StreamEvent), "Working...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) updated, _ := m.handleLLMError(errors.New("stream failed")) if updated.askUser.active() { @@ -1625,7 +1627,7 @@ func TestHandleLLMStreamMsgRoutesMainEvents(t *testing.T) { } }}, {name: "tool start", event: agentcore.StreamEvent{Type: agentcore.StreamEventTypeToolStart, ToolCall: &agentcore.ToolCall{Name: "read_file"}}, check: func(t *testing.T, m replModel) { - if len(m.stream.handler.segments) != 1 { + if len(m.stream.handler.Snapshot()) != 1 { t.Fatal("tool start was not routed") } }}, @@ -1664,7 +1666,7 @@ func TestHandleLLMStreamMsgInactiveStreamSwallowsLateEvents(t *testing.T) { func TestHandleAdversaryStreamLifecycle(t *testing.T) { m := newTestModel() events := make(chan agentcore.StreamEvent) - m.adversary.streamHandler = NewStreamHandler(nil) + m.adversary.streamHandler = replstream.NewStreamHandler(nil) m.adversary.streamHandler.Start(events, "Reviewing...") m.adversary.showSpinner = true @@ -1673,7 +1675,7 @@ func TestHandleAdversaryStreamLifecycle(t *testing.T) { t.Fatal("adversary chunk was not handled") } updated, cmd, handled = updated.handleAdversaryStreamMsg(adversaryToolStartMsg{toolCall: &agentcore.ToolCall{Name: "read_file"}}) - if !handled || cmd == nil || len(updated.adversary.streamHandler.segments) < 2 { + if !handled || cmd == nil || len(updated.adversary.streamHandler.Snapshot()) < 2 { t.Fatal("adversary tool start was not handled") } updated, cmd, handled = updated.handleAdversaryStreamMsg(adversaryToolEndMsg{toolCall: &agentcore.ToolCall{Name: "read_file", Output: "ok"}}) @@ -1688,7 +1690,7 @@ func TestHandleAdversaryStreamLifecycle(t *testing.T) { func TestHandleAdversaryStreamErrorAndStaleMessages(t *testing.T) { m := newTestModel() - m.adversary.streamHandler = NewStreamHandler(nil) + m.adversary.streamHandler = replstream.NewStreamHandler(nil) m.adversary.streamHandler.Start(make(chan agentcore.StreamEvent), "Reviewing...") m.adversary.showSpinner = true diff --git a/internal/cli/repl/headless_progress.go b/internal/cli/repl/headless_progress.go index fa6ab16..8f47736 100644 --- a/internal/cli/repl/headless_progress.go +++ b/internal/cli/repl/headless_progress.go @@ -6,6 +6,7 @@ import ( "io" reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" ) // headlessProgress streams live agent text chunks and tool start lines to the @@ -32,7 +33,7 @@ func (p *headlessProgress) writeToolEnd(toolCall *agentcore.ToolCall) { if p.out == nil || toolCall == nil { return } - if isHiddenToolFailure(toolCall) { + if replstream.IsHiddenToolFailure(toolCall) { return } p.newLine() diff --git a/internal/cli/repl/headless_run.go b/internal/cli/repl/headless_run.go index 20d06f8..082cd19 100644 --- a/internal/cli/repl/headless_run.go +++ b/internal/cli/repl/headless_run.go @@ -11,6 +11,7 @@ import ( "github.com/mochow13/keen-code/internal/agentcore" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" repltooling "github.com/mochow13/keen-code/internal/cli/repl/tooling" "github.com/mochow13/keen-code/internal/config" "github.com/mochow13/keen-code/internal/llm" @@ -109,9 +110,9 @@ func RunHeadless(ctx context.Context, opts HeadlessRunOptions) (*HeadlessRunResu return nil, fmt.Errorf("LLM client not initialized") } - handler := NewStreamHandler(nil) - handler.workingDir = opts.WorkingDir - handler.showThinking = false + handler := replstream.NewStreamHandler(nil) + handler.SetWorkingDir(opts.WorkingDir) + handler.SetShowThinking(false) handler.Start(eventCh, "") turnMemory := newTurnMemoryAccumulator(false) var completedText strings.Builder @@ -185,7 +186,7 @@ func loadHeadlessSession(sessions *replSessionState, sessionID string) (*session return nil, fmt.Errorf("session %q not found", sessionID) } -func handleHeadlessToolStart(handler *StreamHandler, toolCall *agentcore.ToolCall) { +func handleHeadlessToolStart(handler *replstream.StreamHandler, toolCall *agentcore.ToolCall) { if toolCall == nil { return } @@ -198,7 +199,7 @@ func handleHeadlessToolStart(handler *StreamHandler, toolCall *agentcore.ToolCal handler.HandleToolStart(toolCall) } -func handleHeadlessToolEnd(handler *StreamHandler, toolCall *agentcore.ToolCall) { +func handleHeadlessToolEnd(handler *replstream.StreamHandler, toolCall *agentcore.ToolCall) { if toolCall == nil { return } @@ -212,7 +213,7 @@ func handleHeadlessToolEnd(handler *StreamHandler, toolCall *agentcore.ToolCall) func checkpointHeadlessAutoCompaction( sessions *replSessionState, agentCore agentcore.AgentCore, - handler *StreamHandler, + handler *replstream.StreamHandler, turnMemory *turnMemoryAccumulator, completedText *strings.Builder, compaction *agentcore.AutoCompactionEvent, @@ -221,8 +222,8 @@ func checkpointHeadlessAutoCompaction( return fmt.Errorf("automatic compaction applied without replacement history") } - segments := cloneStreamSegments(handler.segments) - turnMemory.RecordToolActivity(segments, handler.workingDir) + segments := handler.Snapshot() + turnMemory.RecordToolActivity(segments, handler.WorkingDir()) response := handler.GetResponse() persistedReplacement := agentCore.WithoutSystemMessages(compaction.Replacement) if err := sessions.appendAutoCompaction(segments, agentcore.Message{ @@ -245,13 +246,13 @@ func finishHeadlessRun( format string, completionSignal string, sessions *replSessionState, - handler *StreamHandler, + handler *replstream.StreamHandler, turnMemory *turnMemoryAccumulator, completedText string, usage *agentcore.TokenUsage, ) (*HeadlessRunResult, error) { - segments := cloneStreamSegments(handler.segments) - turnMemory.RecordToolActivity(segments, handler.workingDir) + segments := handler.Snapshot() + turnMemory.RecordToolActivity(segments, handler.WorkingDir()) _, currentResponse := handler.HandleDone() assistantMessage := agentcore.Message{ Role: agentcore.RoleAssistant, @@ -281,7 +282,7 @@ func failHeadlessRun( out io.Writer, format string, sessions *replSessionState, - handler *StreamHandler, + handler *replstream.StreamHandler, turnMemory *turnMemoryAccumulator, completedText string, usage *agentcore.TokenUsage, @@ -290,8 +291,8 @@ func failHeadlessRun( if err == nil { err = fmt.Errorf("LLM stream incomplete") } - segments := cloneStreamSegments(handler.segments) - turnMemory.RecordToolActivity(segments, handler.workingDir) + segments := handler.Snapshot() + turnMemory.RecordToolActivity(segments, handler.WorkingDir()) partialResponse := handler.GetResponse() _, errMsg := handler.HandleError(err) assistantMessage := agentcore.Message{ diff --git a/internal/cli/repl/headless_run_test.go b/internal/cli/repl/headless_run_test.go index d3ac7a7..42bfbf9 100644 --- a/internal/cli/repl/headless_run_test.go +++ b/internal/cli/repl/headless_run_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "github.com/mochow13/keen-code/internal/llm/core" "os" "path/filepath" @@ -405,7 +406,7 @@ func loadOnlyHeadlessSessionEvents(t *testing.T, workingDir string) []session.Ev } func TestCheckpointHeadlessAutoCompactionRejectsEmptyReplacement(t *testing.T) { - handler := NewStreamHandler(nil) + handler := replstream.NewStreamHandler(nil) handler.Start(make(chan agentcore.StreamEvent), "") completedText := &strings.Builder{} turnMemory := newTurnMemoryAccumulator(false) diff --git a/internal/cli/repl/repl.go b/internal/cli/repl/repl.go index fbd4729..26efd71 100644 --- a/internal/cli/repl/repl.go +++ b/internal/cli/repl/repl.go @@ -23,6 +23,7 @@ import ( replmarkdown "github.com/mochow13/keen-code/internal/cli/repl/markdown" reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" repltooling "github.com/mochow13/keen-code/internal/cli/repl/tooling" replwidgets "github.com/mochow13/keen-code/internal/cli/repl/widgets" @@ -126,14 +127,14 @@ type loadingState struct { type btwState struct { lines []string question string - streamHandler *StreamHandler + streamHandler *replstream.StreamHandler streamCancel context.CancelFunc showSpinner bool spinner spinner.Model } type adversaryState struct { - streamHandler *StreamHandler + streamHandler *replstream.StreamHandler streamCancel context.CancelFunc lines []string focus string @@ -143,7 +144,7 @@ type adversaryState struct { } type streamState struct { - handler *StreamHandler + handler *replstream.StreamHandler cancel context.CancelFunc // Streamed tokens arrive faster than the terminal can redraw. Batch @@ -262,7 +263,7 @@ func initialModel(ctx *replContext, lifecycleCtx context.Context, agentCore agen agentCore: agentCore, output: output, loading: loadingState{spinner: s}, - stream: streamState{handler: NewStreamHandler(mdRenderer), renderInterval: streamRenderInterval}, + stream: streamState{handler: replstream.NewStreamHandler(mdRenderer), renderInterval: streamRenderInterval}, mdRenderer: mdRenderer, permissionRequester: permissionRequester, askUser: askUserState{requester: askUserRequester}, @@ -274,11 +275,11 @@ func initialModel(ctx *replContext, lifecycleCtx context.Context, agentCore agen showThinking: true, btw: btwState{ spinner: bs, - streamHandler: NewStreamHandler(mdRenderer), + streamHandler: replstream.NewStreamHandler(mdRenderer), }, adversary: adversaryState{ spinner: as, - streamHandler: NewStreamHandler(mdRenderer), + streamHandler: replstream.NewStreamHandler(mdRenderer), }, lastSession: lastSession, projectPermsErr: projectPermsErr, @@ -287,8 +288,8 @@ func initialModel(ctx *replContext, lifecycleCtx context.Context, agentCore agen if ctx.globalCfg != nil && ctx.globalCfg.ShowThinking != nil { model.showThinking = *ctx.globalCfg.ShowThinking } - model.stream.handler.workingDir = ctx.workingDir - model.stream.handler.showThinking = model.showThinking + model.stream.handler.SetWorkingDir(ctx.workingDir) + model.stream.handler.SetShowThinking(model.showThinking) model.refreshGitBranch() if ctx.resumeSession != nil { @@ -544,7 +545,7 @@ func (m *replModel) updateViewportContent() { } func (m replModel) waitForAsyncEvent() tea.Cmd { - if m.stream.handler == nil || !m.stream.handler.IsActive() || m.stream.handler.eventCh == nil { + if m.stream.handler == nil || !m.stream.handler.IsActive() || m.stream.handler.EventChannel() == nil { return nil } var askUserCh <-chan *replaskuser.Request @@ -560,7 +561,7 @@ func (m replModel) waitForAsyncEvent() tea.Cmd { diffCh = m.diffEmitter.GetDiffChan() } return waitForAsyncEvent( - m.stream.handler.eventCh, + m.stream.handler.EventChannel(), permissionCh, diffCh, m.subagentActivity, @@ -657,7 +658,7 @@ func (m replModel) updateNormalMode(msg tea.Msg) (replModel, tea.Cmd) { return m, m.waitForAsyncEvent() } m.askUser.begin(msg.req) - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) m.textarea.Reset() m.updateViewportContent() m.scrollToBottomIfFollowing() @@ -988,7 +989,7 @@ func (m *replModel) replayLoadedSession(loaded *session.LoadedSession) { } replay := newSessionReplay(m.width, m.mdRenderer, m.ctx.workingDir) - replay.handler.showThinking = m.showThinking + replay.handler.SetShowThinking(m.showThinking) for _, event := range loaded.Events { replay.applyEvent(event) diff --git a/internal/cli/repl/repl_test.go b/internal/cli/repl/repl_test.go index e51c35e..c74703a 100644 --- a/internal/cli/repl/repl_test.go +++ b/internal/cli/repl/repl_test.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/x/ansi" reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" repltooling "github.com/mochow13/keen-code/internal/cli/repl/tooling" replwidgets "github.com/mochow13/keen-code/internal/cli/repl/widgets" @@ -56,7 +57,7 @@ func newTestModel() replModel { ctx: &replContext{cfg: cfg}, agentCore: &mockAgentCore{cfg: cfg, mode: agentcore.ModeBuild}, output: reploutput.NewOutputBuilder(80, ""), - stream: streamState{handler: NewStreamHandler(nil)}, + stream: streamState{handler: replstream.NewStreamHandler(nil)}, permissionRequester: replpermissions.NewRequester(nil), projectPerms: config.NewProjectPermissions(), diffEmitter: repltooling.NewDiffEmitter(), @@ -120,7 +121,7 @@ func scrollViewportAwayFromBottom(t *testing.T, m *replModel) int { } func TestUpdate_InlinePermission_AllowsToolStartEvent(t *testing.T) { - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) eventCh := make(chan agentcore.StreamEvent) sh.Start(eventCh, "Loading...") @@ -598,7 +599,7 @@ func TestUpdateNormalMode_DiffReadyRendersImmediately(t *testing.T) { newM, cmd := m.updateNormalMode(diffReadyMsg{req: req}) - if len(newM.stream.handler.segments) != 1 || newM.stream.handler.segments[0].kind != segmentDiff { + if len(newM.stream.handler.Snapshot()) != 1 || newM.stream.handler.Snapshot()[0].Kind != replstream.SegmentDiff { t.Fatal("expected diff segment to be rendered immediately") } select { diff --git a/internal/cli/repl/session_replay.go b/internal/cli/repl/session_replay.go index e743477..5dab71c 100644 --- a/internal/cli/repl/session_replay.go +++ b/internal/cli/repl/session_replay.go @@ -10,13 +10,14 @@ import ( replmarkdown "github.com/mochow13/keen-code/internal/cli/repl/markdown" reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" "github.com/mochow13/keen-code/internal/session" ) type sessionReplay struct { output *reploutput.OutputBuilder - handler *StreamHandler + handler *replstream.StreamHandler } func newSessionReplay(width int, mdRenderer *replmarkdown.Renderer, workingDir string) *sessionReplay { @@ -25,11 +26,11 @@ func newSessionReplay(width int, mdRenderer *replmarkdown.Renderer, workingDir s outputWidth = width } - handler := NewStreamHandler(mdRenderer) - handler.lastWidth = width - handler.workingDir = workingDir - if handler.lastWidth <= 0 { - handler.lastWidth = defaultWidth + handler := replstream.NewStreamHandler(mdRenderer) + handler.SetLastWidth(width) + handler.SetWorkingDir(workingDir) + if handler.LastWidth() <= 0 { + handler.SetLastWidth(defaultWidth) } return &sessionReplay{ @@ -120,7 +121,7 @@ func (r *sessionReplay) flushError(errText string) { } } -func replayTranscript(handler *StreamHandler, transcript []session.TranscriptItem) { +func replayTranscript(handler *replstream.StreamHandler, transcript []session.TranscriptItem) { if handler == nil { return } @@ -138,7 +139,7 @@ func replayTranscript(handler *StreamHandler, transcript []session.TranscriptIte case session.TranscriptItemToolEnd: if item.ToolEnd != nil && item.ToolEnd.Name == agentcore.ToolNameAskUser { if state := askUserStateFromPayload(item.ToolEnd); state != nil { - handler.SetAskUser(state) + handler.SetAskUser(askUserCard(state)) } } else { handler.HandleToolEnd(toolCallResultFromPayload(item.ToolEnd)) @@ -182,7 +183,7 @@ func decodeSessionPayload(value any, target any) bool { return json.Unmarshal(encoded, target) == nil } -func replayBashPayload(handler *StreamHandler, payload *session.BashPayload) { +func replayBashPayload(handler *replstream.StreamHandler, payload *session.BashPayload) { if handler == nil || payload == nil { return } diff --git a/internal/cli/repl/session_replay_test.go b/internal/cli/repl/session_replay_test.go index 7dab44e..0b09a90 100644 --- a/internal/cli/repl/session_replay_test.go +++ b/internal/cli/repl/session_replay_test.go @@ -2,6 +2,7 @@ package repl import ( "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "github.com/mochow13/keen-code/internal/llm/core" "strings" "testing" @@ -126,16 +127,16 @@ func TestBuildAssistantTurnEvent_MixedTranscript(t *testing.T) { {Kind: agentcore.EditDiffLineAdded, Content: "added", NewLineNum: 1}, } - segments := []streamSegment{ - {kind: segmentAssistant, content: "draft"}, - {kind: segmentReasoning, content: "thinking"}, + segments := []replstream.Segment{ + {Kind: replstream.SegmentAssistant, Content: "draft"}, + {Kind: replstream.SegmentReasoning, Content: "thinking"}, { - kind: segmentToolStart, - toolCall: toolCallFromPayload(&session.ToolStartPayload{Name: "read_file", Input: map[string]any{"path": "go.mod"}}), + Kind: replstream.SegmentToolStart, + ToolCall: toolCallFromPayload(&session.ToolStartPayload{Name: "read_file", Input: map[string]any{"path": "go.mod"}}), }, { - kind: segmentToolEnd, - toolCall: toolCallResultFromPayload(&session.ToolEndPayload{ + Kind: replstream.SegmentToolEnd, + ToolCall: toolCallResultFromPayload(&session.ToolEndPayload{ Name: "read_file", Input: map[string]any{"path": "go.mod"}, Output: map[string]any{"content": "module github.com/user/keen-code"}, @@ -143,15 +144,15 @@ func TestBuildAssistantTurnEvent_MixedTranscript(t *testing.T) { }), }, { - kind: segmentBash, - command: "go test ./...", - summary: "Run unit tests", - output: "ok", - toolCall: &agentcore.ToolCall{ + Kind: replstream.SegmentBash, + Command: "go test ./...", + Summary: "Run unit tests", + Output: "ok", + ToolCall: &agentcore.ToolCall{ Duration: 7 * time.Millisecond, }, }, - {kind: segmentDiff, diffLines: diffLines}, + {Kind: replstream.SegmentDiff, DiffLines: diffLines}, } event := buildAssistantTurnEvent(segments, agentcore.Message{ diff --git a/internal/cli/repl/session_state.go b/internal/cli/repl/session_state.go index 85dee85..c800fab 100644 --- a/internal/cli/repl/session_state.go +++ b/internal/cli/repl/session_state.go @@ -4,6 +4,7 @@ import ( "time" "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "github.com/mochow13/keen-code/internal/session" ) @@ -49,7 +50,7 @@ func (s *replSessionState) appendUserMessage(content string) error { } func (s *replSessionState) appendAssistantTurn( - segments []streamSegment, + segments []replstream.Segment, message agentcore.Message, interrupted bool, errText string, @@ -60,14 +61,14 @@ func (s *replSessionState) appendAssistantTurn( return s.store.Append(s.current, buildAssistantTurnEvent(segments, message, interrupted, errText)) } -func (s *replSessionState) appendCompaction(segments []streamSegment, messages []agentcore.Message, status string) error { +func (s *replSessionState) appendCompaction(segments []replstream.Segment, messages []agentcore.Message, status string) error { if s == nil || s.current == nil { return nil } return s.store.Append(s.current, buildCompactionEvent(segments, messages, status)) } -func (s *replSessionState) appendAutoCompaction(checkpointSegments []streamSegment, checkpoint agentcore.Message, messages []agentcore.Message) error { +func (s *replSessionState) appendAutoCompaction(checkpointSegments []replstream.Segment, checkpoint agentcore.Message, messages []agentcore.Message) error { if s == nil || s.current == nil { return nil } @@ -77,7 +78,7 @@ func (s *replSessionState) appendAutoCompaction(checkpointSegments []streamSegme }) } -func buildCompactionEvent(segments []streamSegment, messages []agentcore.Message, status string) session.Event { +func buildCompactionEvent(segments []replstream.Segment, messages []agentcore.Message, status string) session.Event { return session.Event{ Kind: session.KindCompactionApplied, CompactionApplied: &session.CompactionAppliedPayload{ @@ -131,7 +132,7 @@ func (s *replSessionState) setSession(session *session.Session) { } func buildAssistantTurnEvent( - segments []streamSegment, + segments []replstream.Segment, message agentcore.Message, interrupted bool, errText string, @@ -148,69 +149,69 @@ func buildAssistantTurnEvent( } } -func buildAssistantTurnTranscript(segments []streamSegment) []session.TranscriptItem { +func buildAssistantTurnTranscript(segments []replstream.Segment) []session.TranscriptItem { items := make([]session.TranscriptItem, 0, len(segments)) for _, seg := range segments { - switch seg.kind { - case segmentAssistant: - if seg.content != "" { + switch seg.Kind { + case replstream.SegmentAssistant: + if seg.Content != "" { items = append(items, session.TranscriptItem{ Kind: session.TranscriptItemText, - Content: seg.content, + Content: seg.Content, }) } - case segmentReasoning: - if seg.content != "" { + case replstream.SegmentReasoning: + if seg.Content != "" { items = append(items, session.TranscriptItem{ Kind: session.TranscriptItemReasoning, - Content: seg.content, + Content: seg.Content, }) } - case segmentToolStart: - if seg.toolCall != nil { + case replstream.SegmentToolStart: + if seg.ToolCall != nil { items = append(items, session.TranscriptItem{ Kind: session.TranscriptItemToolStart, ToolStart: &session.ToolStartPayload{ - Name: seg.toolCall.Name, - Input: cloneInput(seg.toolCall.Input), + Name: seg.ToolCall.Name, + Input: cloneInput(seg.ToolCall.Input), }, }) } - case segmentToolEnd: - if seg.toolCall != nil { + case replstream.SegmentToolEnd: + if seg.ToolCall != nil { items = append(items, session.TranscriptItem{ Kind: session.TranscriptItemToolEnd, ToolEnd: &session.ToolEndPayload{ - Name: seg.toolCall.Name, - Input: cloneInput(seg.toolCall.Input), - Output: seg.toolCall.Output, - Error: seg.toolCall.Error, - DurationNS: seg.toolCall.Duration.Nanoseconds(), + Name: seg.ToolCall.Name, + Input: cloneInput(seg.ToolCall.Input), + Output: seg.ToolCall.Output, + Error: seg.ToolCall.Error, + DurationNS: seg.ToolCall.Duration.Nanoseconds(), }, }) } - case segmentBash: + case replstream.SegmentBash: duration := int64(0) errText := "" - if seg.toolCall != nil { - duration = seg.toolCall.Duration.Nanoseconds() - errText = seg.toolCall.Error + if seg.ToolCall != nil { + duration = seg.ToolCall.Duration.Nanoseconds() + errText = seg.ToolCall.Error } items = append(items, session.TranscriptItem{ Kind: session.TranscriptItemBash, Bash: &session.BashPayload{ - Command: seg.command, - Summary: seg.summary, - Output: seg.output, + Command: seg.Command, + Summary: seg.Summary, + Output: seg.Output, Error: errText, DurationNS: duration, }, }) - case segmentDiff: - if len(seg.diffLines) > 0 { - lines := make([]agentcore.EditDiffLine, len(seg.diffLines)) - copy(lines, seg.diffLines) + case replstream.SegmentDiff: + if len(seg.DiffLines) > 0 { + lines := make([]agentcore.EditDiffLine, len(seg.DiffLines)) + copy(lines, seg.DiffLines) items = append(items, session.TranscriptItem{ Kind: session.TranscriptItemDiff, Diff: &session.DiffPayload{Lines: agentcore.ToToolDiffLines(lines)}, @@ -234,25 +235,8 @@ func cloneInput(input map[string]any) map[string]any { return result } -func cloneStreamSegments(segments []streamSegment) []streamSegment { - result := make([]streamSegment, len(segments)) - for i, seg := range segments { - result[i] = seg - if seg.toolCall != nil { - toolCall := *seg.toolCall - toolCall.Input = cloneInput(seg.toolCall.Input) - result[i].toolCall = &toolCall - } - if len(seg.diffLines) > 0 { - diffLines := make([]agentcore.EditDiffLine, len(seg.diffLines)) - copy(diffLines, seg.diffLines) - result[i].diffLines = diffLines - } - if seg.askUser != nil { - result[i].askUser = cloneAskUserState(*seg.askUser) - } - } - return result +func cloneStreamSegments(segments []replstream.Segment) []replstream.Segment { + return replstream.CloneSegments(segments) } func toolCallFromPayload(payload *session.ToolStartPayload) *agentcore.ToolCall { diff --git a/internal/cli/repl/stream_handler.go b/internal/cli/repl/stream/handler.go similarity index 55% rename from internal/cli/repl/stream_handler.go rename to internal/cli/repl/stream/handler.go index e0ebce0..b57f268 100644 --- a/internal/cli/repl/stream_handler.go +++ b/internal/cli/repl/stream/handler.go @@ -1,4 +1,4 @@ -package repl +package stream import ( "strings" @@ -16,16 +16,41 @@ type StreamHandler struct { lastWidth int workingDir string mdRenderer *replmarkdown.Renderer - segments []streamSegment + segments []Segment showThinking bool } -func NewStreamHandler(mdRenderer *replmarkdown.Renderer) *StreamHandler { - return &StreamHandler{ +// Option configures a StreamHandler without exposing its fields. +type Option func(*StreamHandler) + +func WithWorkingDir(dir string) Option { + return func(sh *StreamHandler) { + sh.workingDir = dir + } +} + +func WithShowThinking(show bool) Option { + return func(sh *StreamHandler) { + sh.showThinking = show + } +} + +func WithWidth(width int) Option { + return func(sh *StreamHandler) { + sh.lastWidth = width + } +} + +func NewStreamHandler(mdRenderer *replmarkdown.Renderer, opts ...Option) *StreamHandler { + sh := &StreamHandler{ mdRenderer: mdRenderer, - segments: make([]streamSegment, 0), + segments: make([]Segment, 0), showThinking: true, } + for _, opt := range opts { + opt(sh) + } + return sh } func (sh *StreamHandler) Start(eventCh <-chan agentcore.StreamEvent, loadingText string) { @@ -35,7 +60,7 @@ func (sh *StreamHandler) Start(eventCh <-chan agentcore.StreamEvent, loadingText sh.eventCh = eventCh sh.loadingText = loadingText sh.lastWidth = 0 - sh.segments = make([]streamSegment, 0) + sh.segments = make([]Segment, 0) } func (sh *StreamHandler) IsActive() bool { @@ -58,6 +83,62 @@ func (sh *StreamHandler) SetLoadingText(loadingText string) { sh.loadingText = loadingText } +func (sh *StreamHandler) EventChannel() <-chan agentcore.StreamEvent { + if sh == nil { + return nil + } + return sh.eventCh +} + +func (sh *StreamHandler) WorkingDir() string { + if sh == nil { + return "" + } + return sh.workingDir +} + +func (sh *StreamHandler) SetWorkingDir(dir string) { + if sh == nil { + return + } + sh.workingDir = dir +} + +func (sh *StreamHandler) ShowThinking() bool { + if sh == nil { + return false + } + return sh.showThinking +} + +func (sh *StreamHandler) SetShowThinking(show bool) { + if sh == nil { + return + } + sh.showThinking = show +} + +func (sh *StreamHandler) LastWidth() int { + if sh == nil { + return 0 + } + return sh.lastWidth +} + +func (sh *StreamHandler) SetLastWidth(width int) { + if sh == nil { + return + } + sh.lastWidth = width +} + +func (sh *StreamHandler) Snapshot() []Segment { + if sh == nil { + return nil + } + return CloneSegments(sh.segments) +} + func (sh *StreamHandler) HasContent() bool { return len(sh.segments) > 0 } @@ -68,36 +149,36 @@ func (sh *StreamHandler) HandleChunk(chunk string) { } func (sh *StreamHandler) HandleReasoningChunk(chunk string) { - if n := len(sh.segments); n > 0 && sh.segments[n-1].kind == segmentReasoning { - sh.segments[n-1].content += chunk + if n := len(sh.segments); n > 0 && sh.segments[n-1].Kind == SegmentReasoning { + sh.segments[n-1].Content += chunk return } - sh.segments = append(sh.segments, streamSegment{kind: segmentReasoning, content: chunk}) + sh.segments = append(sh.segments, Segment{Kind: SegmentReasoning, Content: chunk}) } func (sh *StreamHandler) HandleToolStart(toolCall *agentcore.ToolCall) { - sh.segments = append(sh.segments, streamSegment{kind: segmentToolStart, toolCall: toolCall}) + sh.segments = append(sh.segments, Segment{Kind: SegmentToolStart, ToolCall: toolCall}) } func (sh *StreamHandler) HandleToolEnd(toolCall *agentcore.ToolCall) { - sh.segments = append(sh.segments, streamSegment{kind: segmentToolEnd, toolCall: toolCall}) + sh.segments = append(sh.segments, Segment{Kind: SegmentToolEnd, ToolCall: toolCall}) } -func (sh *StreamHandler) SetAskUser(state *askUserState) { +func (sh *StreamHandler) SetAskUser(card *AskUserCard) { for i := len(sh.segments) - 1; i >= 0; i-- { segment := &sh.segments[i] - if segment.kind != segmentAskUser || segment.askUser == nil || !segment.askUser.active() { + if segment.Kind != SegmentAskUser || !segment.AskUser.active() { continue } - if state == nil { + if card == nil { sh.segments = append(sh.segments[:i], sh.segments[i+1:]...) } else { - segment.askUser = cloneAskUserState(*state) + segment.AskUser = card.Clone() } return } - if state != nil { - sh.segments = append(sh.segments, streamSegment{kind: segmentAskUser, askUser: cloneAskUserState(*state)}) + if card != nil { + sh.segments = append(sh.segments, Segment{Kind: SegmentAskUser, AskUser: card.Clone()}) } } @@ -105,61 +186,70 @@ func (sh *StreamHandler) HandleSubagentActivity(activity agentcore.ToolActivity) key := activity.RunID + ":" + activity.CallID switch activity.Event.Type { case agentcore.StreamEventTypeToolStart: - sh.segments = append(sh.segments, streamSegment{ - kind: segmentSubagent, - agent: activity.Agent, - activityKey: key, - toolCall: activity.Event.ToolCall, + sh.segments = append(sh.segments, Segment{ + Kind: SegmentSubagent, + Agent: activity.Agent, + ActivityKey: key, + ToolCall: activity.Event.ToolCall, }) case agentcore.StreamEventTypeToolEnd: for i := len(sh.segments) - 1; i >= 0; i-- { - if sh.segments[i].kind == segmentSubagent && sh.segments[i].activityKey == key { - sh.segments[i].endToolCall = activity.Event.ToolCall + if sh.segments[i].Kind == SegmentSubagent && sh.segments[i].ActivityKey == key { + sh.segments[i].EndToolCall = activity.Event.ToolCall return } } - sh.segments = append(sh.segments, streamSegment{ - kind: segmentSubagent, - agent: activity.Agent, - activityKey: key, - endToolCall: activity.Event.ToolCall, + sh.segments = append(sh.segments, Segment{ + Kind: SegmentSubagent, + Agent: activity.Agent, + ActivityKey: key, + EndToolCall: activity.Event.ToolCall, }) } } func (sh *StreamHandler) HandleBashStart(command, summary string) { - sh.segments = append(sh.segments, streamSegment{ - kind: segmentBash, - command: command, - summary: summary, + sh.segments = append(sh.segments, Segment{ + Kind: SegmentBash, + Command: command, + Summary: summary, }) } func (sh *StreamHandler) HandleBashEnd(toolCall *agentcore.ToolCall) { n := len(sh.segments) - if n > 0 && sh.segments[n-1].kind == segmentBash { + if n > 0 && sh.segments[n-1].Kind == SegmentBash { if result, ok := toolCall.Output.(map[string]any); ok { if stdout, ok := result["stdout"].(string); ok { - sh.segments[n-1].output = stdout + sh.segments[n-1].Output = stdout } if stderr, ok := result["stderr"].(string); ok && stderr != "" { - if sh.segments[n-1].output != "" { - sh.segments[n-1].output += "\n" + if sh.segments[n-1].Output != "" { + sh.segments[n-1].Output += "\n" } - sh.segments[n-1].output += stderr + sh.segments[n-1].Output += stderr } } - sh.segments[n-1].toolCall = toolCall + sh.segments[n-1].ToolCall = toolCall } } func (sh *StreamHandler) HandleDiff(lines []agentcore.EditDiffLine) { - sh.segments = append(sh.segments, streamSegment{ - kind: segmentDiff, - diffLines: lines, + sh.segments = append(sh.segments, Segment{ + Kind: SegmentDiff, + DiffLines: lines, }) } +// TranscriptLines renders the current stream transcript without finalizing or +// resetting the handler. +func (sh *StreamHandler) TranscriptLines() []string { + if sh == nil { + return nil + } + return sh.renderTranscriptLines() +} + func (sh *StreamHandler) HandleDone() ([]string, string) { response := sh.currentResponse lines := sh.renderTranscriptLines() @@ -192,7 +282,7 @@ func (sh *StreamHandler) resetState() { sh.rawResponse = "" sh.eventCh = nil sh.loadingText = "" - sh.segments = make([]streamSegment, 0) + sh.segments = make([]Segment, 0) } func (sh *StreamHandler) appendAssistantVisible(chunk string) { @@ -202,18 +292,18 @@ func (sh *StreamHandler) appendAssistantVisible(chunk string) { sh.currentResponse += chunk - if n := len(sh.segments); n > 0 && sh.segments[n-1].kind == segmentAssistant { - sh.segments[n-1].content += chunk + if n := len(sh.segments); n > 0 && sh.segments[n-1].Kind == SegmentAssistant { + sh.segments[n-1].Content += chunk return } - sh.segments = append(sh.segments, streamSegment{kind: segmentAssistant, content: chunk}) + sh.segments = append(sh.segments, Segment{Kind: SegmentAssistant, Content: chunk}) } // Checkpoint returns the current visible content and starts a fresh segment // collection without disconnecting the active stream. -func (sh *StreamHandler) Checkpoint() ([]string, string, []streamSegment) { - segments := cloneStreamSegments(sh.segments) +func (sh *StreamHandler) Checkpoint() ([]string, string, []Segment) { + segments := CloneSegments(sh.segments) lines := sh.renderTranscriptLines() response := sh.currentResponse sh.ResetContent() @@ -223,7 +313,7 @@ func (sh *StreamHandler) Checkpoint() ([]string, string, []streamSegment) { func (sh *StreamHandler) ResetContent() { sh.currentResponse = "" sh.rawResponse = "" - sh.segments = make([]streamSegment, 0) + sh.segments = make([]Segment, 0) } // RewindForRetry discards only the in-flight assistant/reasoning chunks from a @@ -234,7 +324,7 @@ func (sh *StreamHandler) ResetContent() { func (sh *StreamHandler) RewindForRetry() { for len(sh.segments) > 0 { last := sh.segments[len(sh.segments)-1] - if last.kind == segmentAssistant || last.kind == segmentReasoning { + if last.Kind == SegmentAssistant || last.Kind == SegmentReasoning { sh.segments = sh.segments[:len(sh.segments)-1] continue } @@ -243,8 +333,8 @@ func (sh *StreamHandler) RewindForRetry() { var rebuilt strings.Builder for _, seg := range sh.segments { - if seg.kind == segmentAssistant { - rebuilt.WriteString(seg.content) + if seg.Kind == SegmentAssistant { + rebuilt.WriteString(seg.Content) } } sh.currentResponse = rebuilt.String() diff --git a/internal/cli/repl/stream_handler_test.go b/internal/cli/repl/stream/handler_test.go similarity index 90% rename from internal/cli/repl/stream_handler_test.go rename to internal/cli/repl/stream/handler_test.go index 3de15bf..a3e30c4 100644 --- a/internal/cli/repl/stream_handler_test.go +++ b/internal/cli/repl/stream/handler_test.go @@ -1,4 +1,4 @@ -package repl +package stream import ( "charm.land/lipgloss/v2" @@ -91,11 +91,11 @@ func TestStreamHandler_HandleReasoningChunk_DoesNotAffectAssistantResponse(t *te if len(sh.segments) != 2 { t.Fatalf("expected 2 segments (reasoning + assistant), got %d", len(sh.segments)) } - if sh.segments[0].kind != segmentReasoning { - t.Fatalf("expected first segment reasoning, got %q", sh.segments[0].kind) + if sh.segments[0].Kind != SegmentReasoning { + t.Fatalf("expected first segment reasoning, got %q", sh.segments[0].Kind) } - if sh.segments[0].content != "thinking more" { - t.Fatalf("unexpected reasoning content %q", sh.segments[0].content) + if sh.segments[0].Content != "thinking more" { + t.Fatalf("unexpected reasoning content %q", sh.segments[0].Content) } } @@ -174,11 +174,11 @@ func TestStreamHandler_RewindForRetry_PreservesSealedSegments(t *testing.T) { if len(sh.segments) != 3 { t.Fatalf("expected 3 surviving segments after rewind, got %d", len(sh.segments)) } - if sh.segments[0].kind != segmentAssistant || sh.segments[0].content != "Let me read the file. " { + if sh.segments[0].Kind != SegmentAssistant || sh.segments[0].Content != "Let me read the file. " { t.Fatalf("expected first segment to be the iteration-1 assistant message, got %+v", sh.segments[0]) } - if sh.segments[1].kind != segmentToolStart || sh.segments[2].kind != segmentToolEnd { - t.Fatalf("expected tool start/end pair to remain, got %q/%q", sh.segments[1].kind, sh.segments[2].kind) + if sh.segments[1].Kind != SegmentToolStart || sh.segments[2].Kind != SegmentToolEnd { + t.Fatalf("expected tool start/end pair to remain, got %q/%q", sh.segments[1].Kind, sh.segments[2].Kind) } // currentResponse and rawResponse must be rebuilt to match what's still in the slice, @@ -221,8 +221,8 @@ func TestStreamHandler_RewindForRetry_LeavesSealedTailUnchanged(t *testing.T) { if len(sh.segments) != 2 { t.Fatalf("expected sealed assistant/bash segments to remain, got %d", len(sh.segments)) } - if sh.segments[0].kind != segmentAssistant || sh.segments[1].kind != segmentBash { - t.Fatalf("expected assistant/bash segments to remain, got %q/%q", sh.segments[0].kind, sh.segments[1].kind) + if sh.segments[0].Kind != SegmentAssistant || sh.segments[1].Kind != SegmentBash { + t.Fatalf("expected assistant/bash segments to remain, got %q/%q", sh.segments[0].Kind, sh.segments[1].Kind) } if got := sh.GetResponse(); got != "Running tests. " { t.Fatalf("expected response to remain %q, got %q", "Running tests. ", got) @@ -286,24 +286,24 @@ func TestStreamHandler_HandleDone_AdjacentToolStartEnd_CollapsedToOneLine(t *tes } func TestFinalAssistantRun(t *testing.T) { - segments := []streamSegment{ - {kind: segmentReasoning, content: "thinking"}, - {kind: segmentAssistant, content: "Let me check the config first."}, - {kind: segmentToolStart, toolCall: &agentcore.ToolCall{Name: "read_file"}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file"}}, - {kind: segmentAssistant, content: "## Goal\nShip it."}, + segments := []Segment{ + {Kind: SegmentReasoning, Content: "thinking"}, + {Kind: SegmentAssistant, Content: "Let me check the config first."}, + {Kind: SegmentToolStart, ToolCall: &agentcore.ToolCall{Name: "read_file"}}, + {Kind: SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file"}}, + {Kind: SegmentAssistant, Content: "## Goal\nShip it."}, } - if got := finalAssistantRun(segments); got != "## Goal\nShip it." { - t.Fatalf("finalAssistantRun() = %q", got) + if got := FinalAssistantRun(segments); got != "## Goal\nShip it." { + t.Fatalf("FinalAssistantRun() = %q", got) } - noTools := []streamSegment{{kind: segmentAssistant, content: "whole response"}} - if got := finalAssistantRun(noTools); got != "whole response" { - t.Fatalf("finalAssistantRun() = %q", got) + noTools := []Segment{{Kind: SegmentAssistant, Content: "whole response"}} + if got := FinalAssistantRun(noTools); got != "whole response" { + t.Fatalf("FinalAssistantRun() = %q", got) } - noTrailingText := []streamSegment{{kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "grep"}}} - if got := finalAssistantRun(noTrailingText); got != "" { + noTrailingText := []Segment{{Kind: SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "grep"}}} + if got := FinalAssistantRun(noTrailingText); got != "" { t.Fatalf("expected empty final run, got %q", got) } } @@ -311,26 +311,26 @@ func TestFinalAssistantRun(t *testing.T) { func TestHasNonTextActivity(t *testing.T) { cases := []struct { name string - segments []streamSegment + segments []Segment want bool }{ {name: "empty"}, - {name: "assistant only", segments: []streamSegment{{kind: segmentAssistant, content: "text"}}}, - {name: "reasoning only", segments: []streamSegment{{kind: segmentReasoning, content: "thinking"}}}, - {name: "reasoning and assistant", segments: []streamSegment{{kind: segmentReasoning}, {kind: segmentAssistant, content: "text"}}}, - {name: "tool start", segments: []streamSegment{{kind: segmentToolStart}}, want: true}, - {name: "tool end", segments: []streamSegment{{kind: segmentToolEnd}}, want: true}, - {name: "bash", segments: []streamSegment{{kind: segmentBash}}, want: true}, - {name: "permission", segments: []streamSegment{{kind: segmentPermission}}, want: true}, - {name: "diff", segments: []streamSegment{{kind: segmentDiff}}, want: true}, - {name: "subagent", segments: []streamSegment{{kind: segmentSubagent}}, want: true}, - {name: "ask user", segments: []streamSegment{{kind: segmentAskUser}}, want: true}, + {name: "assistant only", segments: []Segment{{Kind: SegmentAssistant, Content: "text"}}}, + {name: "reasoning only", segments: []Segment{{Kind: SegmentReasoning, Content: "thinking"}}}, + {name: "reasoning and assistant", segments: []Segment{{Kind: SegmentReasoning}, {Kind: SegmentAssistant, Content: "text"}}}, + {name: "tool start", segments: []Segment{{Kind: SegmentToolStart}}, want: true}, + {name: "tool end", segments: []Segment{{Kind: SegmentToolEnd}}, want: true}, + {name: "bash", segments: []Segment{{Kind: SegmentBash}}, want: true}, + {name: "permission", segments: []Segment{{Kind: SegmentPermission}}, want: true}, + {name: "diff", segments: []Segment{{Kind: SegmentDiff}}, want: true}, + {name: "subagent", segments: []Segment{{Kind: SegmentSubagent}}, want: true}, + {name: "ask user", segments: []Segment{{Kind: SegmentAskUser}}, want: true}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - if got := hasNonTextActivity(tt.segments); got != tt.want { - t.Fatalf("hasNonTextActivity() = %v, want %v", got, tt.want) + if got := HasNonTextActivity(tt.segments); got != tt.want { + t.Fatalf("HasNonTextActivity() = %v, want %v", got, tt.want) } }) } diff --git a/internal/cli/repl/stream_permission.go b/internal/cli/repl/stream/permission.go similarity index 87% rename from internal/cli/repl/stream_permission.go rename to internal/cli/repl/stream/permission.go index 9da29c8..9d9f7d9 100644 --- a/internal/cli/repl/stream_permission.go +++ b/internal/cli/repl/stream/permission.go @@ -1,4 +1,4 @@ -package repl +package stream import ( "fmt" @@ -15,20 +15,20 @@ const ( ) func (sh *StreamHandler) HandlePermissionRequest(req *replpermissions.Request) { - sh.segments = append(sh.segments, streamSegment{ - kind: segmentPermission, - permissionReq: req, + sh.segments = append(sh.segments, Segment{ + Kind: SegmentPermission, + PermissionReq: req, }) } // Scan back for pending permission; later events may follow the prompt. -func (sh *StreamHandler) pendingPermissionSegment() *streamSegment { +func (sh *StreamHandler) pendingPermissionSegment() *Segment { for i := len(sh.segments) - 1; i >= 0; i-- { seg := &sh.segments[i] - if seg.kind != segmentPermission || seg.permissionReq == nil { + if seg.Kind != SegmentPermission || seg.PermissionReq == nil { continue } - if seg.permissionReq.Status != replpermissions.StatusPending { + if seg.PermissionReq.Status != replpermissions.StatusPending { continue } return seg @@ -45,7 +45,7 @@ func (sh *StreamHandler) MovePendingCursor(delta int) { if seg == nil { return } - choices := replpermissions.Choices(seg.permissionReq.IsDangerous) + choices := replpermissions.Choices(seg.PermissionReq.IsDangerous) newCursor := seg.permissionCursor + delta if newCursor < 0 { newCursor = 0 @@ -61,7 +61,7 @@ func (sh *StreamHandler) GetPendingChoice() replpermissions.Choice { if seg == nil { return replpermissions.ChoiceDeny } - return replpermissions.ChoiceAt(seg.permissionCursor, seg.permissionReq.IsDangerous) + return replpermissions.ChoiceAt(seg.permissionCursor, seg.PermissionReq.IsDangerous) } func (sh *StreamHandler) GetPendingPermissionRequest() *replpermissions.Request { @@ -69,7 +69,16 @@ func (sh *StreamHandler) GetPendingPermissionRequest() *replpermissions.Request if seg == nil { return nil } - return seg.permissionReq + return seg.PermissionReq +} + +// PendingCursor returns the cursor of the pending permission card, or -1. +func (sh *StreamHandler) PendingCursor() int { + seg := sh.pendingPermissionSegment() + if seg == nil { + return -1 + } + return seg.permissionCursor } func (sh *StreamHandler) ResolvePendingPermission(status replpermissions.Status) { @@ -77,12 +86,12 @@ func (sh *StreamHandler) ResolvePendingPermission(status replpermissions.Status) if seg == nil { return } - seg.permissionReq.Status = status + seg.PermissionReq.Status = status seg.renderedLines = nil } -func renderPermissionCard(seg *streamSegment, width int) []string { - req := seg.permissionReq +func renderPermissionCard(seg *Segment, width int) []string { + req := seg.PermissionReq if req == nil { return nil } @@ -91,7 +100,7 @@ func renderPermissionCard(seg *streamSegment, width int) []string { return renderPermissionResolved(req) } - cardWidth := width - contentHorizontalPadding + cardWidth := width - ContentHorizontalPadding if cardWidth < permissionCardMinWidth { cardWidth = permissionCardMinWidth } diff --git a/internal/cli/repl/stream/permission_test.go b/internal/cli/repl/stream/permission_test.go new file mode 100644 index 0000000..9a38323 --- /dev/null +++ b/internal/cli/repl/stream/permission_test.go @@ -0,0 +1,342 @@ +package stream + +import ( + "charm.land/lipgloss/v2" + "github.com/mochow13/keen-code/internal/agentcore" + replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" + "strings" + "testing" +) + +func TestStreamHandler_RewindForRetry_PreservesResolvedPermissionAndDiff(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + sh.HandleChunk("I'll edit this. ") + sh.HandleDiff([]agentcore.EditDiffLine{{Kind: agentcore.EditDiffLineAdded, Content: "new line", NewLineNum: 1}}) + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + sh.ResolvePendingPermission(replpermissions.StatusAllowed) + sh.HandleReasoningChunk("checking result") + sh.HandleChunk("The edit completed") + + sh.RewindForRetry() + + if len(sh.segments) != 3 { + t.Fatalf("expected 3 surviving segments after rewind, got %d", len(sh.segments)) + } + if sh.segments[0].Kind != SegmentAssistant || sh.segments[1].Kind != SegmentDiff || sh.segments[2].Kind != SegmentPermission { + t.Fatalf("expected assistant/diff/permission segments to remain, got %q/%q/%q", sh.segments[0].Kind, sh.segments[1].Kind, sh.segments[2].Kind) + } + if sh.segments[2].PermissionReq.Status != replpermissions.StatusAllowed { + t.Fatalf("expected resolved permission to remain allowed, got %q", sh.segments[2].PermissionReq.Status) + } + if got := sh.GetResponse(); got != "I'll edit this. " { + t.Fatalf("expected rebuilt response %q, got %q", "I'll edit this. ", got) + } +} + +func makeTestPermissionRequest(isDangerous bool) *replpermissions.Request { + return &replpermissions.Request{ + RequestID: "test-1", + ToolName: "read_file", + Path: "../secret.txt", + ResolvedPath: "/home/user/secret.txt", + IsDangerous: isDangerous, + Status: replpermissions.StatusPending, + ResponseChan: make(chan bool, 1), + } +} + +func TestStreamHandler_HandlePermissionRequest_AddsSegment(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + + if len(sh.segments) != 1 { + t.Fatalf("expected 1 segment, got %d", len(sh.segments)) + } + if sh.segments[0].Kind != SegmentPermission { + t.Errorf("expected SegmentPermission, got %q", sh.segments[0].Kind) + } + if sh.segments[0].PermissionReq != req { + t.Error("expected permission request to be stored in segment") + } +} + +func TestStreamHandler_HasPendingPermission_True(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + + if !sh.HasPendingPermission() { + t.Error("expected HasPendingPermission to be true") + } +} + +func TestStreamHandler_HasPendingPermission_FalseWhenResolved(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + sh.ResolvePendingPermission(replpermissions.StatusAllowed) + + if sh.HasPendingPermission() { + t.Error("expected HasPendingPermission to be false after resolution") + } +} + +func TestStreamHandler_MovePendingCursor(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + + sh.MovePendingCursor(1) + if sh.segments[0].permissionCursor != 1 { + t.Errorf("expected cursor at 1, got %d", sh.segments[0].permissionCursor) + } + + sh.MovePendingCursor(100) + if sh.segments[0].permissionCursor != 3 { + t.Errorf("expected cursor clamped at 3, got %d", sh.segments[0].permissionCursor) + } + + sh.MovePendingCursor(-100) + if sh.segments[0].permissionCursor != 0 { + t.Errorf("expected cursor clamped at 0, got %d", sh.segments[0].permissionCursor) + } +} + +func TestStreamHandler_GetPendingChoice_NonDangerous(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + + if sh.GetPendingChoice() != replpermissions.ChoiceAllow { + t.Error("expected initial choice to be Allow") + } + + sh.MovePendingCursor(1) + if sh.GetPendingChoice() != replpermissions.ChoiceAllowSession { + t.Error("expected choice at cursor 1 to be AllowSession") + } + + sh.MovePendingCursor(1) + if sh.GetPendingChoice() != replpermissions.ChoiceDeny { + t.Error("expected choice at cursor 2 to be Deny") + } +} + +func TestStreamHandler_GetPendingChoice_Dangerous(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(true) + sh.HandlePermissionRequest(req) + + sh.MovePendingCursor(1) + if sh.GetPendingChoice() != replpermissions.ChoiceDeny { + t.Error("expected cursor 1 to be Deny for dangerous (no AllowSession)") + } +} + +func TestStreamHandler_ResolvePendingPermission(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + sh.ResolvePendingPermission(replpermissions.StatusAllowedSession) + + if sh.segments[0].PermissionReq.Status != replpermissions.StatusAllowedSession { + t.Errorf("expected status AllowedSession, got %q", sh.segments[0].PermissionReq.Status) + } +} + +func TestRenderPermissionCard_Pending(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + + view := sh.View(80) + + if !strings.Contains(view, "Permission Required") { + t.Error("expected 'Permission Required' in pending card") + } + if !strings.Contains(view, "read_file") { + t.Error("expected tool name in card") + } + if !strings.Contains(view, "Allow for this session") { + t.Error("expected 'Allow for this session' choice in card") + } + if !strings.Contains(view, "↑/↓") { + t.Error("expected keyboard hint in card") + } +} + +func TestRenderPermissionCard_Dangerous(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(true) + sh.HandlePermissionRequest(req) + + view := sh.View(80) + + if !strings.Contains(view, "Allow Dangerous Command") { + t.Error("expected dangerous warning in card") + } + if strings.Contains(view, "Allow for this session") { + t.Error("expected no 'Allow for this session' for dangerous operations") + } +} + +func TestRenderPermissionCard_Resolved_Allowed(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + sh.ResolvePendingPermission(replpermissions.StatusAllowed) + + view := sh.View(80) + + if !strings.Contains(view, "✓") { + t.Error("expected checkmark in resolved allowed card") + } + if strings.Contains(view, "Permission Required") { + t.Error("expected no card title in resolved state") + } +} + +func TestRenderPermissionCard_Resolved_Denied(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + sh.ResolvePendingPermission(replpermissions.StatusDenied) + + view := sh.View(80) + + if !strings.Contains(view, "✗") { + t.Error("expected X mark in resolved denied card") + } +} + +func TestRenderPermissionCard_PreviewTruncation(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + var previewLines []string + for i := range permissionPreviewMaxLines + 10 { + previewLines = append(previewLines, strings.Repeat("x", i%40)) + } + req.Preview = strings.Join(previewLines, "\n") + sh.HandlePermissionRequest(req) + + view := sh.View(80) + + if !strings.Contains(view, "more preview lines omitted") { + t.Error("expected truncation message in card with long preview") + } +} + +func TestRenderPermissionCard_LongPathWrapsWithinWidth(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(false) + req.Path = "/very/long/path/" + strings.Repeat("nested-directory/", 12) + "file.go" + req.ResolvedPath = "/Users/example/" + strings.Repeat("really-long-segment/", 10) + "file.go" + sh.HandlePermissionRequest(req) + + width := 50 + view := sh.View(width) + + for _, line := range strings.Split(strings.TrimRight(view, "\n"), "\n") { + if w := lipgloss.Width(line); w > width { + t.Fatalf("line exceeds viewport width (%d > %d): %q", w, width, line) + } + } + + if !strings.Contains(view, "Path:") { + t.Error("expected Path field to be present") + } + if !strings.Contains(view, "Resolved:") { + t.Error("expected Resolved field to be present") + } +} + +func TestRenderPermissionCard_LongDangerousCommandWrapsWithinWidth(t *testing.T) { + sh := NewStreamHandler(nil) + sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") + + req := makeTestPermissionRequest(true) + req.Path = "rm -rf " + strings.Repeat("/tmp/very-long-segment-name/", 12) + sh.HandlePermissionRequest(req) + + width := 48 + view := sh.View(width) + + for _, line := range strings.Split(strings.TrimRight(view, "\n"), "\n") { + if w := lipgloss.Width(line); w > width { + t.Fatalf("line exceeds viewport width (%d > %d): %q", w, width, line) + } + } + + if !strings.Contains(view, "Allow Dangerous Command") { + t.Error("expected dangerous command title to be present") + } +} + +func TestPermissionTranscript_ResolvedBeforeDone(t *testing.T) { + sh := NewStreamHandler(nil) + eventCh := make(chan agentcore.StreamEvent) + sh.Start(eventCh, "Loading...") + + sh.HandleChunk("before permission") + + req := makeTestPermissionRequest(false) + sh.HandlePermissionRequest(req) + sh.ResolvePendingPermission(replpermissions.StatusAllowedSession) + + sh.HandleChunk(" after permission") + + lines, _ := sh.HandleDone() + + foundBefore, foundStatus, foundAfter := false, false, false + for _, l := range lines { + if strings.Contains(l, "before permission") { + foundBefore = true + } + if strings.Contains(l, "✓") && strings.Contains(l, "this session") { + foundStatus = true + } + if strings.Contains(l, "after permission") { + foundAfter = true + } + } + + if !foundBefore { + t.Error("expected 'before permission' in transcript") + } + if !foundStatus { + t.Error("expected resolved permission status line in transcript") + } + if !foundAfter { + t.Error("expected 'after permission' in transcript") + } +} diff --git a/internal/cli/repl/stream/render.go b/internal/cli/repl/stream/render.go new file mode 100644 index 0000000..bfcc817 --- /dev/null +++ b/internal/cli/repl/stream/render.go @@ -0,0 +1,475 @@ +package stream + +import ( + "fmt" + "strings" + + "github.com/mochow13/keen-code/internal/agentcore" + + "charm.land/lipgloss/v2" + reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" + repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" +) + +const ( + DefaultWidth = 120 + ContentHorizontalPadding = 4 + + bashOutputMaxLines = 16 + diffLeftPadding = 2 + diffRightPadding = 2 +) + +func wrapAndIndent(styled string, wrapWidth int) []string { + if wrapWidth < 1 { + wrapWidth = 1 + } + wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(styled) + parts := strings.Split(wrapped, "\n") + out := make([]string, len(parts)) + for i, p := range parts { + out[i] = " " + p + } + return out +} + +func wrapTextWithStyle(text string, style lipgloss.Style, width int) string { + if width < 1 { + width = 1 + } + return lipgloss.NewStyle().Width(width).Render(style.Render(text)) +} + +func renderToolStatusLines(line string, width int) []string { + if width <= 0 { + width = DefaultWidth + } + line = strings.TrimPrefix(line, " ") + return wrapAndIndent(line, width-ContentHorizontalPadding) +} + +func (sh *StreamHandler) renderViewLines(width int) []string { + lines := make([]string, 0) + + lastAssistantIdx := -1 + lastReasoningIdx := -1 + for i := range sh.segments { + if sh.segments[i].Kind == SegmentAssistant { + lastAssistantIdx = i + } + if sh.segments[i].Kind == SegmentReasoning { + lastReasoningIdx = i + } + } + + for i := 0; i < len(sh.segments); i++ { + seg := &sh.segments[i] + switch seg.Kind { + case SegmentToolStart: + if seg.ToolCall != nil && seg.ToolCall.Name == agentcore.ToolNameAskUser { + continue + } + if endCalls, endIndex := consecutiveReadCalls(sh.segments, i); len(endCalls) > 1 { + lines = append(lines, renderToolStatusLines(reploutput.FormatFoldedReads(seg.ToolCall, endCalls, sh.workingDir), width)...) + i = endIndex + continue + } + if seg.ToolCall != nil { + if sh.shouldHideToolStart(i) || (i+1 < len(sh.segments) && sh.segments[i+1].Kind == SegmentToolEnd) { + continue + } + lines = append(lines, renderToolStatusLines(reploutput.FormatToolStart(seg.ToolCall, sh.workingDir), width)...) + } + case SegmentToolEnd: + if seg.ToolCall != nil { + if seg.ToolCall.Name == agentcore.ToolNameAskUser || isHiddenToolFailure(seg.ToolCall) { + continue + } + if i > 0 && sh.segments[i-1].Kind == SegmentToolStart && sh.segments[i-1].ToolCall != nil { + lines = append(lines, renderToolStatusLines(reploutput.FormatToolDone(sh.segments[i-1].ToolCall, seg.ToolCall, sh.workingDir), width)...) + } else { + lines = append(lines, renderToolStatusLines(reploutput.FormatToolEnd(seg.ToolCall), width)...) + } + } + case SegmentBash: + lines = append(lines, sh.renderBashSegment(seg, width)...) + case SegmentSubagent: + if isHiddenToolFailure(seg.EndToolCall) { + continue + } + line := reploutput.FormatSubagentTool(seg.Agent, seg.ToolCall, seg.EndToolCall, sh.workingDir) + if line != "" { + lines = append(lines, renderToolStatusLines(line, width)...) + } + case SegmentAssistant: + if seg.renderedLines == nil || i == lastAssistantIdx { + seg.renderedLines = sh.renderAssistantViewLines(seg.Content, width) + } + lines = append(lines, seg.renderedLines...) + case SegmentReasoning: + if !sh.showThinking { + continue + } + if seg.renderedLines == nil || i == lastReasoningIdx { + seg.renderedLines = sh.renderReasoningViewLines(seg.Content, width) + } + lines = append(lines, seg.renderedLines...) + case SegmentPermission: + if seg.PermissionReq != nil { + lines = append(lines, renderPermissionCard(seg, width)...) + } + case SegmentDiff: + lines = append(lines, renderDiffSegment(seg, width)...) + case SegmentAskUser: + if seg.AskUser != nil && seg.AskUser.Render != nil { + if card := seg.AskUser.Render(width); card != "" { + lines = append(lines, strings.Split(strings.Trim(card, "\n"), "\n")...) + } + } + } + } + + return lines +} + +func (sh *StreamHandler) renderTranscriptLines() []string { + lines := make([]string, 0) + + for i := 0; i < len(sh.segments); i++ { + seg := &sh.segments[i] + switch seg.Kind { + case SegmentToolStart: + if seg.ToolCall != nil && seg.ToolCall.Name == agentcore.ToolNameAskUser { + continue + } + if endCalls, endIndex := consecutiveReadCalls(sh.segments, i); len(endCalls) > 1 { + lines = append(lines, renderToolStatusLines(reploutput.FormatFoldedReads(seg.ToolCall, endCalls, sh.workingDir), sh.lastWidth)...) + i = endIndex + continue + } + if seg.ToolCall != nil { + if sh.shouldHideToolStart(i) || (i+1 < len(sh.segments) && sh.segments[i+1].Kind == SegmentToolEnd) { + continue + } + lines = append(lines, renderToolStatusLines(reploutput.FormatToolStart(seg.ToolCall, sh.workingDir), sh.lastWidth)...) + } + case SegmentToolEnd: + if seg.ToolCall != nil { + if seg.ToolCall.Name == agentcore.ToolNameAskUser || isHiddenToolFailure(seg.ToolCall) { + continue + } + if i > 0 && sh.segments[i-1].Kind == SegmentToolStart && sh.segments[i-1].ToolCall != nil { + lines = append(lines, renderToolStatusLines(reploutput.FormatToolDone(sh.segments[i-1].ToolCall, seg.ToolCall, sh.workingDir), sh.lastWidth)...) + } else { + lines = append(lines, renderToolStatusLines(reploutput.FormatToolEnd(seg.ToolCall), sh.lastWidth)...) + } + } + case SegmentBash: + lines = append(lines, sh.renderBashSegment(seg, 0)...) + case SegmentSubagent: + if isHiddenToolFailure(seg.EndToolCall) { + continue + } + line := reploutput.FormatSubagentTool(seg.Agent, seg.ToolCall, seg.EndToolCall, sh.workingDir) + if line != "" { + lines = append(lines, renderToolStatusLines(line, sh.lastWidth)...) + } + case SegmentAssistant: + lines = append(lines, sh.renderAssistantTranscriptLines(seg.Content)...) + case SegmentReasoning: + if !sh.showThinking { + continue + } + lines = append(lines, sh.renderReasoningTranscriptLines(seg.Content)...) + case SegmentPermission: + if seg.PermissionReq != nil { + lines = append(lines, renderPermissionResolved(seg.PermissionReq)...) + } + case SegmentDiff: + lines = append(lines, renderDiffSegment(seg, sh.lastWidth)...) + case SegmentAskUser: + if seg.AskUser != nil && seg.AskUser.Render != nil { + width := sh.lastWidth + if width <= 0 { + width = DefaultWidth + } + if card := seg.AskUser.Render(width); card != "" { + lines = append(lines, strings.Split(strings.Trim(card, "\n"), "\n")...) + } + } + } + } + + return lines +} + +func consecutiveReadCalls(segments []Segment, startIndex int) ([]*agentcore.ToolCall, int) { + if startIndex >= len(segments) || segments[startIndex].Kind != SegmentToolStart { + return nil, startIndex + } + startCall := segments[startIndex].ToolCall + if startCall == nil || startCall.Name != agentcore.ToolNameReadFile { + return nil, startIndex + } + path, _ := startCall.Input["path"].(string) + + var endCalls []*agentcore.ToolCall + endIndex := startIndex + for i := startIndex; i+1 < len(segments); i += 2 { + start := segments[i] + end := segments[i+1] + if start.Kind != SegmentToolStart || start.ToolCall == nil || start.ToolCall.Name != agentcore.ToolNameReadFile || + end.Kind != SegmentToolEnd || end.ToolCall == nil || end.ToolCall.Name != agentcore.ToolNameReadFile || end.ToolCall.Error != "" { + break + } + readPath, _ := start.ToolCall.Input["path"].(string) + if readPath != path { + break + } + endCalls = append(endCalls, end.ToolCall) + endIndex = i + 1 + } + return endCalls, endIndex +} + +func (sh *StreamHandler) shouldHideToolStart(index int) bool { + return index+1 < len(sh.segments) && sh.segments[index+1].Kind == SegmentToolEnd && isHiddenToolFailure(sh.segments[index+1].ToolCall) +} + +// IsHiddenToolFailure reports whether a tool failure is hidden from the +// transcript view (e.g. expected read/edit misses). Headless progress uses +// it to skip noise on the console. +func IsHiddenToolFailure(toolCall *agentcore.ToolCall) bool { + return isHiddenToolFailure(toolCall) +} +func isHiddenToolFailure(toolCall *agentcore.ToolCall) bool { + if toolCall == nil { + return false + } + if toolCall.Name == agentcore.ToolNameReadFile { + return strings.HasPrefix(toolCall.Error, "not found: file ") + } + if toolCall.Name != agentcore.ToolNameEditFile { + return false + } + return strings.Contains(toolCall.Error, "line hash mismatch") || + strings.Contains(toolCall.Error, "anchor ") && strings.Contains(toolCall.Error, "does not exist in the current file snapshot") || + strings.Contains(toolCall.Error, "only insert_head is valid for an empty file") || + strings.HasPrefix(toolCall.Error, "ops ") && (strings.Contains(toolCall.Error, "overlapping ranges") || strings.Contains(toolCall.Error, " conflict:")) || + strings.HasPrefix(toolCall.Error, "not found: file ") || + strings.HasPrefix(toolCall.Error, "not a file: ") && strings.HasSuffix(toolCall.Error, " is a directory") || + strings.HasPrefix(toolCall.Error, "path resolution failed:") +} + +func (sh *StreamHandler) renderAssistantViewLines(content string, width int) []string { + if content == "" { + return nil + } + + if sh.mdRenderer != nil { + rendered := sh.mdRenderer.Render(content) + if rendered == "" { + return nil + } + rawLines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") + formatted := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + formatted = append(formatted, " "+line) + } + return formatted + } + + responseLines := strings.Split(content, "\n") + wrapWidth := width - ContentHorizontalPadding + formatted := make([]string, 0, len(responseLines)) + for _, line := range responseLines { + formatted = append(formatted, wrapAndIndent(repltheme.AssistantStyle.Render(line), wrapWidth)...) + } + return formatted +} + +func (sh *StreamHandler) renderAssistantTranscriptLines(content string) []string { + if content == "" { + return nil + } + + if sh.mdRenderer != nil { + rendered := sh.mdRenderer.Render(content) + if rendered == "" { + return nil + } + rawLines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") + formatted := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + formatted = append(formatted, " "+line) + } + return formatted + } + + return formatResponseLines(content) +} + +func (sh *StreamHandler) renderReasoningViewLines(content string, width int) []string { + if content == "" { + return nil + } + + responseLines := strings.Split(content, "\n") + wrapWidth := width - ContentHorizontalPadding + formatted := make([]string, 0, len(responseLines)) + for _, line := range responseLines { + formatted = append(formatted, wrapAndIndent(repltheme.ReasoningStyle.Render(line), wrapWidth)...) + } + return formatted +} + +func (sh *StreamHandler) renderReasoningTranscriptLines(content string) []string { + if content == "" { + return nil + } + + lines := strings.Split(content, "\n") + wrapWidth := sh.lastWidth - ContentHorizontalPadding + if wrapWidth < 1 { + wrapWidth = DefaultWidth + } + + result := make([]string, 0, len(lines)) + for _, line := range lines { + result = append(result, wrapAndIndent(repltheme.ReasoningStyle.Render(line), wrapWidth)...) + } + return result +} + +func formatResponseLines(response string) []string { + lines := strings.Split(response, "\n") + result := make([]string, len(lines)) + for i, line := range lines { + result[i] = " " + line + } + return result +} + +func (sh *StreamHandler) renderBashSegment(seg *Segment, width int) []string { + ruleWidth := DefaultWidth + if width > 0 { + ruleWidth = width + } + if ruleWidth < 1 { + ruleWidth = 1 + } + rule := repltheme.RuleStyle.Render(strings.Repeat("─", ruleWidth)) + + lines := make([]string, 0) + + lines = append(lines, "") + lines = append(lines, rule) + if width > 0 { + lines = append(lines, wrapAndIndent(repltheme.BashCommandStyle.Render("$ "+seg.Command), width-ContentHorizontalPadding)...) + } else { + lines = append(lines, repltheme.BashCommandStyle.Render(" $ "+seg.Command)) + } + + if seg.Summary != "" { + lines = append(lines, repltheme.BashSummaryStyle.Render(" › "+seg.Summary)) + } + + lines = append(lines, "") + + if seg.Output != "" { + outputLines := strings.Split(seg.Output, "\n") + total := len(outputLines) + visible := outputLines + if total > bashOutputMaxLines { + visible = outputLines[:bashOutputMaxLines] + } + for _, line := range visible { + if width > 0 { + lines = append(lines, wrapAndIndent(repltheme.BashOutputStyle.Render(line), width-ContentHorizontalPadding)...) + } else { + lines = append(lines, " "+repltheme.BashOutputStyle.Render(line)) + } + } + if total > bashOutputMaxLines { + accentStyle := lipgloss.NewStyle().Foreground(repltheme.AccentColor) + lines = append(lines, " "+accentStyle.Render(fmt.Sprintf("→ %d more lines", total-bashOutputMaxLines))) + } + } + + lines = append(lines, rule) + + return lines +} + +func renderWrappedDiffLine(prefix string, content string, contentStyle lipgloss.Style, width int) []string { + renderedPrefix := prefix + if width <= 0 { + return []string{renderedPrefix + contentStyle.Render(content)} + } + + contentWidth := width - lipgloss.Width(renderedPrefix) - diffRightPadding + if contentWidth < 1 { + contentWidth = 1 + } + + wrapped := lipgloss.NewStyle().Width(contentWidth).Render(contentStyle.Render(content)) + wrappedLines := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") + if len(wrappedLines) == 0 { + return []string{renderedPrefix} + } + + lines := make([]string, 0, len(wrappedLines)) + lines = append(lines, renderedPrefix+wrappedLines[0]) + + continuationPrefix := strings.Repeat(" ", lipgloss.Width(renderedPrefix)) + for _, line := range wrappedLines[1:] { + lines = append(lines, continuationPrefix+line) + } + + return lines +} + +func renderDiffLines(dl agentcore.EditDiffLine, width int) []string { + switch dl.Kind { + case agentcore.EditDiffLineHunk: + return renderWrappedDiffLine(" ", dl.Content, repltheme.DiffHunkStyle, width) + case agentcore.EditDiffLineAdded: + lineNum := fmt.Sprintf("%4d", dl.NewLineNum) + prefix := repltheme.DiffLineNumStyle.Render(" "+lineNum) + " " + repltheme.DiffAddStyle.Render("+ ") + return renderWrappedDiffLine(prefix, dl.Content, repltheme.DiffAddStyle, width) + case agentcore.EditDiffLineRemoved: + lineNum := fmt.Sprintf("%4d", dl.OldLineNum) + prefix := repltheme.DiffLineNumStyle.Render(lineNum+" ") + " " + repltheme.DiffRemoveStyle.Render("- ") + return renderWrappedDiffLine(prefix, dl.Content, repltheme.DiffRemoveStyle, width) + default: + prefix := repltheme.DiffLineNumStyle.Render(fmt.Sprintf("%4d %4d", dl.OldLineNum, dl.NewLineNum)) + " " + repltheme.DiffContextStyle.Render(" ") + return renderWrappedDiffLine(prefix, dl.Content, repltheme.DiffContextStyle, width) + } +} + +func renderDiffSegment(seg *Segment, width int) []string { + if len(seg.DiffLines) == 0 { + return nil + } + + rendered := make([]string, 0, len(seg.DiffLines)) + for _, dl := range seg.DiffLines { + rendered = append(rendered, renderDiffLines(dl, width)...) + } + + ruleWidth := DefaultWidth - diffLeftPadding - diffRightPadding + if width > 0 { + ruleWidth = width - diffLeftPadding - diffRightPadding + } + if ruleWidth < 1 { + ruleWidth = 1 + } + + rule := strings.Repeat(" ", diffLeftPadding) + repltheme.RuleStyle.Render(strings.Repeat("─", ruleWidth)) + lines := make([]string, 0, len(rendered)+3) + lines = append(lines, "") + lines = append(lines, rule) + lines = append(lines, rendered...) + lines = append(lines, rule) + return lines +} diff --git a/internal/cli/repl/stream_render_test.go b/internal/cli/repl/stream/render_test.go similarity index 87% rename from internal/cli/repl/stream_render_test.go rename to internal/cli/repl/stream/render_test.go index 05cb01e..b55a868 100644 --- a/internal/cli/repl/stream_render_test.go +++ b/internal/cli/repl/stream/render_test.go @@ -1,4 +1,4 @@ -package repl +package stream import ( "github.com/mochow13/keen-code/internal/agentcore" @@ -45,7 +45,7 @@ func TestRenderBashSegmentCoversWidthSummaryAndTruncation(t *testing.T) { output[i] = "line" } handler := NewStreamHandler(nil) - segment := &streamSegment{kind: segmentBash, command: "go test ./...", summary: "testing", output: strings.Join(output, "\n")} + segment := &Segment{Kind: SegmentBash, Command: "go test ./...", Summary: "testing", Output: strings.Join(output, "\n")} lines := handler.renderBashSegment(segment, 20) got := ansi.Strip(strings.Join(lines, "\n")) for _, want := range []string{"$ go test", "testing", "2 more lines"} { @@ -54,7 +54,7 @@ func TestRenderBashSegmentCoversWidthSummaryAndTruncation(t *testing.T) { } } - withoutWidth := ansi.Strip(strings.Join(handler.renderBashSegment(&streamSegment{command: "pwd", output: "path"}, 0), "\n")) + withoutWidth := ansi.Strip(strings.Join(handler.renderBashSegment(&Segment{Command: "pwd", Output: "path"}, 0), "\n")) if !strings.Contains(withoutWidth, "$ pwd") || !strings.Contains(withoutWidth, "path") { t.Fatalf("unbounded bash render = %q", withoutWidth) } @@ -64,10 +64,10 @@ func TestRenderViewAndTranscriptHandleStandaloneToolEnd(t *testing.T) { handler := NewStreamHandler(nil) handler.lastWidth = 40 handler.showThinking = true - handler.segments = []streamSegment{ - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"total_lines": 2}}}, - {kind: segmentAssistant, content: "done"}, - {kind: segmentReasoning, content: "thought"}, + handler.segments = []Segment{ + {Kind: SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"total_lines": 2}}}, + {Kind: SegmentAssistant, Content: "done"}, + {Kind: SegmentReasoning, Content: "thought"}, } view := ansi.Strip(strings.Join(handler.renderViewLines(40), "\n")) transcript := ansi.Strip(strings.Join(handler.renderTranscriptLines(), "\n")) @@ -81,10 +81,10 @@ func TestRenderViewAndTranscriptHandleStandaloneToolEnd(t *testing.T) { } func TestRenderFoldsOnlyConsecutiveReadsOfSameFile(t *testing.T) { - read := func(path string, lines, bytes int) []streamSegment { - return []streamSegment{ - {kind: segmentToolStart, toolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": path}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"lines_read": lines, "bytes_read": bytes}}}, + read := func(path string, lines, bytes int) []Segment { + return []Segment{ + {Kind: SegmentToolStart, ToolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": path}}}, + {Kind: SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"lines_read": lines, "bytes_read": bytes}}}, } } @@ -111,12 +111,12 @@ func TestRenderFoldsOnlyConsecutiveReadsOfSameFile(t *testing.T) { } func TestConsecutiveReadCallsRequireSuccessfulAdjacentPairs(t *testing.T) { - segments := []streamSegment{ - {kind: segmentToolStart, toolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "same.go"}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file"}}, - {kind: segmentAssistant, content: "between"}, - {kind: segmentToolStart, toolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "same.go"}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file", Error: "failed"}}, + segments := []Segment{ + {Kind: SegmentToolStart, ToolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "same.go"}}}, + {Kind: SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file"}}, + {Kind: SegmentAssistant, Content: "between"}, + {Kind: SegmentToolStart, ToolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "same.go"}}}, + {Kind: SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file", Error: "failed"}}, } calls, endIndex := consecutiveReadCalls(segments, 0) @@ -130,7 +130,7 @@ func TestConsecutiveReadCallsRequireSuccessfulAdjacentPairs(t *testing.T) { } func TestRenderDiffBoundaryBranches(t *testing.T) { - if lines := renderDiffSegment(&streamSegment{}, 20); lines != nil { + if lines := renderDiffSegment(&Segment{}, 20); lines != nil { t.Fatalf("empty diff segment = %#v", lines) } lines := renderWrappedDiffLine("prefix", "content", replthemeZeroStyle(), 0) diff --git a/internal/cli/repl/stream/segments.go b/internal/cli/repl/stream/segments.go new file mode 100644 index 0000000..55eb2a6 --- /dev/null +++ b/internal/cli/repl/stream/segments.go @@ -0,0 +1,123 @@ +package stream + +import ( + "strings" + + "github.com/mochow13/keen-code/internal/agentcore" + replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" +) + +type SegmentType string + +const ( + SegmentAssistant SegmentType = "assistant" + SegmentReasoning SegmentType = "reasoning" + SegmentToolStart SegmentType = "tool_start" + SegmentToolEnd SegmentType = "tool_end" + SegmentBash SegmentType = "bash" + SegmentPermission SegmentType = "permission" + SegmentDiff SegmentType = "diff" + SegmentSubagent SegmentType = "subagent_tool" + SegmentAskUser SegmentType = "ask_user" +) + +// AskUserCard is an immutable snapshot of the ask-user REPL state for one +// stream segment. The parent REPL owns rendering; the stream package only +// stores the snapshot and calls Render lazily. +type AskUserCard struct { + Active bool + Render func(width int) string +} + +func (c *AskUserCard) Clone() *AskUserCard { + if c == nil { + return nil + } + cp := *c + return &cp +} + +func (c *AskUserCard) active() bool { + return c != nil && c.Active +} + +type Segment struct { + Kind SegmentType + Content string + ToolCall *agentcore.ToolCall + Command string + Summary string + Output string + PermissionReq *replpermissions.Request + DiffLines []agentcore.EditDiffLine + Agent string + ActivityKey string + EndToolCall *agentcore.ToolCall + AskUser *AskUserCard + + renderedLines []string + permissionCursor int +} + +func CloneSegments(segments []Segment) []Segment { + result := make([]Segment, len(segments)) + for i, seg := range segments { + result[i] = seg + if seg.ToolCall != nil { + toolCall := *seg.ToolCall + toolCall.Input = cloneInput(seg.ToolCall.Input) + result[i].ToolCall = &toolCall + } + if seg.EndToolCall != nil { + endCall := *seg.EndToolCall + endCall.Input = cloneInput(seg.EndToolCall.Input) + result[i].EndToolCall = &endCall + } + if len(seg.DiffLines) > 0 { + diffLines := make([]agentcore.EditDiffLine, len(seg.DiffLines)) + copy(diffLines, seg.DiffLines) + result[i].DiffLines = diffLines + } + if seg.AskUser != nil { + result[i].AskUser = seg.AskUser.Clone() + } + if len(seg.renderedLines) > 0 { + rendered := make([]string, len(seg.renderedLines)) + copy(rendered, seg.renderedLines) + result[i].renderedLines = rendered + } + } + return result +} + +func cloneInput(input map[string]any) map[string]any { + if input == nil { + return nil + } + result := make(map[string]any, len(input)) + for key, value := range input { + result[key] = value + } + return result +} + +func FinalAssistantRun(segments []Segment) string { + start := len(segments) + for start > 0 && segments[start-1].Kind == SegmentAssistant { + start-- + } + var content strings.Builder + for _, segment := range segments[start:] { + content.WriteString(segment.Content) + } + return content.String() +} + +func HasNonTextActivity(segments []Segment) bool { + for _, segment := range segments { + if segment.Kind != SegmentAssistant && segment.Kind != SegmentReasoning { + return true + } + } + return false +} diff --git a/internal/cli/repl/stream/stream_test.go b/internal/cli/repl/stream/stream_test.go new file mode 100644 index 0000000..9004c37 --- /dev/null +++ b/internal/cli/repl/stream/stream_test.go @@ -0,0 +1,123 @@ +package stream + +import ( + "testing" + + "github.com/mochow13/keen-code/internal/agentcore" + replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" +) + +func TestHandlerChunkAndSnapshot(t *testing.T) { + h := NewStreamHandler(nil, WithWorkingDir("/repo"), WithShowThinking(true)) + h.Start(make(chan agentcore.StreamEvent), "Loading...") + h.HandleChunk("Hello") + h.HandleChunk(" World") + if got := h.GetResponse(); got != "Hello World" { + t.Fatalf("response = %q", got) + } + snap := h.Snapshot() + if len(snap) != 1 || snap[0].Kind != SegmentAssistant || snap[0].Content != "Hello World" { + t.Fatalf("snapshot = %#v", snap) + } + snap[0].Content = "mutated" + if got := h.Snapshot()[0].Content; got != "Hello World" { + t.Fatalf("snapshot not isolated, got %q", got) + } +} + +func TestHandlerOptionsAndAccessors(t *testing.T) { + h := NewStreamHandler(nil, WithWorkingDir("/w"), WithShowThinking(false), WithWidth(42)) + if h.WorkingDir() != "/w" { + t.Fatalf("working dir = %q", h.WorkingDir()) + } + if h.ShowThinking() { + t.Fatal("expected showThinking false") + } + if h.LastWidth() != 42 { + t.Fatalf("width = %d", h.LastWidth()) + } + h.SetWorkingDir("/x") + h.SetShowThinking(true) + h.SetLastWidth(80) + if h.WorkingDir() != "/x" || !h.ShowThinking() || h.LastWidth() != 80 { + t.Fatalf("accessors did not stick: %#v", h) + } + ch := make(chan agentcore.StreamEvent) + h.Start(ch, "hi") + if h.EventChannel() == nil { + t.Fatal("expected event channel") + } +} + +func TestHandlerAskUserCard(t *testing.T) { + h := NewStreamHandler(nil) + h.Start(make(chan agentcore.StreamEvent), "Loading...") + h.SetAskUser(&AskUserCard{Active: true, Render: func(width int) string { return "card" }}) + if len(h.Snapshot()) != 1 { + t.Fatalf("expected ask-user segment, got %#v", h.Snapshot()) + } + h.SetAskUser(nil) + if len(h.Snapshot()) != 0 { + t.Fatalf("expected removal, got %#v", h.Snapshot()) + } + h.SetAskUser(&AskUserCard{Active: false, Render: func(width int) string { return "done" }}) + if got := h.View(80); got == "" { + t.Fatal("expected resolved card view") + } +} + +func TestHandlerPermissionFlow(t *testing.T) { + h := NewStreamHandler(nil) + h.Start(make(chan agentcore.StreamEvent), "Loading...") + req := &replpermissions.Request{ToolName: "bash", Status: replpermissions.StatusPending} + h.HandlePermissionRequest(req) + if !h.HasPendingPermission() { + t.Fatal("expected pending permission") + } + h.MovePendingCursor(1) + h.ResolvePendingPermission(replpermissions.StatusAllowed) + if h.HasPendingPermission() { + t.Fatal("expected resolved permission") + } +} + +func TestCloneSegmentsDeepCopies(t *testing.T) { + segs := []Segment{ + {Kind: SegmentToolStart, ToolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "a"}}}, + {Kind: SegmentDiff, DiffLines: []agentcore.EditDiffLine{{Kind: agentcore.EditDiffLineAdded, Content: "x"}}}, + } + cloned := CloneSegments(segs) + segs[0].ToolCall.Input["path"] = "b" + segs[1].DiffLines[0].Content = "y" + if cloned[0].ToolCall.Input["path"] != "a" || cloned[1].DiffLines[0].Content != "x" { + t.Fatalf("clone not deep: %#v", cloned) + } + if FinalAssistantRun([]Segment{{Kind: SegmentAssistant, Content: "hi"}}) != "hi" { + t.Fatal("FinalAssistantRun mismatch") + } + if !HasNonTextActivity([]Segment{{Kind: SegmentToolEnd}}) { + t.Fatal("HasNonTextActivity mismatch") + } +} + +func TestFormatResponseLines(t *testing.T) { + input := "Line 1\nLine 2\nLine 3" + result := formatResponseLines(input) + + if len(result) != 3 { + t.Errorf("expected 3 lines, got %d", len(result)) + } + if result[0] != " Line 1" { + t.Errorf("expected ' Line 1', got '%s'", result[0]) + } + if result[1] != " Line 2" { + t.Errorf("expected ' Line 2', got '%s'", result[1]) + } +} + +func TestFormatResponseLines_Empty(t *testing.T) { + result := formatResponseLines("") + if len(result) != 1 { + t.Errorf("expected 1 line for empty input, got %d", len(result)) + } +} diff --git a/internal/cli/repl/stream_ask_user.go b/internal/cli/repl/stream_ask_user.go index 02aaf38..744182a 100644 --- a/internal/cli/repl/stream_ask_user.go +++ b/internal/cli/repl/stream_ask_user.go @@ -8,6 +8,7 @@ import ( "charm.land/lipgloss/v2" "github.com/mochow13/keen-code/internal/agentcore" replaskuser "github.com/mochow13/keen-code/internal/cli/repl/askuser" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" ) @@ -122,10 +123,28 @@ func (m *replModel) appendResolvedAskUserSegment() { if !m.askUser.completed || m.stream.handler == nil { return } - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) m.clearAskUser() } +// askUserCard snapshots REPL ask-user state for the stream package without +// creating an import cycle. The snapshot is immutable; rendering closes over +// a clone taken at call time. +func askUserCard(s *askUserState) *replstream.AskUserCard { + if s == nil { + return nil + } + cloned := cloneAskUserState(*s) + if !cloned.visible() { + return nil + } + return &replstream.AskUserCard{ + Active: cloned.active(), + Render: func(width int) string { + return renderAskUserCard(*cloned, width) + }, + } +} func renderAskUserCard(s askUserState, width int) string { if !s.visible() { return "" diff --git a/internal/cli/repl/stream_ask_user_test.go b/internal/cli/repl/stream_ask_user_test.go index e816d7a..9ba1420 100644 --- a/internal/cli/repl/stream_ask_user_test.go +++ b/internal/cli/repl/stream_ask_user_test.go @@ -200,7 +200,7 @@ func TestAskUserResolvedSummaryIsOrderedInStream(t *testing.T) { m.stream.handler.Start(make(chan agentcore.StreamEvent), "Loading...") m.stream.handler.HandleChunk("Before") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) m.askUser.answers = []string{"Recommended", "Second"} m.askUser.resolve(nil, false) m.appendResolvedAskUserSegment() @@ -220,7 +220,7 @@ func TestAskUserResolvedSummaryUsesViewportWidth(t *testing.T) { m.width = 80 m.stream.handler.Start(make(chan agentcore.StreamEvent), "Loading...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(&m.askUser) + m.stream.handler.SetAskUser(askUserCard(&m.askUser)) m.askUser.answers = []string{"Recommended", "Second"} m.askUser.resolve(nil, false) m.appendResolvedAskUserSegment() diff --git a/internal/cli/repl/stream_msgs_test.go b/internal/cli/repl/stream_msgs_test.go index 68f851c..9287aa9 100644 --- a/internal/cli/repl/stream_msgs_test.go +++ b/internal/cli/repl/stream_msgs_test.go @@ -117,28 +117,6 @@ func TestWaitForAsyncEvent_ChannelClosed(t *testing.T) { } } -func TestFormatResponseLines(t *testing.T) { - input := "Line 1\nLine 2\nLine 3" - result := formatResponseLines(input) - - if len(result) != 3 { - t.Errorf("expected 3 lines, got %d", len(result)) - } - if result[0] != " Line 1" { - t.Errorf("expected ' Line 1', got '%s'", result[0]) - } - if result[1] != " Line 2" { - t.Errorf("expected ' Line 2', got '%s'", result[1]) - } -} - -func TestFormatResponseLines_Empty(t *testing.T) { - result := formatResponseLines("") - if len(result) != 1 { - t.Errorf("expected 1 line for empty input, got %d", len(result)) - } -} - func TestWaitForAsyncEvent_Permission(t *testing.T) { permissionCh := make(chan *replpermissions.Request, 1) req := makeTestPermissionRequest(false) diff --git a/internal/cli/repl/stream_permission_test.go b/internal/cli/repl/stream_permission_test.go index 3aa27f0..ce06810 100644 --- a/internal/cli/repl/stream_permission_test.go +++ b/internal/cli/repl/stream_permission_test.go @@ -2,41 +2,11 @@ package repl import ( tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/mochow13/keen-code/internal/agentcore" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" - "strings" "testing" ) -func TestStreamHandler_RewindForRetry_PreservesResolvedPermissionAndDiff(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - sh.HandleChunk("I'll edit this. ") - sh.HandleDiff([]agentcore.EditDiffLine{{Kind: agentcore.EditDiffLineAdded, Content: "new line", NewLineNum: 1}}) - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - sh.ResolvePendingPermission(replpermissions.StatusAllowed) - sh.HandleReasoningChunk("checking result") - sh.HandleChunk("The edit completed") - - sh.RewindForRetry() - - if len(sh.segments) != 3 { - t.Fatalf("expected 3 surviving segments after rewind, got %d", len(sh.segments)) - } - if sh.segments[0].kind != segmentAssistant || sh.segments[1].kind != segmentDiff || sh.segments[2].kind != segmentPermission { - t.Fatalf("expected assistant/diff/permission segments to remain, got %q/%q/%q", sh.segments[0].kind, sh.segments[1].kind, sh.segments[2].kind) - } - if sh.segments[2].permissionReq.Status != replpermissions.StatusAllowed { - t.Fatalf("expected resolved permission to remain allowed, got %q", sh.segments[2].permissionReq.Status) - } - if got := sh.GetResponse(); got != "I'll edit this. " { - t.Fatalf("expected rebuilt response %q, got %q", "I'll edit this. ", got) - } -} - func makeTestPermissionRequest(isDangerous bool) *replpermissions.Request { return &replpermissions.Request{ RequestID: "test-1", @@ -49,299 +19,6 @@ func makeTestPermissionRequest(isDangerous bool) *replpermissions.Request { } } -func TestStreamHandler_HandlePermissionRequest_AddsSegment(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - - if len(sh.segments) != 1 { - t.Fatalf("expected 1 segment, got %d", len(sh.segments)) - } - if sh.segments[0].kind != segmentPermission { - t.Errorf("expected segmentPermission, got %q", sh.segments[0].kind) - } - if sh.segments[0].permissionReq != req { - t.Error("expected permission request to be stored in segment") - } -} - -func TestStreamHandler_HasPendingPermission_True(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - - if !sh.HasPendingPermission() { - t.Error("expected HasPendingPermission to be true") - } -} - -func TestStreamHandler_HasPendingPermission_FalseWhenResolved(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - sh.ResolvePendingPermission(replpermissions.StatusAllowed) - - if sh.HasPendingPermission() { - t.Error("expected HasPendingPermission to be false after resolution") - } -} - -func TestStreamHandler_MovePendingCursor(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - - sh.MovePendingCursor(1) - if sh.segments[0].permissionCursor != 1 { - t.Errorf("expected cursor at 1, got %d", sh.segments[0].permissionCursor) - } - - sh.MovePendingCursor(100) - if sh.segments[0].permissionCursor != 3 { - t.Errorf("expected cursor clamped at 3, got %d", sh.segments[0].permissionCursor) - } - - sh.MovePendingCursor(-100) - if sh.segments[0].permissionCursor != 0 { - t.Errorf("expected cursor clamped at 0, got %d", sh.segments[0].permissionCursor) - } -} - -func TestStreamHandler_GetPendingChoice_NonDangerous(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - - if sh.GetPendingChoice() != replpermissions.ChoiceAllow { - t.Error("expected initial choice to be Allow") - } - - sh.MovePendingCursor(1) - if sh.GetPendingChoice() != replpermissions.ChoiceAllowSession { - t.Error("expected choice at cursor 1 to be AllowSession") - } - - sh.MovePendingCursor(1) - if sh.GetPendingChoice() != replpermissions.ChoiceDeny { - t.Error("expected choice at cursor 2 to be Deny") - } -} - -func TestStreamHandler_GetPendingChoice_Dangerous(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(true) - sh.HandlePermissionRequest(req) - - sh.MovePendingCursor(1) - if sh.GetPendingChoice() != replpermissions.ChoiceDeny { - t.Error("expected cursor 1 to be Deny for dangerous (no AllowSession)") - } -} - -func TestStreamHandler_ResolvePendingPermission(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - sh.ResolvePendingPermission(replpermissions.StatusAllowedSession) - - if sh.segments[0].permissionReq.Status != replpermissions.StatusAllowedSession { - t.Errorf("expected status AllowedSession, got %q", sh.segments[0].permissionReq.Status) - } -} - -func TestRenderPermissionCard_Pending(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - - view := sh.View(80) - - if !strings.Contains(view, "Permission Required") { - t.Error("expected 'Permission Required' in pending card") - } - if !strings.Contains(view, "read_file") { - t.Error("expected tool name in card") - } - if !strings.Contains(view, "Allow for this session") { - t.Error("expected 'Allow for this session' choice in card") - } - if !strings.Contains(view, "↑/↓") { - t.Error("expected keyboard hint in card") - } -} - -func TestRenderPermissionCard_Dangerous(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(true) - sh.HandlePermissionRequest(req) - - view := sh.View(80) - - if !strings.Contains(view, "Allow Dangerous Command") { - t.Error("expected dangerous warning in card") - } - if strings.Contains(view, "Allow for this session") { - t.Error("expected no 'Allow for this session' for dangerous operations") - } -} - -func TestRenderPermissionCard_Resolved_Allowed(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - sh.ResolvePendingPermission(replpermissions.StatusAllowed) - - view := sh.View(80) - - if !strings.Contains(view, "✓") { - t.Error("expected checkmark in resolved allowed card") - } - if strings.Contains(view, "Permission Required") { - t.Error("expected no card title in resolved state") - } -} - -func TestRenderPermissionCard_Resolved_Denied(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - sh.ResolvePendingPermission(replpermissions.StatusDenied) - - view := sh.View(80) - - if !strings.Contains(view, "✗") { - t.Error("expected X mark in resolved denied card") - } -} - -func TestRenderPermissionCard_PreviewTruncation(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - var previewLines []string - for i := range permissionPreviewMaxLines + 10 { - previewLines = append(previewLines, strings.Repeat("x", i%40)) - } - req.Preview = strings.Join(previewLines, "\n") - sh.HandlePermissionRequest(req) - - view := sh.View(80) - - if !strings.Contains(view, "more preview lines omitted") { - t.Error("expected truncation message in card with long preview") - } -} - -func TestRenderPermissionCard_LongPathWrapsWithinWidth(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(false) - req.Path = "/very/long/path/" + strings.Repeat("nested-directory/", 12) + "file.go" - req.ResolvedPath = "/Users/example/" + strings.Repeat("really-long-segment/", 10) + "file.go" - sh.HandlePermissionRequest(req) - - width := 50 - view := sh.View(width) - - for _, line := range strings.Split(strings.TrimRight(view, "\n"), "\n") { - if w := lipgloss.Width(line); w > width { - t.Fatalf("line exceeds viewport width (%d > %d): %q", w, width, line) - } - } - - if !strings.Contains(view, "Path:") { - t.Error("expected Path field to be present") - } - if !strings.Contains(view, "Resolved:") { - t.Error("expected Resolved field to be present") - } -} - -func TestRenderPermissionCard_LongDangerousCommandWrapsWithinWidth(t *testing.T) { - sh := NewStreamHandler(nil) - sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") - - req := makeTestPermissionRequest(true) - req.Path = "rm -rf " + strings.Repeat("/tmp/very-long-segment-name/", 12) - sh.HandlePermissionRequest(req) - - width := 48 - view := sh.View(width) - - for _, line := range strings.Split(strings.TrimRight(view, "\n"), "\n") { - if w := lipgloss.Width(line); w > width { - t.Fatalf("line exceeds viewport width (%d > %d): %q", w, width, line) - } - } - - if !strings.Contains(view, "Allow Dangerous Command") { - t.Error("expected dangerous command title to be present") - } -} - -func TestPermissionTranscript_ResolvedBeforeDone(t *testing.T) { - sh := NewStreamHandler(nil) - eventCh := make(chan agentcore.StreamEvent) - sh.Start(eventCh, "Loading...") - - sh.HandleChunk("before permission") - - req := makeTestPermissionRequest(false) - sh.HandlePermissionRequest(req) - sh.ResolvePendingPermission(replpermissions.StatusAllowedSession) - - sh.HandleChunk(" after permission") - - lines, _ := sh.HandleDone() - - foundBefore, foundStatus, foundAfter := false, false, false - for _, l := range lines { - if strings.Contains(l, "before permission") { - foundBefore = true - } - if strings.Contains(l, "✓") && strings.Contains(l, "this session") { - foundStatus = true - } - if strings.Contains(l, "after permission") { - foundAfter = true - } - } - - if !foundBefore { - t.Error("expected 'before permission' in transcript") - } - if !foundStatus { - t.Error("expected resolved permission status line in transcript") - } - if !foundAfter { - t.Error("expected 'after permission' in transcript") - } -} - func TestHandleKeyMsg_PermissionEnter_ResolvesAllowed(t *testing.T) { m := newTestModel() eventCh := make(chan agentcore.StreamEvent) diff --git a/internal/cli/repl/stream_render.go b/internal/cli/repl/stream_render.go index 11d1491..b6966f0 100644 --- a/internal/cli/repl/stream_render.go +++ b/internal/cli/repl/stream_render.go @@ -1,461 +1,11 @@ package repl import ( - "fmt" - "github.com/mochow13/keen-code/internal/agentcore" "strings" - "charm.land/lipgloss/v2" - reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" ) -const ( - bashOutputMaxLines = 16 - diffLeftPadding = 2 - diffRightPadding = 2 -) - -// wrapAndIndent wraps an already-styled string to wrapWidth and prefixes every -// produced sub-line with two spaces so wrapped continuations stay aligned with -// the first line. -func wrapAndIndent(styled string, wrapWidth int) []string { - if wrapWidth < 1 { - wrapWidth = 1 - } - wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(styled) - parts := strings.Split(wrapped, "\n") - out := make([]string, len(parts)) - for i, p := range parts { - out[i] = " " + p - } - return out -} - -func renderToolStatusLines(line string, width int) []string { - if width <= 0 { - width = defaultWidth - } - line = strings.TrimPrefix(line, " ") - return wrapAndIndent(line, width-contentHorizontalPadding) -} - -func (sh *StreamHandler) renderViewLines(width int) []string { - lines := make([]string, 0) - - lastAssistantIdx := -1 - lastReasoningIdx := -1 - for i := range sh.segments { - if sh.segments[i].kind == segmentAssistant { - lastAssistantIdx = i - } - if sh.segments[i].kind == segmentReasoning { - lastReasoningIdx = i - } - } - - for i := 0; i < len(sh.segments); i++ { - seg := &sh.segments[i] - switch seg.kind { - case segmentToolStart: - if seg.toolCall != nil && seg.toolCall.Name == agentcore.ToolNameAskUser { - continue - } - if endCalls, endIndex := consecutiveReadCalls(sh.segments, i); len(endCalls) > 1 { - lines = append(lines, renderToolStatusLines(reploutput.FormatFoldedReads(seg.toolCall, endCalls, sh.workingDir), width)...) - i = endIndex - continue - } - if seg.toolCall != nil { - if sh.shouldHideToolStart(i) || (i+1 < len(sh.segments) && sh.segments[i+1].kind == segmentToolEnd) { - continue - } - lines = append(lines, renderToolStatusLines(reploutput.FormatToolStart(seg.toolCall, sh.workingDir), width)...) - } - case segmentToolEnd: - if seg.toolCall != nil { - if seg.toolCall.Name == agentcore.ToolNameAskUser || isHiddenToolFailure(seg.toolCall) { - continue - } - if i > 0 && sh.segments[i-1].kind == segmentToolStart && sh.segments[i-1].toolCall != nil { - lines = append(lines, renderToolStatusLines(reploutput.FormatToolDone(sh.segments[i-1].toolCall, seg.toolCall, sh.workingDir), width)...) - } else { - lines = append(lines, renderToolStatusLines(reploutput.FormatToolEnd(seg.toolCall), width)...) - } - } - case segmentBash: - lines = append(lines, sh.renderBashSegment(seg, width)...) - case segmentSubagent: - if isHiddenToolFailure(seg.endToolCall) { - continue - } - line := reploutput.FormatSubagentTool(seg.agent, seg.toolCall, seg.endToolCall, sh.workingDir) - if line != "" { - lines = append(lines, renderToolStatusLines(line, width)...) - } - case segmentAssistant: - if seg.renderedLines == nil || i == lastAssistantIdx { - seg.renderedLines = sh.renderAssistantViewLines(seg.content, width) - } - lines = append(lines, seg.renderedLines...) - case segmentReasoning: - if !sh.showThinking { - continue - } - if seg.renderedLines == nil || i == lastReasoningIdx { - seg.renderedLines = sh.renderReasoningViewLines(seg.content, width) - } - lines = append(lines, seg.renderedLines...) - case segmentPermission: - if seg.permissionReq != nil { - lines = append(lines, renderPermissionCard(seg, width)...) - } - case segmentDiff: - lines = append(lines, renderDiffSegment(seg, width)...) - case segmentAskUser: - if seg.askUser != nil { - lines = append(lines, strings.Split(strings.Trim(renderAskUserCard(*seg.askUser, width), "\n"), "\n")...) - } - } - } - - return lines -} - -func (sh *StreamHandler) renderTranscriptLines() []string { - lines := make([]string, 0) - - for i := 0; i < len(sh.segments); i++ { - seg := &sh.segments[i] - switch seg.kind { - case segmentToolStart: - if seg.toolCall != nil && seg.toolCall.Name == agentcore.ToolNameAskUser { - continue - } - if endCalls, endIndex := consecutiveReadCalls(sh.segments, i); len(endCalls) > 1 { - lines = append(lines, renderToolStatusLines(reploutput.FormatFoldedReads(seg.toolCall, endCalls, sh.workingDir), sh.lastWidth)...) - i = endIndex - continue - } - if seg.toolCall != nil { - if sh.shouldHideToolStart(i) || (i+1 < len(sh.segments) && sh.segments[i+1].kind == segmentToolEnd) { - continue - } - lines = append(lines, renderToolStatusLines(reploutput.FormatToolStart(seg.toolCall, sh.workingDir), sh.lastWidth)...) - } - case segmentToolEnd: - if seg.toolCall != nil { - if seg.toolCall.Name == agentcore.ToolNameAskUser || isHiddenToolFailure(seg.toolCall) { - continue - } - if i > 0 && sh.segments[i-1].kind == segmentToolStart && sh.segments[i-1].toolCall != nil { - lines = append(lines, renderToolStatusLines(reploutput.FormatToolDone(sh.segments[i-1].toolCall, seg.toolCall, sh.workingDir), sh.lastWidth)...) - } else { - lines = append(lines, renderToolStatusLines(reploutput.FormatToolEnd(seg.toolCall), sh.lastWidth)...) - } - } - case segmentBash: - lines = append(lines, sh.renderBashSegment(seg, 0)...) - case segmentSubagent: - if isHiddenToolFailure(seg.endToolCall) { - continue - } - line := reploutput.FormatSubagentTool(seg.agent, seg.toolCall, seg.endToolCall, sh.workingDir) - if line != "" { - lines = append(lines, renderToolStatusLines(line, sh.lastWidth)...) - } - case segmentAssistant: - lines = append(lines, sh.renderAssistantTranscriptLines(seg.content)...) - case segmentReasoning: - if !sh.showThinking { - continue - } - lines = append(lines, sh.renderReasoningTranscriptLines(seg.content)...) - case segmentPermission: - if seg.permissionReq != nil { - lines = append(lines, renderPermissionResolved(seg.permissionReq)...) - } - case segmentDiff: - lines = append(lines, renderDiffSegment(seg, sh.lastWidth)...) - case segmentAskUser: - if seg.askUser != nil { - width := sh.lastWidth - if width <= 0 { - width = defaultWidth - } - lines = append(lines, strings.Split(strings.Trim(renderAskUserCard(*seg.askUser, width), "\n"), "\n")...) - } - } - } - - return lines -} - -func consecutiveReadCalls(segments []streamSegment, startIndex int) ([]*agentcore.ToolCall, int) { - if startIndex >= len(segments) || segments[startIndex].kind != segmentToolStart { - return nil, startIndex - } - startCall := segments[startIndex].toolCall - if startCall == nil || startCall.Name != agentcore.ToolNameReadFile { - return nil, startIndex - } - path, _ := startCall.Input["path"].(string) - - var endCalls []*agentcore.ToolCall - endIndex := startIndex - for i := startIndex; i+1 < len(segments); i += 2 { - start := segments[i] - end := segments[i+1] - if start.kind != segmentToolStart || start.toolCall == nil || start.toolCall.Name != agentcore.ToolNameReadFile || - end.kind != segmentToolEnd || end.toolCall == nil || end.toolCall.Name != agentcore.ToolNameReadFile || end.toolCall.Error != "" { - break - } - readPath, _ := start.toolCall.Input["path"].(string) - if readPath != path { - break - } - endCalls = append(endCalls, end.toolCall) - endIndex = i + 1 - } - return endCalls, endIndex -} - -func (sh *StreamHandler) shouldHideToolStart(index int) bool { - return index+1 < len(sh.segments) && sh.segments[index+1].kind == segmentToolEnd && isHiddenToolFailure(sh.segments[index+1].toolCall) -} - -func isHiddenToolFailure(toolCall *agentcore.ToolCall) bool { - if toolCall == nil { - return false - } - if toolCall.Name == agentcore.ToolNameReadFile { - return strings.HasPrefix(toolCall.Error, "not found: file ") - } - if toolCall.Name != agentcore.ToolNameEditFile { - return false - } - return strings.Contains(toolCall.Error, "line hash mismatch") || - strings.Contains(toolCall.Error, "anchor ") && strings.Contains(toolCall.Error, "does not exist in the current file snapshot") || - strings.Contains(toolCall.Error, "only insert_head is valid for an empty file") || - strings.HasPrefix(toolCall.Error, "ops ") && (strings.Contains(toolCall.Error, "overlapping ranges") || strings.Contains(toolCall.Error, " conflict:")) || - strings.HasPrefix(toolCall.Error, "not found: file ") || - strings.HasPrefix(toolCall.Error, "not a file: ") && strings.HasSuffix(toolCall.Error, " is a directory") || - strings.HasPrefix(toolCall.Error, "path resolution failed:") -} - -func (sh *StreamHandler) renderAssistantViewLines(content string, width int) []string { - if content == "" { - return nil - } - - if sh.mdRenderer != nil { - rendered := sh.mdRenderer.Render(content) - if rendered == "" { - return nil - } - rawLines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") - formatted := make([]string, 0, len(rawLines)) - for _, line := range rawLines { - formatted = append(formatted, " "+line) - } - return formatted - } - - responseLines := strings.Split(content, "\n") - wrapWidth := width - contentHorizontalPadding - formatted := make([]string, 0, len(responseLines)) - for _, line := range responseLines { - formatted = append(formatted, wrapAndIndent(repltheme.AssistantStyle.Render(line), wrapWidth)...) - } - return formatted -} - -func (sh *StreamHandler) renderAssistantTranscriptLines(content string) []string { - if content == "" { - return nil - } - - if sh.mdRenderer != nil { - rendered := sh.mdRenderer.Render(content) - if rendered == "" { - return nil - } - rawLines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") - formatted := make([]string, 0, len(rawLines)) - for _, line := range rawLines { - formatted = append(formatted, " "+line) - } - return formatted - } - - return formatResponseLines(content) -} - -func (sh *StreamHandler) renderReasoningViewLines(content string, width int) []string { - if content == "" { - return nil - } - - responseLines := strings.Split(content, "\n") - wrapWidth := width - contentHorizontalPadding - formatted := make([]string, 0, len(responseLines)) - for _, line := range responseLines { - formatted = append(formatted, wrapAndIndent(repltheme.ReasoningStyle.Render(line), wrapWidth)...) - } - return formatted -} - -func (sh *StreamHandler) renderReasoningTranscriptLines(content string) []string { - if content == "" { - return nil - } - - lines := strings.Split(content, "\n") - wrapWidth := sh.lastWidth - contentHorizontalPadding - if wrapWidth < 1 { - wrapWidth = defaultWidth - } - - result := make([]string, 0, len(lines)) - for _, line := range lines { - result = append(result, wrapAndIndent(repltheme.ReasoningStyle.Render(line), wrapWidth)...) - } - return result -} - -func formatResponseLines(response string) []string { - lines := strings.Split(response, "\n") - result := make([]string, len(lines)) - for i, line := range lines { - result[i] = " " + line - } - return result -} - -func (sh *StreamHandler) renderBashSegment(seg *streamSegment, width int) []string { - ruleWidth := defaultWidth - if width > 0 { - ruleWidth = width - } - if ruleWidth < 1 { - ruleWidth = 1 - } - rule := repltheme.RuleStyle.Render(strings.Repeat("─", ruleWidth)) - - lines := make([]string, 0) - - lines = append(lines, "") - lines = append(lines, rule) - if width > 0 { - lines = append(lines, wrapAndIndent(repltheme.BashCommandStyle.Render("$ "+seg.command), width-contentHorizontalPadding)...) - } else { - lines = append(lines, repltheme.BashCommandStyle.Render(" $ "+seg.command)) - } - - if seg.summary != "" { - lines = append(lines, repltheme.BashSummaryStyle.Render(" › "+seg.summary)) - } - - lines = append(lines, "") - - if seg.output != "" { - outputLines := strings.Split(seg.output, "\n") - total := len(outputLines) - visible := outputLines - if total > bashOutputMaxLines { - visible = outputLines[:bashOutputMaxLines] - } - for _, line := range visible { - if width > 0 { - lines = append(lines, wrapAndIndent(repltheme.BashOutputStyle.Render(line), width-contentHorizontalPadding)...) - } else { - lines = append(lines, " "+repltheme.BashOutputStyle.Render(line)) - } - } - if total > bashOutputMaxLines { - accentStyle := lipgloss.NewStyle().Foreground(repltheme.AccentColor) - lines = append(lines, " "+accentStyle.Render(fmt.Sprintf("→ %d more lines", total-bashOutputMaxLines))) - } - } - - lines = append(lines, rule) - - return lines -} - -func renderWrappedDiffLine(prefix string, content string, contentStyle lipgloss.Style, width int) []string { - renderedPrefix := prefix - if width <= 0 { - return []string{renderedPrefix + contentStyle.Render(content)} - } - - contentWidth := width - lipgloss.Width(renderedPrefix) - diffRightPadding - if contentWidth < 1 { - contentWidth = 1 - } - - wrapped := lipgloss.NewStyle().Width(contentWidth).Render(contentStyle.Render(content)) - wrappedLines := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") - if len(wrappedLines) == 0 { - return []string{renderedPrefix} - } - - lines := make([]string, 0, len(wrappedLines)) - lines = append(lines, renderedPrefix+wrappedLines[0]) - - continuationPrefix := strings.Repeat(" ", lipgloss.Width(renderedPrefix)) - for _, line := range wrappedLines[1:] { - lines = append(lines, continuationPrefix+line) - } - - return lines -} - -func renderDiffLines(dl agentcore.EditDiffLine, width int) []string { - switch dl.Kind { - case agentcore.EditDiffLineHunk: - return renderWrappedDiffLine(" ", dl.Content, repltheme.DiffHunkStyle, width) - case agentcore.EditDiffLineAdded: - lineNum := fmt.Sprintf("%4d", dl.NewLineNum) - prefix := repltheme.DiffLineNumStyle.Render(" "+lineNum) + " " + repltheme.DiffAddStyle.Render("+ ") - return renderWrappedDiffLine(prefix, dl.Content, repltheme.DiffAddStyle, width) - case agentcore.EditDiffLineRemoved: - lineNum := fmt.Sprintf("%4d", dl.OldLineNum) - prefix := repltheme.DiffLineNumStyle.Render(lineNum+" ") + " " + repltheme.DiffRemoveStyle.Render("- ") - return renderWrappedDiffLine(prefix, dl.Content, repltheme.DiffRemoveStyle, width) - default: - prefix := repltheme.DiffLineNumStyle.Render(fmt.Sprintf("%4d %4d", dl.OldLineNum, dl.NewLineNum)) + " " + repltheme.DiffContextStyle.Render(" ") - return renderWrappedDiffLine(prefix, dl.Content, repltheme.DiffContextStyle, width) - } -} - -func renderDiffSegment(seg *streamSegment, width int) []string { - if len(seg.diffLines) == 0 { - return nil - } - - rendered := make([]string, 0, len(seg.diffLines)) - for _, dl := range seg.diffLines { - rendered = append(rendered, renderDiffLines(dl, width)...) - } - - ruleWidth := defaultWidth - diffLeftPadding - diffRightPadding - if width > 0 { - ruleWidth = width - diffLeftPadding - diffRightPadding - } - if ruleWidth < 1 { - ruleWidth = 1 - } - - rule := strings.Repeat(" ", diffLeftPadding) + repltheme.RuleStyle.Render(strings.Repeat("─", ruleWidth)) - lines := make([]string, 0, len(rendered)+3) - lines = append(lines, "") - lines = append(lines, rule) - lines = append(lines, rendered...) - lines = append(lines, rule) - return lines -} - func renderBtwQuestionHeader(question string) string { chip := repltheme.BtwChipStyle.Render("btw") return chip + " " + repltheme.BtwLabelStyle.Render(question) diff --git a/internal/cli/repl/stream_segments.go b/internal/cli/repl/stream_segments.go deleted file mode 100644 index e7ede78..0000000 --- a/internal/cli/repl/stream_segments.go +++ /dev/null @@ -1,37 +0,0 @@ -package repl - -import ( - "github.com/mochow13/keen-code/internal/agentcore" - replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" -) - -type streamSegmentType string - -const ( - segmentAssistant streamSegmentType = "assistant" - segmentReasoning streamSegmentType = "reasoning" - segmentToolStart streamSegmentType = "tool_start" - segmentToolEnd streamSegmentType = "tool_end" - segmentBash streamSegmentType = "bash" - segmentPermission streamSegmentType = "permission" - segmentDiff streamSegmentType = "diff" - segmentSubagent streamSegmentType = "subagent_tool" - segmentAskUser streamSegmentType = "ask_user" -) - -type streamSegment struct { - kind streamSegmentType - content string - toolCall *agentcore.ToolCall - command string - summary string - output string - renderedLines []string - permissionReq *replpermissions.Request - permissionCursor int - diffLines []agentcore.EditDiffLine - agent string - activityKey string - endToolCall *agentcore.ToolCall - askUser *askUserState -} diff --git a/internal/cli/repl/stream_segments_test.go b/internal/cli/repl/stream_segments_test.go index aa731ad..8aa2c40 100644 --- a/internal/cli/repl/stream_segments_test.go +++ b/internal/cli/repl/stream_segments_test.go @@ -4,30 +4,31 @@ import ( "testing" "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "github.com/mochow13/keen-code/internal/session" ) func TestCloneStreamSegments_DeepCopiesMutableFields(t *testing.T) { - segments := []streamSegment{ + segments := []replstream.Segment{ { - kind: segmentToolStart, - toolCall: toolCallFromPayload(&session.ToolStartPayload{Name: "read_file", Input: map[string]any{"path": "go.mod"}}), + Kind: replstream.SegmentToolStart, + ToolCall: toolCallFromPayload(&session.ToolStartPayload{Name: "read_file", Input: map[string]any{"path": "go.mod"}}), }, { - kind: segmentDiff, - diffLines: []agentcore.EditDiffLine{{Kind: agentcore.EditDiffLineAdded, Content: "added", NewLineNum: 1}}, + Kind: replstream.SegmentDiff, + DiffLines: []agentcore.EditDiffLine{{Kind: agentcore.EditDiffLineAdded, Content: "added", NewLineNum: 1}}, }, } cloned := cloneStreamSegments(segments) - segments[0].toolCall.Input["path"] = "go.sum" - segments[1].diffLines[0].Content = "changed" + segments[0].ToolCall.Input["path"] = "go.sum" + segments[1].DiffLines[0].Content = "changed" - if cloned[0].toolCall.Input["path"] != "go.mod" { - t.Fatalf("expected cloned tool input to remain unchanged, got %v", cloned[0].toolCall.Input["path"]) + if cloned[0].ToolCall.Input["path"] != "go.mod" { + t.Fatalf("expected cloned tool input to remain unchanged, got %v", cloned[0].ToolCall.Input["path"]) } - if cloned[1].diffLines[0].Content != "added" { - t.Fatalf("expected cloned diff content to remain unchanged, got %q", cloned[1].diffLines[0].Content) + if cloned[1].DiffLines[0].Content != "added" { + t.Fatalf("expected cloned diff content to remain unchanged, got %q", cloned[1].DiffLines[0].Content) } } diff --git a/internal/cli/repl/turn_memory.go b/internal/cli/repl/turn_memory.go index bb5dc99..4f79bf0 100644 --- a/internal/cli/repl/turn_memory.go +++ b/internal/cli/repl/turn_memory.go @@ -3,6 +3,7 @@ package repl import ( "encoding/json" "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "maps" "path/filepath" "strings" @@ -35,28 +36,28 @@ func newTurnMemoryAccumulator(retainOutput bool) *turnMemoryAccumulator { return &turnMemoryAccumulator{retainOutput: retainOutput} } -func (a *turnMemoryAccumulator) RecordToolActivity(segments []streamSegment, workingDir string) { +func (a *turnMemoryAccumulator) RecordToolActivity(segments []replstream.Segment, workingDir string) { if a == nil { return } a.toolActivity = collectHistoricalToolActivity(segments, workingDir, a.retainOutput) } -func collectHistoricalToolActivity(segments []streamSegment, workingDir string, retainOutput bool) []agentcore.HistoricalToolActivity { +func collectHistoricalToolActivity(segments []replstream.Segment, workingDir string, retainOutput bool) []agentcore.HistoricalToolActivity { textOffset := 0 activities := make([]agentcore.HistoricalToolActivity, 0) for _, segment := range segments { - switch segment.kind { - case segmentAssistant: - textOffset += len(segment.content) - case segmentToolEnd: - if segment.toolCall != nil { - activities = append(activities, historicalToolActivity(segment.toolCall, textOffset, workingDir, "", retainOutput)) + switch segment.Kind { + case replstream.SegmentAssistant: + textOffset += len(segment.Content) + case replstream.SegmentToolEnd: + if segment.ToolCall != nil { + activities = append(activities, historicalToolActivity(segment.ToolCall, textOffset, workingDir, "", retainOutput)) } - case segmentBash: - if segment.toolCall != nil { - activities = append(activities, historicalToolActivity(segment.toolCall, textOffset, workingDir, segment.command, retainOutput)) + case replstream.SegmentBash: + if segment.ToolCall != nil { + activities = append(activities, historicalToolActivity(segment.ToolCall, textOffset, workingDir, segment.Command, retainOutput)) } } } @@ -194,7 +195,7 @@ func (m *replModel) startAssistantTurnMemory() { m.turnMemory = newTurnMemoryAccumulator(m.toolHistory == toolHistoryFull) } -func (m *replModel) recordHistoricalToolActivity(segments []streamSegment) { +func (m *replModel) recordHistoricalToolActivity(segments []replstream.Segment) { if m == nil || m.turnMemory == nil { return } diff --git a/internal/cli/repl/turn_memory_test.go b/internal/cli/repl/turn_memory_test.go index 521e344..1144383 100644 --- a/internal/cli/repl/turn_memory_test.go +++ b/internal/cli/repl/turn_memory_test.go @@ -2,6 +2,7 @@ package repl import ( "github.com/mochow13/keen-code/internal/agentcore" + replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" "path/filepath" "strings" "testing" @@ -12,7 +13,7 @@ import ( func TestHandleLLMDone_AttachesTurnMemoryToAssistantMessage(t *testing.T) { workingDir := t.TempDir() - sh := NewStreamHandler(nil) + sh := replstream.NewStreamHandler(nil) sh.Start(make(<-chan agentcore.StreamEvent), "Loading...") sh.HandleChunk("working") sh.HandleToolStart(&agentcore.ToolCall{Name: "edit_file", Input: map[string]any{"path": "nested/a.go"}}) @@ -59,9 +60,9 @@ func TestHandleLLMDone_AttachesTurnMemoryToAssistantMessage(t *testing.T) { } func TestCollectHistoricalToolActivity_RetainsRawOutputsWhenEnabled(t *testing.T) { - activities := collectHistoricalToolActivity([]streamSegment{ - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"content": "package main"}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "bash", Error: "command failed", Output: map[string]any{"exit_code": 1}}}, + activities := collectHistoricalToolActivity([]replstream.Segment{ + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"content": "package main"}}}, + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "bash", Error: "command failed", Output: map[string]any{"exit_code": 1}}}, }, "", true) if !activities[0].HasRawOutput || activities[0].RawOutput.(map[string]any)["content"] != "package main" { @@ -80,9 +81,9 @@ func TestCollectHistoricalToolActivity_RetainsLLMCompressedOutputsWhenEnabled(t "total_lines": 1, "truncated": false, } - activities := collectHistoricalToolActivity([]streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{Name: "read_file", Output: output}, + activities := collectHistoricalToolActivity([]replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{Name: "read_file", Output: output}, }}, "", true) if len(activities) != 1 || activities[0].RetainedOutput == nil { @@ -104,9 +105,9 @@ func TestCollectHistoricalToolActivity_RetainsLLMCompressedOutputsWhenEnabled(t } func TestCollectHistoricalToolActivity_OmitsRawOutputsByDefault(t *testing.T) { - activities := collectHistoricalToolActivity([]streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"content": "package main"}}, + activities := collectHistoricalToolActivity([]replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{Name: "read_file", Output: map[string]any{"content": "package main"}}, }}, "", false) if activities[0].HasRawOutput || activities[0].RawOutput != nil { @@ -117,9 +118,9 @@ func TestCollectHistoricalToolActivity_OmitsRawOutputsByDefault(t *testing.T) { func TestCollectHistoricalToolActivity_RetainsAskUserResult(t *testing.T) { input := map[string]any{"questions": []any{map[string]any{"question": "Pick", "options": []any{"one", "two"}}}} output := map[string]any{"answers": []string{"two"}, "cancelled": false} - activities := collectHistoricalToolActivity([]streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{Name: "ask_user", Input: input, Output: output}, + activities := collectHistoricalToolActivity([]replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{Name: "ask_user", Input: input, Output: output}, }}, "", false) if len(activities) != 1 || activities[0].Input == nil || activities[0].RetainedOutput == nil { t.Fatalf("expected retained ask_user input and output, got %#v", activities) @@ -133,9 +134,9 @@ func TestCollectHistoricalToolActivity_RetainsAskUserResult(t *testing.T) { func TestCollectHistoricalToolActivity_RetainsWriteInputWithoutChangedPath(t *testing.T) { workingDir := t.TempDir() targetPath := filepath.Join(workingDir, "dir", "file.go") - activities := collectHistoricalToolActivity([]streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{ + activities := collectHistoricalToolActivity([]replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{ Name: "write_file", Input: map[string]any{"path": targetPath, "content": "content"}, Output: map[string]any{"file_changed": targetPath}, @@ -167,9 +168,9 @@ func TestCollectHistoricalToolActivity_RelativizesRetainedPathInputs(t *testing. for _, test := range tests { t.Run(test.name, func(t *testing.T) { input := map[string]any{"path": test.path} - activities := collectHistoricalToolActivity([]streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{Name: test.tool, Input: input}, + activities := collectHistoricalToolActivity([]replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{Name: test.tool, Input: input}, }}, workingDir, false) if len(activities) != 1 || activities[0].Input["path"] != test.expected { @@ -185,13 +186,13 @@ func TestCollectHistoricalToolActivity_RelativizesRetainedPathInputs(t *testing. func TestCollectHistoricalToolActivity_RecordsOffsetsInputsAndStatus(t *testing.T) { workingDir := t.TempDir() readPath := filepath.Join(workingDir, "a.go") - segments := []streamSegment{ - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "glob", Input: map[string]any{"path": workingDir, "pattern": "**/*.go"}}}, - {kind: segmentAssistant, content: "Inspecting. "}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": readPath}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "edit_file", Error: "failed", Input: map[string]any{"path": readPath}}}, - {kind: segmentAssistant, content: "Done."}, - {kind: segmentBash, command: "go test ./...", toolCall: &agentcore.ToolCall{Name: "bash"}}, + segments := []replstream.Segment{ + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "glob", Input: map[string]any{"path": workingDir, "pattern": "**/*.go"}}}, + {Kind: replstream.SegmentAssistant, Content: "Inspecting. "}, + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": readPath}}}, + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "edit_file", Error: "failed", Input: map[string]any{"path": readPath}}}, + {Kind: replstream.SegmentAssistant, Content: "Done."}, + {Kind: replstream.SegmentBash, Command: "go test ./...", ToolCall: &agentcore.ToolCall{Name: "bash"}}, } got := collectHistoricalToolActivity(segments, workingDir, false) @@ -213,9 +214,9 @@ func TestCollectHistoricalToolActivity_RecordsOffsetsInputsAndStatus(t *testing. } func TestCollectHistoricalToolActivity_RetainsMCPInput(t *testing.T) { - segments := []streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{ + segments := []replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{ Name: "call_mcp_tool", Input: map[string]any{ "server": "context7", @@ -237,9 +238,9 @@ func TestCollectHistoricalToolActivity_RetainsMCPInput(t *testing.T) { } func TestCollectHistoricalToolActivity_DoesNotInferRetainedOutcomesFromArguments(t *testing.T) { - activities := collectHistoricalToolActivity([]streamSegment{ - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "write_file", Input: map[string]any{"path": "a.go", "content": "content"}, Output: map[string]any{"path": "a.go"}}}, - {kind: segmentBash, command: "go test ./...", toolCall: &agentcore.ToolCall{Name: "bash", Input: map[string]any{"command": "go test ./..."}, Output: map[string]any{"exit_code": 1}}}, + activities := collectHistoricalToolActivity([]replstream.Segment{ + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "write_file", Input: map[string]any{"path": "a.go", "content": "content"}, Output: map[string]any{"path": "a.go"}}}, + {Kind: replstream.SegmentBash, Command: "go test ./...", ToolCall: &agentcore.ToolCall{Name: "bash", Input: map[string]any{"command": "go test ./..."}, Output: map[string]any{"exit_code": 1}}}, }, "", false) if activities[0].Input["path"] != "a.go" || activities[0].Input["content"] != "content" || activities[0].Status != "success" { @@ -251,10 +252,10 @@ func TestCollectHistoricalToolActivity_DoesNotInferRetainedOutcomesFromArguments } func TestCollectHistoricalToolActivity_BashToolErrorSetsErrorStatus(t *testing.T) { - activities := collectHistoricalToolActivity([]streamSegment{{ - kind: segmentBash, - command: "go test ./...", - toolCall: &agentcore.ToolCall{ + activities := collectHistoricalToolActivity([]replstream.Segment{{ + Kind: replstream.SegmentBash, + Command: "go test ./...", + ToolCall: &agentcore.ToolCall{ Name: "bash", Error: "tool execution failed", Output: map[string]any{"exit_code": 1}, @@ -268,9 +269,9 @@ func TestCollectHistoricalToolActivity_BashToolErrorSetsErrorStatus(t *testing.T func TestCollectHistoricalToolActivity_StripsOversizedMCPArguments(t *testing.T) { oversized := string(make([]byte, maxHistoricalToolInputFieldBytes+1)) - segments := []streamSegment{{ - kind: segmentToolEnd, - toolCall: &agentcore.ToolCall{ + segments := []replstream.Segment{{ + Kind: replstream.SegmentToolEnd, + ToolCall: &agentcore.ToolCall{ Name: "call_mcp_tool", Input: map[string]any{ "server": "context7", @@ -293,10 +294,10 @@ func TestCollectHistoricalToolActivity_StripsOversizedMCPArguments(t *testing.T) } func TestCollectHistoricalToolActivity_RetainsWriteAndEditInputs(t *testing.T) { - segments := []streamSegment{ - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "a.go"}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "write_file", Input: map[string]any{"path": "a.go", "content": "content"}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "edit_file", Input: map[string]any{"path": "a.go", "oldString": "old", "newString": "new", "shouldReplaceAll": true}}}, + segments := []replstream.Segment{ + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "read_file", Input: map[string]any{"path": "a.go"}}}, + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "write_file", Input: map[string]any{"path": "a.go", "content": "content"}}}, + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "edit_file", Input: map[string]any{"path": "a.go", "oldString": "old", "newString": "new", "shouldReplaceAll": true}}}, } got := collectHistoricalToolActivity(segments, "", false) @@ -311,9 +312,9 @@ func TestCollectHistoricalToolActivity_RetainsWriteAndEditInputs(t *testing.T) { func TestCollectHistoricalToolActivity_TruncatesOversizedWriteAndEditStrings(t *testing.T) { oversizedASCII := strings.Repeat("a", maxHistoricalToolInputFieldBytes+1) oversizedUTF8 := strings.Repeat("é", maxHistoricalToolInputFieldBytes/2+1) - segments := []streamSegment{ - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "write_file", Input: map[string]any{"path": "a.go", "content": oversizedASCII}}}, - {kind: segmentToolEnd, toolCall: &agentcore.ToolCall{Name: "edit_file", Input: map[string]any{"path": "a.go", "oldString": oversizedASCII, "newString": oversizedUTF8}}}, + segments := []replstream.Segment{ + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "write_file", Input: map[string]any{"path": "a.go", "content": oversizedASCII}}}, + {Kind: replstream.SegmentToolEnd, ToolCall: &agentcore.ToolCall{Name: "edit_file", Input: map[string]any{"path": "a.go", "oldString": oversizedASCII, "newString": oversizedUTF8}}}, } got := collectHistoricalToolActivity(segments, "", false) From c793e9bebf9194057c51ae9f53ba99fb30275a68 Mon Sep 17 00:00:00 2001 From: mochow13 Date: Mon, 28 Sep 2026 21:53:52 +0200 Subject: [PATCH 2/2] refactor(repl): move ask-user state into its package Let stream segments use typed ask-user cards directly, eliminating the REPL-to-stream rendering adapter and clarifying package ownership. --- .agents/agents/worker.md | 4 + internal/cli/repl/ask_user.go | 16 ++ internal/cli/repl/askuser/render.go | 155 ++++++++++++++ internal/cli/repl/askuser/state.go | 129 +++++++++++ internal/cli/repl/handlers.go | 50 ++--- internal/cli/repl/handlers_test.go | 12 +- internal/cli/repl/repl.go | 16 +- internal/cli/repl/session_replay.go | 14 +- internal/cli/repl/stream/handler.go | 5 +- internal/cli/repl/stream/render.go | 4 +- internal/cli/repl/stream/segments.go | 23 +- internal/cli/repl/stream/stream_test.go | 9 +- internal/cli/repl/stream_ask_user.go | 247 ---------------------- internal/cli/repl/stream_ask_user_test.go | 78 +++---- 14 files changed, 400 insertions(+), 362 deletions(-) create mode 100644 internal/cli/repl/ask_user.go create mode 100644 internal/cli/repl/askuser/render.go create mode 100644 internal/cli/repl/askuser/state.go delete mode 100644 internal/cli/repl/stream_ask_user.go diff --git a/.agents/agents/worker.md b/.agents/agents/worker.md index 58c3ddc..3e23564 100644 --- a/.agents/agents/worker.md +++ b/.agents/agents/worker.md @@ -12,3 +12,7 @@ timeout_seconds: 1800 --- Implement only the delegated change. First inspect the relevant code and project instructions, then make the smallest focused change that satisfies the task. Run the most relevant tests or checks when practical. Do not invoke or simulate nested subagents. Return a concise summary of the work, changed files, verification performed and results, and any blockers or follow-up concerns. + +Use this subagent only for a self-contained, bounded task where delegation provides clear value (for example, an independent implementation or investigation). Do not delegate routine lookups, small edits, or work the main agent can efficiently complete itself. + +Avoid unnecessary or obvious code comments. Add comments only when they explain non-obvious behavior, important constraints, or safety-critical reasoning. diff --git a/internal/cli/repl/ask_user.go b/internal/cli/repl/ask_user.go new file mode 100644 index 0000000..1227aa6 --- /dev/null +++ b/internal/cli/repl/ask_user.go @@ -0,0 +1,16 @@ +package repl + +func (m *replModel) clearAskUser() { + if m.stream.handler != nil { + m.stream.handler.SetAskUser(nil) + } + m.askUser.Clear() +} + +func (m *replModel) appendResolvedAskUserSegment() { + if !m.askUser.Completed || m.stream.handler == nil { + return + } + m.stream.handler.SetAskUser(m.askUser.Card()) + m.clearAskUser() +} diff --git a/internal/cli/repl/askuser/render.go b/internal/cli/repl/askuser/render.go new file mode 100644 index 0000000..1ce2011 --- /dev/null +++ b/internal/cli/repl/askuser/render.go @@ -0,0 +1,155 @@ +package askuser + +import ( + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + + repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" +) + +const HorizontalMargin = 4 + +const inputPrefixWidth = 4 + +func NewInput() textinput.Model { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "Type your answer" + var styles textinput.Styles + styles.Focused.Text = repltheme.AskUserTypedStyle + styles.Focused.Placeholder = repltheme.AskUserCustomStyle + styles.Blurred = styles.Focused + styles.Cursor.Color = repltheme.TextDimColor + input.SetStyles(styles) + return input +} + +type Card struct { + Active bool + state State +} + +func (c *Card) Clone() *Card { + if c == nil { + return nil + } + cp := *c + cp.state.Answers = append([]string(nil), c.state.Answers...) + cp.state.Resolved = append([]Answer(nil), c.state.Resolved...) + return &cp +} + +func (c *Card) IsActive() bool { + return c != nil && c.Active +} + +func (c *Card) Render(width int) string { + if c == nil { + return "" + } + return c.state.Render(width) +} + +func (s State) Render(width int) string { + if !s.Visible() { + return "" + } + contentWidth := max(width-HorizontalMargin*2, 1) + var content strings.Builder + if s.Active() { + renderActive(&content, s, contentWidth) + } else { + renderResolved(&content, s, contentWidth) + } + body := strings.TrimRight(content.String(), "\n") + margin := strings.Repeat(" ", HorizontalMargin) + body = margin + strings.ReplaceAll(body, "\n", "\n"+margin) + rule := repltheme.AskUserRuleStyle.Render(strings.Repeat("─", max(width, 1))) + return "\n" + rule + "\n" + body + "\n" + rule + "\n" +} + +func renderActive(content *strings.Builder, s State, width int) { + question := s.Request.Questionnaire.Questions[s.Index] + for i, answer := range s.Answers { + content.WriteString(repltheme.AskUserResolvedStyle.Render("• " + s.Request.Questionnaire.Questions[i].Question + ": " + answer)) + content.WriteString("\n") + } + if len(s.Answers) > 0 { + content.WriteString("\n") + } + content.WriteString(repltheme.AskUserProgressStyle.Render("Question " + strconv.Itoa(s.Index+1) + " of " + strconv.Itoa(len(s.Request.Questionnaire.Questions)))) + content.WriteString("\n") + content.WriteString(wrapText(question.Question, repltheme.AskUserQuestionStyle, width, "", "")) + content.WriteString("\n\n") + for i, option := range question.Options { + badge := "" + if i == 0 { + badge = " " + repltheme.AskUserBadgeStyle.Render("(recommended)") + } + content.WriteString(renderOption(i == s.Selected, option+badge, repltheme.NormalStyle, true, width)) + } + customRow := "Type your answer" + customStyle := repltheme.AskUserCustomStyle + if s.Editing || s.Input.Value() != "" { + input := s.Input + input.SetWidth(max(width-inputPrefixWidth, 1)) + customRow = input.View() + customStyle = lipgloss.NewStyle() + } + content.WriteString(renderOption(s.Selected == len(question.Options), customRow, customStyle, false, width)) + content.WriteString("\n") + hint := "↑/↓ navigate · Enter select · Esc cancel" + if s.Selected == len(question.Options) && s.Editing { + hint = "Enter submit · Esc cancel" + } + content.WriteString(repltheme.AskUserHintStyle.Render(hint)) + content.WriteString("\n") +} + +func renderResolved(content *strings.Builder, s State, width int) { + header := "Answers provided" + if s.Cancelled { + header = "↩ Questions cancelled" + } + content.WriteString(repltheme.AskUserProgressStyle.Render(header)) + if len(s.Resolved) == 0 { + content.WriteString("\n") + return + } + content.WriteString("\n\n") + for _, answer := range s.Resolved { + content.WriteString(wrapText(answer.Question+": "+answer.Answer, repltheme.AskUserResolvedStyle, width, "• ", " ")) + content.WriteString("\n") + } +} + +func renderOption(selected bool, text string, style lipgloss.Style, highlightSelected bool, width int) string { + cursor := " " + if selected { + cursor = repltheme.AskUserSelectedStyle.Render("› ") + } + bulletStyle := style + if selected && highlightSelected { + style = repltheme.AskUserSelectedStyle + bulletStyle = repltheme.AskUserSelectedStyle + } + prefix := cursor + bulletStyle.Render("• ") + return wrapText(text, style, width, prefix, " ") + "\n" +} + +func wrapText(text string, style lipgloss.Style, width int, prefix, continuation string) string { + available := max(width-lipgloss.Width(prefix), 1) + wrapped := lipgloss.NewStyle().Width(available).Render(style.Render(text)) + lines := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") + for i, line := range lines { + if i == 0 { + lines[i] = prefix + line + } else { + lines[i] = continuation + line + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/cli/repl/askuser/state.go b/internal/cli/repl/askuser/state.go new file mode 100644 index 0000000..47d3f48 --- /dev/null +++ b/internal/cli/repl/askuser/state.go @@ -0,0 +1,129 @@ +package askuser + +import ( + "github.com/mochow13/keen-code/internal/agentcore" + + "charm.land/bubbles/v2/textinput" +) + +type Answer struct { + Question string + Answer string +} + +type State struct { + Requester *Requester + Request *Request + Index int + Selected int + Answers []string + Input textinput.Model + Editing bool + Resolved []Answer + Cancelled bool + Completed bool +} + +func NewState(requester *Requester) State { + return State{Requester: requester, Input: NewInput()} +} + +func (s *State) Begin(request *Request) { + *s = State{Requester: s.Requester, Request: request, Input: NewInput()} +} + +func (s State) Active() bool { return s.Request != nil } + +func (s State) Visible() bool { return s.Active() || s.Completed } + +func (s State) Clone() *State { + cloned := s + cloned.Requester = nil + cloned.Answers = append([]string(nil), s.Answers...) + cloned.Resolved = append([]Answer(nil), s.Resolved...) + return &cloned +} + +func (s *State) Clear() { + requester := s.Requester + *s = State{Requester: requester} +} + +func (s *State) SyncInputFocus() { + if s.Editing { + s.Input.Focus() + } else { + s.Input.Blur() + } +} + +func (s *State) Move(delta int) { + if !s.Active() { + return + } + rows := len(s.Request.Questionnaire.Questions[s.Index].Options) + 1 + s.Selected = (s.Selected + delta + rows) % rows + s.Editing = s.Selected == len(s.Request.Questionnaire.Questions[s.Index].Options) + s.SyncInputFocus() +} + +func (s *State) Answer(value string) bool { + s.Answers = append(s.Answers, value) + s.Index++ + s.Selected, s.Editing = 0, false + s.Input.SetValue("") + s.Input.Blur() + return s.Index == len(s.Request.Questionnaire.Questions) +} + +func (s *State) Resolve(requester *Requester, cancelled bool) { + if !s.Active() { + return + } + result := agentcore.AskUserResult{ + Answers: append([]string(nil), s.Answers...), + Cancelled: cancelled, + } + if requester != nil { + requester.Respond(s.Request, result) + } + s.Resolved = make([]Answer, len(s.Answers)) + for i, answer := range s.Answers { + s.Resolved[i] = Answer{ + Question: s.Request.Questionnaire.Questions[i].Question, + Answer: answer, + } + } + s.Request = nil + s.Editing = false + s.Cancelled = cancelled + s.Completed = true +} + +func NewResolvedState(request agentcore.AskUserRequest, result agentcore.AskUserResult) *State { + state := &State{Completed: true, Cancelled: result.Cancelled} + for i, answer := range result.Answers { + if i >= len(request.Questions) { + break + } + state.Resolved = append(state.Resolved, Answer{ + Question: request.Questions[i].Question, + Answer: answer, + }) + } + return state +} + +func (s *State) Card() *Card { + if s == nil { + return nil + } + cloned := s.Clone() + if !cloned.Visible() { + return nil + } + return &Card{ + Active: cloned.Active(), + state: *cloned, + } +} diff --git a/internal/cli/repl/handlers.go b/internal/cli/repl/handlers.go index 08e61b1..ec5a876 100644 --- a/internal/cli/repl/handlers.go +++ b/internal/cli/repl/handlers.go @@ -465,38 +465,38 @@ func (m *replModel) handleSuggestionKeyMsg(keyMsg tea.KeyPressMsg) (bool, replMo func (m *replModel) handleAskUserKeyMsg(msg tea.KeyPressMsg) (replModel, tea.Cmd) { s := &m.askUser - question := s.request.Questionnaire.Questions[s.index] + question := s.Request.Questionnaire.Questions[s.Index] var cmd tea.Cmd switch msg.String() { case keyCtrlC, keyEsc: - s.resolve(s.requester, true) + s.Resolve(s.Requester, true) case keyUp: - s.move(-1) + s.Move(-1) case keyDown: - s.move(1) + s.Move(1) case keyEnter: - if s.selected < len(question.Options) { - if s.answer(question.Options[s.selected]) { - s.resolve(s.requester, false) + if s.Selected < len(question.Options) { + if s.Answer(question.Options[s.Selected]) { + s.Resolve(s.Requester, false) } - } else if s.editing { - if value := s.input.Value(); strings.TrimSpace(value) != "" && s.answer(value) { - s.resolve(s.requester, false) + } else if s.Editing { + if value := s.Input.Value(); strings.TrimSpace(value) != "" && s.Answer(value) { + s.Resolve(s.Requester, false) } } else { - s.editing = true - s.input.Focus() + s.Editing = true + s.Input.Focus() } default: - if s.editing || msg.Text != "" { - s.selected = len(question.Options) - s.editing = true - s.input.Focus() - s.input, cmd = s.input.Update(msg) + if s.Editing || msg.Text != "" { + s.Selected = len(question.Options) + s.Editing = true + s.Input.Focus() + s.Input, cmd = s.Input.Update(msg) } } - if s.active() { - m.stream.handler.SetAskUser(askUserCard(s)) + if s.Active() { + m.stream.handler.SetAskUser(s.Card()) } else { m.appendResolvedAskUserSegment() } @@ -507,12 +507,12 @@ func (m *replModel) handleAskUserKeyMsg(msg tea.KeyPressMsg) (replModel, tea.Cmd func (m *replModel) handleAskUserPasteMsg(msg tea.PasteMsg) (replModel, tea.Cmd) { s := &m.askUser - s.selected = len(s.request.Questionnaire.Questions[s.index].Options) - s.editing = true - s.input.Focus() + s.Selected = len(s.Request.Questionnaire.Questions[s.Index].Options) + s.Editing = true + s.Input.Focus() var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - m.stream.handler.SetAskUser(askUserCard(s)) + s.Input, cmd = s.Input.Update(msg) + m.stream.handler.SetAskUser(s.Card()) m.updateViewportContent() m.scrollToBottomIfFollowing() return *m, cmd @@ -570,7 +570,7 @@ func (m *replModel) handleKeyMsg(msg tea.Msg) (replModel, tea.Cmd) { } } - if m.askUser.active() { + if m.askUser.Active() { return m.handleAskUserKeyMsg(keyMsg) } diff --git a/internal/cli/repl/handlers_test.go b/internal/cli/repl/handlers_test.go index 02de851..2346067 100644 --- a/internal/cli/repl/handlers_test.go +++ b/internal/cli/repl/handlers_test.go @@ -297,7 +297,7 @@ func TestHandleKeyMsg_CtrlC_WithAskUserCancelsQuestionnaireFirst(t *testing.T) { m := newTestModel() m.stream.handler.Start(make(chan agentcore.StreamEvent), "Loading...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) + m.stream.handler.SetAskUser(m.askUser.Card()) streamCanceled := false m.stream.cancel = func() { streamCanceled = true } @@ -306,7 +306,7 @@ func TestHandleKeyMsg_CtrlC_WithAskUserCancelsQuestionnaireFirst(t *testing.T) { if streamCanceled { t.Fatal("ctrl+c should cancel the questionnaire before interrupting the stream") } - if updated.askUser.active() || !updated.stream.handler.IsActive() { + if updated.askUser.Active() || !updated.stream.handler.IsActive() { t.Fatal("ctrl+c should resolve the questionnaire and leave the stream active") } if cmd != nil { @@ -1341,10 +1341,10 @@ func TestHandleLLMIncompleteClearsAskUser(t *testing.T) { m := newTestModel() m.stream.handler.Start(make(chan agentcore.StreamEvent), "Working...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) + m.stream.handler.SetAskUser(m.askUser.Card()) updated, _ := m.handleLLMIncomplete(errors.New("response truncated")) - if updated.askUser.active() { + if updated.askUser.Active() { t.Fatal("incomplete response left questionnaire active") } } @@ -1353,10 +1353,10 @@ func TestHandleLLMErrorClearsAskUser(t *testing.T) { m := newTestModel() m.stream.handler.Start(make(chan agentcore.StreamEvent), "Working...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) + m.stream.handler.SetAskUser(m.askUser.Card()) updated, _ := m.handleLLMError(errors.New("stream failed")) - if updated.askUser.active() { + if updated.askUser.Active() { t.Fatal("stream error left questionnaire active") } } diff --git a/internal/cli/repl/repl.go b/internal/cli/repl/repl.go index 26efd71..bcdd37e 100644 --- a/internal/cli/repl/repl.go +++ b/internal/cli/repl/repl.go @@ -69,7 +69,7 @@ type replModel struct { output *reploutput.OutputBuilder modelSelection *replwidgets.Model permissionRequester *replpermissions.Requester - askUser askUserState + askUser replaskuser.State projectPerms *config.ProjectPermissions diffEmitter *repltooling.DiffEmitter sessions *replSessionState @@ -266,7 +266,7 @@ func initialModel(ctx *replContext, lifecycleCtx context.Context, agentCore agen stream: streamState{handler: replstream.NewStreamHandler(mdRenderer), renderInterval: streamRenderInterval}, mdRenderer: mdRenderer, permissionRequester: permissionRequester, - askUser: askUserState{requester: askUserRequester}, + askUser: replaskuser.NewState(askUserRequester), projectPerms: projectPerms, diffEmitter: diffEmitter, sessions: sessions, @@ -549,8 +549,8 @@ func (m replModel) waitForAsyncEvent() tea.Cmd { return nil } var askUserCh <-chan *replaskuser.Request - if m.askUser.requester != nil { - askUserCh = m.askUser.requester.GetRequestChan() + if m.askUser.Requester != nil { + askUserCh = m.askUser.Requester.GetRequestChan() } var permissionCh <-chan *replpermissions.Request if m.permissionRequester != nil { @@ -654,11 +654,11 @@ func (m replModel) updateNormalMode(msg tea.Msg) (replModel, tea.Cmd) { return m, m.waitForAsyncEvent() case askUserReadyMsg: - if m.askUser.requester == nil || !m.askUser.requester.IsPending(msg.req) { + if m.askUser.Requester == nil || !m.askUser.Requester.IsPending(msg.req) { return m, m.waitForAsyncEvent() } - m.askUser.begin(msg.req) - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) + m.askUser.Begin(msg.req) + m.stream.handler.SetAskUser(m.askUser.Card()) m.textarea.Reset() m.updateViewportContent() m.scrollToBottomIfFollowing() @@ -680,7 +680,7 @@ func (m replModel) updateNormalMode(msg tea.Msg) (replModel, tea.Cmd) { return m, nil case tea.PasteMsg: - if m.askUser.active() { + if m.askUser.Active() { return m.handleAskUserPasteMsg(msg) } diff --git a/internal/cli/repl/session_replay.go b/internal/cli/repl/session_replay.go index 5dab71c..7ed10ff 100644 --- a/internal/cli/repl/session_replay.go +++ b/internal/cli/repl/session_replay.go @@ -8,6 +8,7 @@ import ( "github.com/mochow13/keen-code/internal/agentcore" "github.com/mochow13/keen-code/internal/llm" + replaskuser "github.com/mochow13/keen-code/internal/cli/repl/askuser" replmarkdown "github.com/mochow13/keen-code/internal/cli/repl/markdown" reploutput "github.com/mochow13/keen-code/internal/cli/repl/output" replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" @@ -139,7 +140,7 @@ func replayTranscript(handler *replstream.StreamHandler, transcript []session.Tr case session.TranscriptItemToolEnd: if item.ToolEnd != nil && item.ToolEnd.Name == agentcore.ToolNameAskUser { if state := askUserStateFromPayload(item.ToolEnd); state != nil { - handler.SetAskUser(askUserCard(state)) + handler.SetAskUser(state.Card()) } } else { handler.HandleToolEnd(toolCallResultFromPayload(item.ToolEnd)) @@ -154,7 +155,7 @@ func replayTranscript(handler *replstream.StreamHandler, transcript []session.Tr } } -func askUserStateFromPayload(payload *session.ToolEndPayload) *askUserState { +func askUserStateFromPayload(payload *session.ToolEndPayload) *replaskuser.State { if payload == nil { return nil } @@ -165,14 +166,7 @@ func askUserStateFromPayload(payload *session.ToolEndPayload) *askUserState { return nil } - state := &askUserState{completed: true, cancelled: result.Cancelled} - for i, answer := range result.Answers { - if i >= len(request.Questions) { - break - } - state.resolved = append(state.resolved, askUserAnswer{question: request.Questions[i].Question, answer: answer}) - } - return state + return replaskuser.NewResolvedState(request, result) } func decodeSessionPayload(value any, target any) bool { diff --git a/internal/cli/repl/stream/handler.go b/internal/cli/repl/stream/handler.go index b57f268..72857ae 100644 --- a/internal/cli/repl/stream/handler.go +++ b/internal/cli/repl/stream/handler.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/mochow13/keen-code/internal/agentcore" + replaskuser "github.com/mochow13/keen-code/internal/cli/repl/askuser" replmarkdown "github.com/mochow13/keen-code/internal/cli/repl/markdown" ) @@ -164,10 +165,10 @@ func (sh *StreamHandler) HandleToolEnd(toolCall *agentcore.ToolCall) { sh.segments = append(sh.segments, Segment{Kind: SegmentToolEnd, ToolCall: toolCall}) } -func (sh *StreamHandler) SetAskUser(card *AskUserCard) { +func (sh *StreamHandler) SetAskUser(card *replaskuser.Card) { for i := len(sh.segments) - 1; i >= 0; i-- { segment := &sh.segments[i] - if segment.Kind != SegmentAskUser || !segment.AskUser.active() { + if segment.Kind != SegmentAskUser || !segment.AskUser.IsActive() { continue } if card == nil { diff --git a/internal/cli/repl/stream/render.go b/internal/cli/repl/stream/render.go index bfcc817..6dbc536 100644 --- a/internal/cli/repl/stream/render.go +++ b/internal/cli/repl/stream/render.go @@ -121,7 +121,7 @@ func (sh *StreamHandler) renderViewLines(width int) []string { case SegmentDiff: lines = append(lines, renderDiffSegment(seg, width)...) case SegmentAskUser: - if seg.AskUser != nil && seg.AskUser.Render != nil { + if seg.AskUser != nil { if card := seg.AskUser.Render(width); card != "" { lines = append(lines, strings.Split(strings.Trim(card, "\n"), "\n")...) } @@ -188,7 +188,7 @@ func (sh *StreamHandler) renderTranscriptLines() []string { case SegmentDiff: lines = append(lines, renderDiffSegment(seg, sh.lastWidth)...) case SegmentAskUser: - if seg.AskUser != nil && seg.AskUser.Render != nil { + if seg.AskUser != nil { width := sh.lastWidth if width <= 0 { width = DefaultWidth diff --git a/internal/cli/repl/stream/segments.go b/internal/cli/repl/stream/segments.go index 55eb2a6..6115f26 100644 --- a/internal/cli/repl/stream/segments.go +++ b/internal/cli/repl/stream/segments.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/mochow13/keen-code/internal/agentcore" + replaskuser "github.com/mochow13/keen-code/internal/cli/repl/askuser" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" ) @@ -21,26 +22,6 @@ const ( SegmentAskUser SegmentType = "ask_user" ) -// AskUserCard is an immutable snapshot of the ask-user REPL state for one -// stream segment. The parent REPL owns rendering; the stream package only -// stores the snapshot and calls Render lazily. -type AskUserCard struct { - Active bool - Render func(width int) string -} - -func (c *AskUserCard) Clone() *AskUserCard { - if c == nil { - return nil - } - cp := *c - return &cp -} - -func (c *AskUserCard) active() bool { - return c != nil && c.Active -} - type Segment struct { Kind SegmentType Content string @@ -53,7 +34,7 @@ type Segment struct { Agent string ActivityKey string EndToolCall *agentcore.ToolCall - AskUser *AskUserCard + AskUser *replaskuser.Card renderedLines []string permissionCursor int diff --git a/internal/cli/repl/stream/stream_test.go b/internal/cli/repl/stream/stream_test.go index 9004c37..eccb3d2 100644 --- a/internal/cli/repl/stream/stream_test.go +++ b/internal/cli/repl/stream/stream_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/mochow13/keen-code/internal/agentcore" + replaskuser "github.com/mochow13/keen-code/internal/cli/repl/askuser" replpermissions "github.com/mochow13/keen-code/internal/cli/repl/permissions" ) @@ -52,7 +53,10 @@ func TestHandlerOptionsAndAccessors(t *testing.T) { func TestHandlerAskUserCard(t *testing.T) { h := NewStreamHandler(nil) h.Start(make(chan agentcore.StreamEvent), "Loading...") - h.SetAskUser(&AskUserCard{Active: true, Render: func(width int) string { return "card" }}) + questionnaire := agentcore.AskUserRequest{Questions: []agentcore.AskUserQuestion{{Question: "Pick", Options: []string{"one"}}}} + active := replaskuser.NewState(nil) + active.Begin(&replaskuser.Request{Questionnaire: questionnaire}) + h.SetAskUser(active.Card()) if len(h.Snapshot()) != 1 { t.Fatalf("expected ask-user segment, got %#v", h.Snapshot()) } @@ -60,7 +64,8 @@ func TestHandlerAskUserCard(t *testing.T) { if len(h.Snapshot()) != 0 { t.Fatalf("expected removal, got %#v", h.Snapshot()) } - h.SetAskUser(&AskUserCard{Active: false, Render: func(width int) string { return "done" }}) + resolved := replaskuser.NewResolvedState(questionnaire, agentcore.AskUserResult{Answers: []string{"one"}}) + h.SetAskUser(resolved.Card()) if got := h.View(80); got == "" { t.Fatal("expected resolved card view") } diff --git a/internal/cli/repl/stream_ask_user.go b/internal/cli/repl/stream_ask_user.go deleted file mode 100644 index 744182a..0000000 --- a/internal/cli/repl/stream_ask_user.go +++ /dev/null @@ -1,247 +0,0 @@ -package repl - -import ( - "strconv" - "strings" - - "charm.land/bubbles/v2/textinput" - "charm.land/lipgloss/v2" - "github.com/mochow13/keen-code/internal/agentcore" - replaskuser "github.com/mochow13/keen-code/internal/cli/repl/askuser" - replstream "github.com/mochow13/keen-code/internal/cli/repl/stream" - repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" -) - -const askUserHorizontalMargin = 4 - -// askUserInputPrefixWidth matches the "› • " cursor and bullet prefix used by renderAskUserOption. -const askUserInputPrefixWidth = 4 - -type askUserAnswer struct { - question string - answer string -} - -type askUserState struct { - requester *replaskuser.Requester - request *replaskuser.Request - index int - selected int - answers []string - input textinput.Model - editing bool - resolved []askUserAnswer - cancelled bool - completed bool -} - -func newAskUserInput() textinput.Model { - input := textinput.New() - input.Prompt = "" - input.Placeholder = "Type your answer" - var styles textinput.Styles - styles.Focused.Text = repltheme.AskUserTypedStyle - styles.Focused.Placeholder = repltheme.AskUserCustomStyle - styles.Blurred = styles.Focused - styles.Cursor.Color = repltheme.TextDimColor - input.SetStyles(styles) - return input -} - -func (s *askUserState) begin(request *replaskuser.Request) { - *s = askUserState{requester: s.requester, request: request, input: newAskUserInput()} -} -func (s askUserState) active() bool { return s.request != nil } -func (s askUserState) visible() bool { return s.active() || s.completed } - -func cloneAskUserState(s askUserState) *askUserState { - cloned := s - cloned.requester = nil - cloned.answers = append([]string(nil), s.answers...) - cloned.resolved = append([]askUserAnswer(nil), s.resolved...) - return &cloned -} - -func (s *askUserState) clear() { - requester := s.requester - *s = askUserState{requester: requester} -} - -func (s *askUserState) syncInputFocus() { - if s.editing { - s.input.Focus() - } else { - s.input.Blur() - } -} - -func (s *askUserState) move(delta int) { - if !s.active() { - return - } - rows := len(s.request.Questionnaire.Questions[s.index].Options) + 1 - s.selected = (s.selected + delta + rows) % rows - s.editing = s.selected == len(s.request.Questionnaire.Questions[s.index].Options) - s.syncInputFocus() -} - -func (s *askUserState) answer(value string) bool { - s.answers = append(s.answers, value) - s.index++ - s.selected, s.editing = 0, false - s.input.SetValue("") - s.input.Blur() - return s.index == len(s.request.Questionnaire.Questions) -} - -func (s *askUserState) resolve(requester *replaskuser.Requester, cancelled bool) { - if !s.active() { - return - } - result := agentcore.AskUserResult{Answers: append([]string(nil), s.answers...), Cancelled: cancelled} - if requester != nil { - requester.Respond(s.request, result) - } - s.resolved = make([]askUserAnswer, len(s.answers)) - for i, answer := range s.answers { - s.resolved[i] = askUserAnswer{question: s.request.Questionnaire.Questions[i].Question, answer: answer} - } - s.request = nil - s.editing = false - s.cancelled = cancelled - s.completed = true -} - -func (m *replModel) clearAskUser() { - if m.stream.handler != nil { - m.stream.handler.SetAskUser(nil) - } - m.askUser.clear() -} - -func (m *replModel) appendResolvedAskUserSegment() { - if !m.askUser.completed || m.stream.handler == nil { - return - } - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) - m.clearAskUser() -} - -// askUserCard snapshots REPL ask-user state for the stream package without -// creating an import cycle. The snapshot is immutable; rendering closes over -// a clone taken at call time. -func askUserCard(s *askUserState) *replstream.AskUserCard { - if s == nil { - return nil - } - cloned := cloneAskUserState(*s) - if !cloned.visible() { - return nil - } - return &replstream.AskUserCard{ - Active: cloned.active(), - Render: func(width int) string { - return renderAskUserCard(*cloned, width) - }, - } -} -func renderAskUserCard(s askUserState, width int) string { - if !s.visible() { - return "" - } - contentWidth := max(width-askUserHorizontalMargin*2, 1) - var content strings.Builder - if s.active() { - renderActiveAskUserCard(&content, s, contentWidth) - } else { - renderResolvedAskUserCard(&content, s, contentWidth) - } - body := strings.TrimRight(content.String(), "\n") - margin := strings.Repeat(" ", askUserHorizontalMargin) - body = margin + strings.ReplaceAll(body, "\n", "\n"+margin) - rule := repltheme.AskUserRuleStyle.Render(strings.Repeat("─", max(width, 1))) - return "\n" + rule + "\n" + body + "\n" + rule + "\n" -} - -func renderActiveAskUserCard(content *strings.Builder, s askUserState, width int) { - question := s.request.Questionnaire.Questions[s.index] - for i, answer := range s.answers { - content.WriteString(repltheme.AskUserResolvedStyle.Render("• " + s.request.Questionnaire.Questions[i].Question + ": " + answer)) - content.WriteString("\n") - } - if len(s.answers) > 0 { - content.WriteString("\n") - } - content.WriteString(repltheme.AskUserProgressStyle.Render("Question " + strconv.Itoa(s.index+1) + " of " + strconv.Itoa(len(s.request.Questionnaire.Questions)))) - content.WriteString("\n") - content.WriteString(wrapAskUserText(question.Question, repltheme.AskUserQuestionStyle, width, "", "")) - content.WriteString("\n\n") - for i, option := range question.Options { - badge := "" - if i == 0 { - badge = " " + repltheme.AskUserBadgeStyle.Render("(recommended)") - } - content.WriteString(renderAskUserOption(i == s.selected, option+badge, repltheme.NormalStyle, true, width)) - } - customRow := "Type your answer" - customStyle := repltheme.AskUserCustomStyle - if s.editing || s.input.Value() != "" { - input := s.input - input.SetWidth(max(width-askUserInputPrefixWidth, 1)) - customRow = input.View() - customStyle = lipgloss.NewStyle() - } - content.WriteString(renderAskUserOption(s.selected == len(question.Options), customRow, customStyle, false, width)) - content.WriteString("\n") - hint := "↑/↓ navigate · Enter select · Esc cancel" - if s.selected == len(question.Options) && s.editing { - hint = "Enter submit · Esc cancel" - } - content.WriteString(repltheme.AskUserHintStyle.Render(hint)) - content.WriteString("\n") -} - -func renderResolvedAskUserCard(content *strings.Builder, s askUserState, width int) { - header := "Answers provided" - if s.cancelled { - header = "↩ Questions cancelled" - } - content.WriteString(repltheme.AskUserProgressStyle.Render(header)) - if len(s.resolved) == 0 { - content.WriteString("\n") - return - } - content.WriteString("\n\n") - for _, answer := range s.resolved { - content.WriteString(wrapAskUserText(answer.question+": "+answer.answer, repltheme.AskUserResolvedStyle, width, "• ", " ")) - content.WriteString("\n") - } -} - -func renderAskUserOption(selected bool, text string, style lipgloss.Style, highlightSelected bool, width int) string { - cursor := " " - if selected { - cursor = repltheme.AskUserSelectedStyle.Render("› ") - } - bulletStyle := style - if selected && highlightSelected { - style = repltheme.AskUserSelectedStyle - bulletStyle = repltheme.AskUserSelectedStyle - } - prefix := cursor + bulletStyle.Render("• ") - return wrapAskUserText(text, style, width, prefix, " ") + "\n" -} - -func wrapAskUserText(text string, style lipgloss.Style, width int, prefix, continuation string) string { - available := max(width-lipgloss.Width(prefix), 1) - wrapped := lipgloss.NewStyle().Width(available).Render(style.Render(text)) - lines := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") - for i, line := range lines { - if i == 0 { - lines[i] = prefix + line - } else { - lines[i] = continuation + line - } - } - return strings.Join(lines, "\n") -} diff --git a/internal/cli/repl/stream_ask_user_test.go b/internal/cli/repl/stream_ask_user_test.go index 9ba1420..9b317e5 100644 --- a/internal/cli/repl/stream_ask_user_test.go +++ b/internal/cli/repl/stream_ask_user_test.go @@ -12,10 +12,10 @@ import ( repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme" ) -func testAskUserState() askUserState { - return askUserState{ - input: newAskUserInput(), - request: &replaskuser.Request{Questionnaire: agentcore.AskUserRequest{Questions: []agentcore.AskUserQuestion{ +func testAskUserState() replaskuser.State { + return replaskuser.State{ + Input: replaskuser.NewInput(), + Request: &replaskuser.Request{Questionnaire: agentcore.AskUserRequest{Questions: []agentcore.AskUserQuestion{ {Question: "Choose one", Options: []string{"Recommended", "Alternative"}}, {Question: "Choose two", Options: []string{"First", "Second"}}, }}}, @@ -23,7 +23,7 @@ func testAskUserState() askUserState { } func TestRenderAskUserCardIncludesGuidanceAndRecommendation(t *testing.T) { - rendered := renderAskUserCard(testAskUserState(), 80) + rendered := testAskUserState().Render(80) plain := ansi.Strip(rendered) for _, text := range []string{"Question 1 of 2", "› • Recommended", "(recommended)", " • Type your answer", "↑/↓ navigate · Enter select · Esc cancel"} { if !strings.Contains(plain, text) { @@ -45,7 +45,7 @@ func TestRenderAskUserCardIncludesGuidanceAndRecommendation(t *testing.T) { lines := strings.Split(plain, "\n") for _, line := range lines { if strings.Contains(line, "Question 1 of 2") || strings.Contains(line, "Choose one") || strings.Contains(line, "• Alternative") { - if !strings.HasPrefix(line, strings.Repeat(" ", askUserHorizontalMargin)) { + if !strings.HasPrefix(line, strings.Repeat(" ", replaskuser.HorizontalMargin)) { t.Fatalf("questionnaire content should align with rule: %q", line) } } @@ -63,10 +63,10 @@ func TestAskUserCustomOptionStartsEditingWhenSelected(t *testing.T) { m.askUser = testAskUserState() m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyUp}) - if !m.askUser.editing { + if !m.askUser.Editing { t.Fatal("selecting the custom option should start editing") } - rendered := renderAskUserCard(m.askUser, 80) + rendered := m.askUser.Render(80) plain := ansi.Strip(rendered) if !strings.Contains(plain, "› • Type your answer") { t.Fatalf("selected custom option should show a text cursor: %q", plain) @@ -80,16 +80,16 @@ func TestAskUserCustomOptionStartsEditingWhenSelected(t *testing.T) { } m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: 'x', Text: "x"}) - if m.askUser.input.Value() != "x" || !m.askUser.editing { + if m.askUser.Input.Value() != "x" || !m.askUser.Editing { t.Fatalf("selected custom option should accept typing: %#v", m.askUser) } } func TestAskUserSelectionKeepsOptionBulletColumnFixed(t *testing.T) { state := testAskUserState() - unselected := ansi.Strip(renderAskUserCard(state, 80)) - state.selected = 1 - selected := ansi.Strip(renderAskUserCard(state, 80)) + unselected := ansi.Strip(state.Render(80)) + state.Selected = 1 + selected := ansi.Strip(state.Render(80)) if !strings.Contains(unselected, "› • Recommended") || !strings.Contains(unselected, " • Alternative") { t.Fatalf("unexpected initial cursor layout: %q", unselected) @@ -105,13 +105,13 @@ func TestAskUserTypingReplacesCustomLabelAndBackspaceEditsDraft(t *testing.T) { m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: 'h', Text: "h"}) m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: 'i', Text: "i"}) - if m.askUser.input.Value() != "hi" || !m.askUser.editing { - t.Fatalf("draft = %q, editing = %t", m.askUser.input.Value(), m.askUser.editing) + if m.askUser.Input.Value() != "hi" || !m.askUser.Editing { + t.Fatalf("draft = %q, editing = %t", m.askUser.Input.Value(), m.askUser.Editing) } - if got := ansi.Strip(renderAskUserCard(m.askUser, 80)); strings.Contains(got, "Type your answer") || !strings.Contains(got, "› • hi ") || !strings.Contains(got, "Enter submit · Esc cancel") { + if got := ansi.Strip(m.askUser.Render(80)); strings.Contains(got, "Type your answer") || !strings.Contains(got, "› • hi ") || !strings.Contains(got, "Enter submit · Esc cancel") { t.Fatalf("custom answer should replace label: %q", got) } - rendered := renderAskUserCard(m.askUser, 80) + rendered := m.askUser.Render(80) if !strings.Contains(rendered, styleColorPrefix(repltheme.AskUserTypedStyle)) { t.Fatal("typed custom answer should use the secondary color") } @@ -121,8 +121,8 @@ func TestAskUserTypingReplacesCustomLabelAndBackspaceEditsDraft(t *testing.T) { } m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyBackspace}) - if m.askUser.input.Value() != "h" { - t.Fatalf("draft after backspace = %q, want h", m.askUser.input.Value()) + if m.askUser.Input.Value() != "h" { + t.Fatalf("draft after backspace = %q, want h", m.askUser.Input.Value()) } } @@ -131,16 +131,16 @@ func TestAskUserUpDownNavigateOptions(t *testing.T) { m.askUser = testAskUserState() m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyDown}) - if m.askUser.selected != 1 { - t.Fatalf("selected after down = %d, want 1", m.askUser.selected) + if m.askUser.Selected != 1 { + t.Fatalf("selected after down = %d, want 1", m.askUser.Selected) } m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyDown}) - if m.askUser.selected != 2 { - t.Fatalf("selected after second down = %d, want custom row", m.askUser.selected) + if m.askUser.Selected != 2 { + t.Fatalf("selected after second down = %d, want custom row", m.askUser.Selected) } m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyUp}) - if m.askUser.selected != 1 { - t.Fatalf("selected after up = %d, want 1", m.askUser.selected) + if m.askUser.Selected != 1 { + t.Fatalf("selected after up = %d, want 1", m.askUser.Selected) } } @@ -152,13 +152,13 @@ func TestAskUserCustomAnswerSupportsArrowKeyEditing(t *testing.T) { m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: 'b', Text: "b"}) m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyLeft}) m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: 'c', Text: "c"}) - if got := m.askUser.input.Value(); got != "acb" { + if got := m.askUser.Input.Value(); got != "acb" { t.Fatalf("arrow keys should move the input cursor, got %q, want acb", got) } m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyRight}) m, _ = m.handleAskUserKeyMsg(tea.KeyPressMsg{Code: tea.KeyBackspace}) - if got := m.askUser.input.Value(); got != "ac" { + if got := m.askUser.Input.Value(); got != "ac" { t.Fatalf("backspace should delete before the cursor, got %q, want ac", got) } } @@ -168,10 +168,10 @@ func TestAskUserPasteInsertsIntoCustomAnswer(t *testing.T) { m.askUser = testAskUserState() m, _ = m.updateNormalMode(tea.PasteMsg{Content: "pasted answer"}) - if got := m.askUser.input.Value(); got != "pasted answer" { + if got := m.askUser.Input.Value(); got != "pasted answer" { t.Fatalf("paste should fill the custom answer, got %q", got) } - if !m.askUser.editing { + if !m.askUser.Editing { t.Fatal("paste should switch to editing the custom answer") } if m.textarea.Value() != "" { @@ -181,15 +181,15 @@ func TestAskUserPasteInsertsIntoCustomAnswer(t *testing.T) { func TestAskUserResolvedSummaryKeepsAnswers(t *testing.T) { state := testAskUserState() - state.answers = []string{"Recommended", "Second"} - state.resolve(nil, false) - plain := ansi.Strip(renderAskUserCard(state, 80)) + state.Answers = []string{"Recommended", "Second"} + state.Resolve(nil, false) + plain := ansi.Strip(state.Render(80)) for _, text := range []string{"Answers provided", "• Choose one: Recommended", "• Choose two: Second"} { if !strings.Contains(plain, text) { t.Fatalf("summary missing %q: %q", text, plain) } } - rendered := renderAskUserCard(state, 80) + rendered := state.Render(80) if !strings.Contains(rendered, repltheme.AssistantStyle.Render("Choose one: Recommended")) { t.Fatal("resolved question and answer should use the assistant text color") } @@ -200,9 +200,9 @@ func TestAskUserResolvedSummaryIsOrderedInStream(t *testing.T) { m.stream.handler.Start(make(chan agentcore.StreamEvent), "Loading...") m.stream.handler.HandleChunk("Before") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) - m.askUser.answers = []string{"Recommended", "Second"} - m.askUser.resolve(nil, false) + m.stream.handler.SetAskUser(m.askUser.Card()) + m.askUser.Answers = []string{"Recommended", "Second"} + m.askUser.Resolve(nil, false) m.appendResolvedAskUserSegment() m.stream.handler.HandleChunk("After") @@ -220,9 +220,9 @@ func TestAskUserResolvedSummaryUsesViewportWidth(t *testing.T) { m.width = 80 m.stream.handler.Start(make(chan agentcore.StreamEvent), "Loading...") m.askUser = testAskUserState() - m.stream.handler.SetAskUser(askUserCard(&m.askUser)) - m.askUser.answers = []string{"Recommended", "Second"} - m.askUser.resolve(nil, false) + m.stream.handler.SetAskUser(m.askUser.Card()) + m.askUser.Answers = []string{"Recommended", "Second"} + m.askUser.Resolve(nil, false) m.appendResolvedAskUserSegment() expectedRule := strings.Repeat("─", m.width) if !strings.Contains(ansi.Strip(m.stream.handler.View(m.width)), expectedRule) { @@ -231,7 +231,7 @@ func TestAskUserResolvedSummaryUsesViewportWidth(t *testing.T) { plain := ansi.Strip(m.stream.handler.View(m.width)) for _, line := range strings.Split(plain, "\n") { if strings.Contains(line, "Answers provided") || strings.Contains(line, "Choose one: Recommended") { - if !strings.HasPrefix(line, strings.Repeat(" ", askUserHorizontalMargin)) { + if !strings.HasPrefix(line, strings.Repeat(" ", replaskuser.HorizontalMargin)) { t.Fatalf("summary content should retain its horizontal margin: %q", line) } }