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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions cmd/chat_compact_results_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"strings"
"testing"

"charm.land/bubbles/v2/viewport"
)

func TestCompactToolResultKeepsFullOutputExpandable(t *testing.T) {
raw := "first\nsecond\nhidden-a\nhidden-b\nlast"
got := stripANSI(collapseToolResult(raw, 0, nil, false))
if !strings.Contains(got, "first\nsecond") || strings.Contains(got, "hidden-a") || !strings.Contains(got, "Enter to expand") {
t.Fatalf("verbose or unusable preview: %q", got)
}
if expanded := collapseToolResult(raw, 0, map[int]bool{0: true}, false); expanded != raw {
t.Fatalf("expanded output lost content: %q", expanded)
}
failed := stripANSI(collapseToolResult(raw, 0, nil, true))
if !strings.Contains(failed, "last") {
t.Fatalf("failure preview hid the diagnostic tail: %q", failed)
}
}

func TestCollapsedToolResultHitTestingUsesVisibleRows(t *testing.T) {
m := &chatModel{width: 80, viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(1))}
m.messages = []displayMsg{
{role: "tool_result", content: strings.Repeat("first result\n", 50)},
{role: "tool_result", content: strings.Repeat("second result\n", 10)},
}
prefix := renderDisplayMessage(m.messages[0], 0, m.messages, 80, nil)
m.viewport.SetContent(renderMessagesRange(m.messages, 0, len(m.messages), 80, nil))
m.viewport.SetYOffset(strings.Count(prefix, "\n"))
if got := m.toolResultIndexAtViewportCenter(80); got != 1 {
t.Fatalf("Enter targets message %d, want visible second result", got)
}
}

func TestCodeCopyAfterCollapsedToolUsesVisibleRows(t *testing.T) {
m := &chatModel{width: 80, viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(1))}
m.messages = []displayMsg{
{role: "tool_result", content: strings.Repeat("long result\n", 50)},
{role: "assistant", content: "Example:\n\n```go\nfmt.Println(\"hello\")\n```"},
}
prefix := renderDisplayMessage(m.messages[0], 0, m.messages, 80, nil)
m.viewport.SetContent(renderMessagesRange(m.messages, 0, len(m.messages), 80, nil))
m.viewport.SetYOffset(strings.Count(prefix, "\n") + 2)
if code, ok := m.codeBlockAtViewportCenter(); !ok || !strings.Contains(code, "fmt.Println") {
t.Fatalf("code copy missed visible block: %q, %v", code, ok)
}
}
2 changes: 0 additions & 2 deletions cmd/chat_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,6 @@ type chatModel struct {
vpLastMsgLen int

// Cached expanded map for viewport position math (avoids repeated allocations).
cachedExpandedMap map[int]bool
cachedExpandedLen int

// Streaming-partial render cache: rendered output of the completed
// markdown blocks of m.partial (see renderStreamTail).
Expand Down
7 changes: 4 additions & 3 deletions cmd/chat_subcommand_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ func (l *lintSubcommand) Handle(m *chatModel, args []string, text string) (tea.M
if len(args) >= 1 {
cmdStr = strings.TrimSpace(strings.TrimPrefix(text, "/lint"))
}
result, isErr := runSlashShellCommand(m, cmdStr)
if isErr {
status := runSlashShellCommandResult(m, cmdStr)
result := status.Output
if status.IsError && !status.CommandFailed {
m.messages = append(m.messages, displayMsg{role: "error", content: result})
return m, nil
}
if result == "" {
if !status.IsError {
m.messages = append(m.messages, displayMsg{role: "system", content: "No lint issues."})
} else {
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Lint issues:\n%s", result)})
Expand Down
14 changes: 11 additions & 3 deletions cmd/chat_subcommand_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ package cmd
import (
"context"
"strings"

"github.com/GrayCodeAI/reflect/internal/engine"
)

func runSlashShellCommand(m *chatModel, command string) (string, bool) {
result := runSlashShellCommandResult(m, command)
return result.Output, result.IsError
}

func runSlashShellCommandResult(m *chatModel, command string) engine.ShellCommandResult {
if m == nil || m.session == nil {
return "Error: no active session", true
return engine.ShellCommandResult{Output: "Error: no active session", IsError: true}
}
out, isErr := m.session.RunUserShellCommand(context.Background(), command, 0)
return strings.TrimSpace(out), isErr
result := m.session.RunUserShellCommandResult(context.Background(), command, 0)
result.Output = strings.TrimSpace(result.Output)
return result
}

func shellCommandFailed(output string, isErr bool) bool {
Expand Down
46 changes: 46 additions & 0 deletions cmd/chat_subcommand_shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ func TestRunSubcommand_UsesBashToolForSafeCommand(t *testing.T) {
}
}

func TestRunSubcommand_ReportsFailedPipelineWithDiagnostics(t *testing.T) {
m := newTestChatModel()
m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO)
result, _ := (&runSubcommand{}).Handle(m, []string{"printf 'build diagnostic\\n'; false | tail -5"}, "/run printf 'build diagnostic\\n'; false | tail -5")
cm := requireChatModel(t, result)
last := cm.messages[len(cm.messages)-1]
if last.role != "error" || !strings.Contains(last.content, "build diagnostic") || !strings.Contains(last.content, "exit code:") {
t.Fatalf("failed check appeared successful or lost its diagnostic: role=%s output=%q", last.role, last.content)
}
}

func TestRunSubcommand_RespectsSpecStageGate(t *testing.T) {
m := newTestChatModel()
m.session.PermSvc().SetSpecStage(safety.SpecStageSpecify)
Expand Down Expand Up @@ -108,3 +119,38 @@ func TestTestSubcommand_DetectsNonzeroExit(t *testing.T) {
t.Fatalf("session messages = %#v, want failure context", msgs)
}
}

func TestLintSubcommandQueuesActualFailureOnly(t *testing.T) {
for _, tc := range []struct {
command string
failed bool
}{
{command: "printf 'compiler diagnostic'; false | tail -5", failed: true},
{command: "printf '0 issues.'"},
} {
t.Run(tc.command, func(t *testing.T) {
m := newTestChatModel()
m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO)
result, _ := (&lintSubcommand{}).Handle(m, []string{tc.command}, "/lint "+tc.command)
cm := requireChatModel(t, result)
messages := cm.session.RawMessages()
if tc.failed {
if len(messages) != 1 || !strings.Contains(messages[0].Content, "Please fix these lint issues") || !strings.Contains(messages[0].Content, "compiler diagnostic") {
t.Fatalf("lost repair diagnostics: %#v", messages)
}
} else if len(messages) != 0 || !strings.Contains(cm.messages[len(cm.messages)-1].content, "No lint issues") {
t.Fatalf("successful linter triggered unnecessary repairs: %#v", messages)
}
})
}
}

func TestTestSubcommandDoesNotTreatDenialAsTestFailure(t *testing.T) {
m := newTestChatModel()
m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO)
result, _ := (&testSubcommand{}).Handle(m, []string{"env"}, "/test env")
cm := requireChatModel(t, result)
if len(cm.session.RawMessages()) != 0 || cm.messages[len(cm.messages)-1].role != "error" {
t.Fatal("blocked command was queued as a code repair")
}
}
5 changes: 3 additions & 2 deletions cmd/chat_subcommand_test_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ func (t *testSubcommand) Handle(m *chatModel, args []string, text string) (tea.M
if len(args) >= 1 {
cmdStr = strings.TrimSpace(strings.TrimPrefix(text, "/test"))
}
result, isErr := runSlashShellCommand(m, cmdStr)
if isErr {
status := runSlashShellCommandResult(m, cmdStr)
result, isErr := status.Output, status.IsError
if isErr && !status.CommandFailed {
m.messages = append(m.messages, displayMsg{role: "error", content: result})
return m, nil
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_update_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (m chatModel) handleToolResult(msg toolResultMsg) (tea.Model, tea.Cmd) {
}
// The preceding tool_use message renders the tool name as this block's
// header, so the result contains only the returned content.
m.messages = append(m.messages, displayMsg{role: "tool_result", content: msg.content})
m.messages = append(m.messages, displayMsg{role: "tool_result", content: msg.content, toolState: msg.state, toolID: msg.id, toolName: msg.name})
m.viewDirty = true
// Persist completed tool results incrementally so a crash mid-turn does
// not lose them before the final session save.
Expand Down
45 changes: 19 additions & 26 deletions cmd/chat_viewport_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strings"

"github.com/GrayCodeAI/reflect/internal/engine"
"github.com/GrayCodeAI/reflect/internal/ui/icons"
)

Expand All @@ -25,8 +26,8 @@ func (m *chatModel) invalidateViewportCache() {
// toolResultCollapseLines are collapsed to toolResultPreviewLines with a
// toggle indicator; the user presses Enter in scrollback focus to expand.
const (
toolResultCollapseLines = 15 // collapse rendered output longer than this
toolResultPreviewLines = 8 // preview lines shown when collapsed
toolResultCollapseLines = 3 // collapse rendered output longer than this
toolResultPreviewLines = 2 // preview lines shown when collapsed
)

func renderDisplayMessage(msg displayMsg, i int, messages []displayMsg, viewWidth int, expanded map[int]bool) string {
Expand Down Expand Up @@ -122,7 +123,8 @@ func renderDisplayMessage(msg displayMsg, i int, messages []displayMsg, viewWidt
toolWrapped := wrapText(display, viewWidth-6, 0)
inner.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n ")))
}
b.WriteString(collapseToolResult(inner.String(), i, expanded))
failed := msg.toolState == engine.ToolStateFailed || msg.toolState == engine.ToolStateTimedOut
b.WriteString(collapseToolResult(inner.String(), i, expanded, failed))
case "thinking":
thinkWrapped := wrapText(msg.content, viewWidth-4, 3)
b.WriteString(dimStyle.Render(icons.Brain() + " " + thinkWrapped))
Expand Down Expand Up @@ -189,18 +191,26 @@ func renderDisplayMessage(msg displayMsg, i int, messages []displayMsg, viewWidt
// [+N lines] toggle indicator when the result is not expanded. Short results
// (≤ toolResultCollapseLines) pass through unchanged. The expanded map is keyed
// by message index.
func collapseToolResult(rendered string, msgIdx int, expanded map[int]bool) string {
func collapseToolResult(rendered string, msgIdx int, expanded map[int]bool, failed bool) string {
if expanded[msgIdx] {
return rendered
}
// Count visible lines. ANSI styling codes don't contain '\n', so a simple
// split accurately reflects the rendered line count.
lines := strings.Split(rendered, "\n")
if len(lines) <= toolResultCollapseLines {
threshold := toolResultCollapseLines
if failed {
threshold = 2 * toolResultPreviewLines
}
if len(lines) <= threshold {
return rendered
}
preview := strings.Join(lines[:toolResultPreviewLines], "\n")
hidden := len(lines) - toolResultPreviewLines
if failed {
preview += "\n…\n" + strings.Join(lines[len(lines)-toolResultPreviewLines:], "\n")
hidden -= toolResultPreviewLines
}
toggle := fmt.Sprintf("[+%d lines — Enter to expand]", hidden)
return preview + "\n" + toolDimStyle.Render(" "+toggle)
}
Expand Down Expand Up @@ -310,23 +320,22 @@ func trailingNewlines(s string) string {

// toolResultIndexAtViewportCenter returns the index of the tool_result message
// whose rendered content contains the viewport's center line, or -1 if none.
// It fully renders each tool_result (ignoring collapse state) to compute true
// line heights, then finds which message spans the center Y offset.
// Use the visible collapse state so shortcuts target what is on screen.
func (m *chatModel) toolResultIndexAtViewportCenter(viewWidth int) int {
if m.viewport.Height() <= 0 {
return -1
}
centerY := m.viewport.YOffset() + m.viewport.Height()/2
cumulative := 0
expanded := m.fullExpandedMap(len(m.messages))
expanded := m.toolResultExpanded
for i, msg := range m.messages {
if msg.role != "tool_result" {
// Still need to count lines for non-tool messages to track offset.
rendered := renderDisplayMessage(msg, i, m.messages, viewWidth, expanded)
cumulative += strings.Count(rendered, "\n")
continue
}
// Render fully (expanded) to get true line count.
// Count the rows currently displayed, including collapsed previews.
rendered := renderDisplayMessage(msg, i, m.messages, viewWidth, expanded)
lineCount := strings.Count(rendered, "\n")
if centerY >= cumulative && centerY < cumulative+lineCount {
Expand All @@ -337,22 +346,6 @@ func (m *chatModel) toolResultIndexAtViewportCenter(viewWidth int) int {
return -1
}

// fullExpandedMap returns a map where every index is expanded — used to compute
// true (uncollapsed) line heights for viewport position math.
// Uses a cached map to avoid repeated allocations.
func (m *chatModel) fullExpandedMap(n int) map[int]bool {
if m.cachedExpandedMap != nil && m.cachedExpandedLen == n {
return m.cachedExpandedMap
}
expanded := make(map[int]bool, n)
for i := 0; i < n; i++ {
expanded[i] = true
}
m.cachedExpandedMap = expanded
m.cachedExpandedLen = n
return expanded
}

// codeBlockAtViewportCenter finds the fenced code block in an assistant message
// whose rendered content contains the viewport's center line. Returns the raw
// code content (without fences) and true if found.
Expand Down Expand Up @@ -381,7 +374,7 @@ func (m *chatModel) codeBlockAtViewportCenter() (string, bool) {

// Slow path: render messages incrementally, stopping early when we pass center.
cumulative := 0
expanded := m.fullExpandedMap(len(m.messages))
expanded := m.toolResultExpanded
for i, msg := range m.messages {
rendered := renderDisplayMessage(msg, i, m.messages, viewWidth, expanded)
lineCount := strings.Count(rendered, "\n")
Expand Down
23 changes: 21 additions & 2 deletions internal/engine/stream_tool_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package engine

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strings"

Expand Down Expand Up @@ -195,6 +197,20 @@ func isNetworkReadOnlyTool(name string) bool {
// permission, approval, timeout, retry, truncation, hook,
// and post-processing path used by model-initiated Bash tool calls.
func (s *Session) RunUserShellCommand(ctx context.Context, command string, timeoutSeconds int) (string, bool) {
result := s.RunUserShellCommandResult(ctx, command, timeoutSeconds)
return result.Output, result.IsError
}

// ShellCommandResult distinguishes failed commands from denied tool calls so
// test/lint commands can offer repair feedback without treating denials as code defects.
type ShellCommandResult struct {
Output string
IsError bool
CommandFailed bool
}

// RunUserShellCommandResult includes the process failure status alongside output.
func (s *Session) RunUserShellCommandResult(ctx context.Context, command string, timeoutSeconds int) ShellCommandResult {
if ctx == nil {
ctx = context.Background()
}
Expand All @@ -208,7 +224,8 @@ func (s *Session) RunUserShellCommand(ctx context.Context, command string, timeo
Name: "Bash",
Arguments: args,
}, tool.BashTool{}, ch, 0, command)
return result.output, result.isErr
var exitErr *exec.ExitError
return ShellCommandResult{Output: result.output, IsError: result.isErr, CommandFailed: errors.As(result.err, &exitErr)}
}

// executeSingleTool runs one tool call with permission checks and all post-processing.
Expand Down Expand Up @@ -239,7 +256,9 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa
output, execErr, isErr := core.output, core.err, core.isErr
if isErr {
s.Logger().Warn("tool execution error", map[string]interface{}{"tool": tc.Name, "error": execErr.Error()})
output = fmt.Sprintf("Error: %s", execErr.Error())
if strings.TrimSpace(output) == "" {
output = fmt.Sprintf("Error: %s", execErr.Error())
}
if s.LifecycleSvc().Backtrack() != nil {
s.LifecycleSvc().Backtrack().MarkOutcome(turnCount, "failure")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/prompts/templates/communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
- State what you did, not what you're about to do.
- Don't narrate your thought process unless asked.
- Don't apologize or hedge.
- Show code/diffs rather than describing changes.
- Summarize the outcome, changed files, and verification in a few sentences. Show full code, diffs, or command output only when requested.
- Keep progress updates brief and report new findings; do not repeat that a check is still running.
- If you made a mistake, fix it directly.
4 changes: 3 additions & 1 deletion internal/prompts/templates/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ When exploring a codebase:
When making changes:
- Read the file first to understand context
- Make minimal, focused edits
- Verify changes compile/run if possible
- Prefer Edit/Write for source changes. Replace complete code blocks rather than deleting matching lines with shell substitutions.
- Inspect the final diff, format modified source, and run the relevant build or tests before claiming a fix is complete. Repair syntax errors immediately.
- If a check fails, use its diagnostics to fix the cause. Do not repeat unchanged checks merely to count passing packages.

When using Bash:
- Prefer non-destructive commands
Expand Down
Loading
Loading