From 74c6828f50b47acf924f5d27a218d8e585d7669c Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 03:34:24 +0900 Subject: [PATCH] feat: fixed-height terminal display with sticky error and filename priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout is now constant height regardless of entry count or error state: - 3 compact past entries (padded if fewer) - Latest entry: up to ~70 chars command + result on next line + 2 reserved - Error section: always present as code block (sticky β€” persists until a newer error replaces it) - File tool paths now prioritize filename: "dir…/backend.py" instead of "projects/terra-py-fo..." Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 164 ++++++++++++++++++++++++++++++----------- pkg/agent/loop_test.go | 161 ++++++++++++++++++++-------------------- 2 files changed, 201 insertions(+), 124 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f725d7222..1357e729d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -42,6 +42,7 @@ type activeTask struct { cancel context.CancelFunc interrupt chan string // buffered 1, for user message injection toolLog []toolLogEntry + lastError *toolLogEntry // sticky: most recent error, persists across iterations mu sync.Mutex } @@ -956,13 +957,27 @@ func buildArgsSnippet(toolName string, args map[string]interface{}, workspace st path = strings.TrimPrefix(path, workspace) path = strings.TrimPrefix(path, "/") } - extra := "" - if toolName == "edit_file" { - if old, ok := args["old_text"].(string); ok && old != "" { - extra = " old:" + utils.Truncate(old, 30) + // Prioritize filename: if path is too long, show "…/filename" + const maxPath = 60 + if runes := []rune(path); len(runes) > maxPath { + // Find last slash to extract filename + if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 { + filename := path[lastSlash:] // includes "/" + dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…" + if dirBudget > 0 { + dir := []rune(path[:lastSlash]) + if len(dir) > dirBudget { + dir = dir[:dirBudget] + } + path = string(dir) + "\u2026" + filename + } else { + path = "\u2026" + filename + } + } else { + path = utils.Truncate(path, maxPath) } } - return utils.Truncate(path, 60) + extra + return path } // Default: raw JSON truncated @@ -1003,16 +1018,40 @@ func formatCompactEntry(entry toolLogEntry) string { return prefix + " " + result } -// buildRichStatus builds a terminal-like status display from the active task's tool log. -// Layout is designed for fixed height: past entries are always 1 line, -// and the latest entry has reserved space for potential error detail. +// Display layout constants. +const ( + displayPastEntries = 3 // number of compact 1-line past entries + displayErrorLines = 3 // content lines inside the error code block + maxLatestWidth = 70 // rune limit for the latest entry command (~2 Telegram lines) +) + +// buildRichStatus builds a fixed-height terminal-like status display. +// +// Layout (always the same number of lines): +// +// πŸ”„ Task in progress (N/M) header +// πŸ“‚ project-name header +// ━━━━━━━━━━ separator +// compact-past-1 past (1 line each) +// compact-past-2 past +// compact-past-3 past +// latest-command… latest line 1 (command, up to ~70 chars) +// ⏳ latest line 2 (result) +// latest line 3 (reserved) +// latest line 4 (reserved) +// ━━━━━━━━━━ separator +// ``` error fence +// err-line / placeholder error body +// err-line / placeholder error body +// err-line / placeholder error body +// ``` error fence +// ↩️ Reply to intervene footer (background only) func buildRichStatus(task *activeTask, isBackground bool, workspace string) string { task.mu.Lock() defer task.mu.Unlock() var sb strings.Builder fmt.Fprintf(&sb, "\U0001F504 Task in progress (%d/%d)\n", task.Iteration, task.MaxIter) - // Show project directory name so user knows which workspace is active if workspace != "" { project := workspace if idx := strings.LastIndex(workspace, "/"); idx >= 0 { @@ -1026,50 +1065,90 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri } sb.WriteString("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n") - // Sliding window: show only the last maxToolLogEntries entries + // --- Task entries region (displayPastEntries + 4 lines) --- entries := task.toolLog if len(entries) > maxToolLogEntries { entries = entries[len(entries)-maxToolLogEntries:] } - for i, entry := range entries { - isLast := i == len(entries)-1 - isErr := strings.HasPrefix(entry.Result, "\u2717") - - if !isLast { - // Past entries: always exactly 1 compact line - sb.WriteString(formatCompactEntry(entry)) - sb.WriteString("\n") - } else { - // Latest entry: show command up to ~2 lines worth, then result on next line - const maxLatestWidth = 70 - prefix := entry.Name - if entry.ArgsSnip != "" { - prefix += " " + entry.ArgsSnip - } - if runes := []rune(prefix); len(runes) > maxLatestWidth { - prefix = string(runes[:maxLatestWidth-1]) + "\u2026" - } - fmt.Fprintf(&sb, "%s\n", prefix) - fmt.Fprintf(&sb, " %s\n", entry.Result) - - if isErr && entry.ErrDetail != "" { - sb.WriteString("```\n") - for _, line := range strings.Split(entry.ErrDetail, "\n") { - sb.WriteString(line) - sb.WriteString("\n") - } - sb.WriteString("```\n") + // Split into past entries and latest entry + var pastEntries []toolLogEntry + var latest *toolLogEntry + if len(entries) > 0 { + latest = &entries[len(entries)-1] + if len(entries) > 1 { + start := len(entries) - 1 - displayPastEntries + if start < 0 { + start = 0 } + pastEntries = entries[start : len(entries)-1] } } - // If no entries yet, still reserve the space - if len(entries) == 0 { - sb.WriteString("\u23F3 waiting...\n\n") + // Past entries: always exactly displayPastEntries lines (pad if fewer) + for i := 0; i < displayPastEntries; i++ { + if i < len(pastEntries) { + sb.WriteString(formatCompactEntry(pastEntries[i])) + } else { + sb.WriteString("\u2800") // braille blank β€” invisible but holds the line + } + sb.WriteString("\n") } + // Latest entry: always exactly 4 lines + if latest != nil { + // Line 1-2: command (truncated to ~2 Telegram lines) + prefix := latest.Name + if latest.ArgsSnip != "" { + prefix += " " + latest.ArgsSnip + } + if runes := []rune(prefix); len(runes) > maxLatestWidth { + prefix = string(runes[:maxLatestWidth-1]) + "\u2026" + } + fmt.Fprintf(&sb, "%s\n", prefix) + // Line 3: result + fmt.Fprintf(&sb, " %s\n", latest.Result) + } else { + sb.WriteString("\u23F3 waiting...\n") + sb.WriteString("\u2800\n") + } + // Lines 3-4: reserved (keep bubble height stable when result wraps or error appears) + sb.WriteString("\u2800\n") + sb.WriteString("\u2800\n") + + // --- Error region (separator + code fence with displayErrorLines) --- sb.WriteString("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n") + sb.WriteString("```\n") + + errEntry := task.lastError + if errEntry != nil { + // Header: tool name + result marker + header := formatCompactEntry(*errEntry) + sb.WriteString("\u274C " + header + "\n") + + // Detail lines + var detailLines []string + if errEntry.ErrDetail != "" { + detailLines = strings.Split(errEntry.ErrDetail, "\n") + } + for i := 0; i < displayErrorLines-1; i++ { + if i < len(detailLines) { + sb.WriteString(detailLines[i]) + } else { + sb.WriteString("\u2800") + } + sb.WriteString("\n") + } + } else { + // No error: placeholder lines + sb.WriteString("\u2714 No errors\n") + for i := 0; i < displayErrorLines-1; i++ { + sb.WriteString("\u2800\n") + } + } + + sb.WriteString("```\n") + if isBackground { sb.WriteString("\u21A9\uFE0F Reply to intervene") } @@ -1393,6 +1472,9 @@ func (al *AgentLoop) runLLMIteration( task.toolLog[logIdx].ErrDetail = utils.Truncate( strings.Join(lines[start:], "\n"), 200) } + // Sticky error: remember most recent error for persistent display + entry := task.toolLog[logIdx] + task.lastError = &entry } else { task.toolLog[logIdx].Result = fmt.Sprintf("\u2713 %.1fs", toolDuration.Seconds()) } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4712d851f..d8fe41a72 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1447,11 +1447,18 @@ func TestBuildArgsSnippet_ExecStripsCD(t *testing.T) { wantSnip: "src/main.go", }, { - name: "edit_file with old_text", + name: "edit_file shows path", tool: "edit_file", args: map[string]interface{}{"path": "/ws/config.json", "old_text": "old value here"}, workspace: "/ws", - wantSnip: "config.json old:old value here", + wantSnip: "config.json", + }, + { + name: "file tool long path prioritizes filename", + tool: "read_file", + args: map[string]interface{}{"path": "/ws/projects/terra-py-form/src/terra_py_form/hot/state/backend.py"}, + workspace: "/ws", + wantSnip: "projects/terra-py-form/src/terra_py_form/hot/sta\u2026/backend.py", }, { name: "unknown tool shows raw JSON", @@ -1529,8 +1536,8 @@ func TestBuildRichStatus(t *testing.T) { mustContain := []string{ "Task in progress (3/20)", "my-projects", - "exec", - "read_file", + "read_file", // latest entry + "No errors", // no error yet } for _, s := range mustContain { if !strings.Contains(got, s) { @@ -1550,29 +1557,76 @@ func TestBuildRichStatus(t *testing.T) { } } -func TestBuildRichStatus_ShowsLast5(t *testing.T) { +func TestBuildRichStatus_FixedHeight(t *testing.T) { + // Test that output has the same number of lines regardless of entry count + countLines := func(s string) int { + return strings.Count(s, "\n") + } + + // 0 entries + task0 := &activeTask{Iteration: 1, MaxIter: 10} + lines0 := countLines(buildRichStatus(task0, true, "/ws/p")) + + // 1 entry + task1 := &activeTask{Iteration: 1, MaxIter: 10, + toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}}} + lines1 := countLines(buildRichStatus(task1, true, "/ws/p")) + + // 5 entries + task5 := &activeTask{Iteration: 5, MaxIter: 10} + for i := 0; i < 5; i++ { + task5.toolLog = append(task5.toolLog, toolLogEntry{ + Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "βœ“ 1.0s"}) + } + lines5 := countLines(buildRichStatus(task5, true, "/ws/p")) + + // 5 entries + sticky error + task5err := &activeTask{Iteration: 5, MaxIter: 10} + for i := 0; i < 5; i++ { + task5err.toolLog = append(task5err.toolLog, toolLogEntry{ + Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "βœ“ 1.0s"}) + } + errEntry := toolLogEntry{Name: "[3] exec", ArgsSnip: "pytest", Result: "βœ— 2.0s", + ErrDetail: "FAILED test\nExit code: 1"} + task5err.lastError = &errEntry + lines5err := countLines(buildRichStatus(task5err, true, "/ws/p")) + + if lines0 != lines1 || lines1 != lines5 || lines5 != lines5err { + t.Errorf("line counts should be equal: 0=%d, 1=%d, 5=%d, 5+err=%d", + lines0, lines1, lines5, lines5err) + } +} + +func TestBuildRichStatus_StickyError(t *testing.T) { + // Error from a past entry sticks in the error section + errEntry := toolLogEntry{ + Name: "[2] exec", ArgsSnip: "pytest", Result: "βœ— 3.2s", + ErrDetail: "FAILED test_login\nExit code: 1", + } task := &activeTask{ - Iteration: 8, - MaxIter: 20, - } - for i := 1; i <= 8; i++ { - task.toolLog = append(task.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] exec", i), - Result: "βœ“ 1.0s", - }) + Iteration: 5, + MaxIter: 10, + toolLog: []toolLogEntry{ + {Name: "[3] read_file", ArgsSnip: "src/auth.py", Result: "βœ“ 0.1s"}, + {Name: "[4] edit_file", ArgsSnip: "src/auth.py", Result: "βœ“ 0.2s"}, + {Name: "[5] exec", ArgsSnip: "pytest --retry", Result: "⏳"}, + }, + lastError: &errEntry, } - got := buildRichStatus(task, false, "/home/user/my-projects") + got := buildRichStatus(task, false, "/ws/p") - // Should contain entries 4-8 but not 1-3 - if strings.Contains(got, "[3] exec") { - t.Error("should not contain entry [3] (only last 5)") + // Error section should show the sticky error in code block + if !strings.Contains(got, "FAILED test_login") { + t.Errorf("expected sticky error detail in error section, got:\n%s", got) } - if !strings.Contains(got, "[4] exec") { - t.Error("should contain entry [4]") + if !strings.Contains(got, "\u274C") { // ❌ + t.Errorf("expected ❌ error header, got:\n%s", got) } - if !strings.Contains(got, "[8] exec") { - t.Error("should contain entry [8]") + + // Latest entry is NOT the error + if !strings.Contains(got, "pytest --retry") { + t.Errorf("expected latest entry command, got:\n%s", got) } } @@ -1589,71 +1643,12 @@ func TestBuildRichStatus_LastEntryShowsMoreCommand(t *testing.T) { got := buildRichStatus(task, false, "/ws/my-project") - // Last entry: shows more than compact (36) but truncated at ~70 - // "exec " + longCmd = 79 chars β†’ should be truncated - if strings.Contains(got, "--timeout=60") { - t.Errorf("last entry should be truncated at ~70 chars, got:\n%s", got) - } - // But shows more than the compact 36 + // Shows more than compact 36 chars if !strings.Contains(got, "test_state_backend") { - t.Errorf("last entry should show more than compact format, got:\n%s", got) + t.Errorf("latest entry should show more than compact format, got:\n%s", got) } // Result on separate indented line if !strings.Contains(got, " ⏳") { - t.Errorf("last entry result should be on indented line, got:\n%s", got) - } -} - -func TestBuildRichStatus_ErrorAsLastEntry(t *testing.T) { - task := &activeTask{ - Iteration: 2, - MaxIter: 10, - toolLog: []toolLogEntry{ - {Name: "exec", ArgsSnip: "ls -la", Result: "βœ“ 0.5s"}, - { - Name: "exec", - ArgsSnip: "pytest tests/test_auth.py", - Result: "βœ— 3.2s", - ErrDetail: "FAILED test_login\nExit code: 1", - }, - }, - } - - got := buildRichStatus(task, false, "/ws/my-project") - - // Last entry: full command + error with code fence detail - if !strings.Contains(got, "pytest tests/test_auth.py") { - t.Errorf("expected full command in last entry, got:\n%s", got) - } - if !strings.Contains(got, "βœ— 3.2s") { - t.Errorf("expected error marker, got:\n%s", got) - } - if !strings.Contains(got, "```\nFAILED test_login\n") { - t.Errorf("expected code-fenced error detail, got:\n%s", got) - } -} - -func TestBuildRichStatus_ErrorNotLastEntry(t *testing.T) { - // When error is a past entry (not the last), it should be 1 compact line - task := &activeTask{ - Iteration: 3, - MaxIter: 10, - toolLog: []toolLogEntry{ - {Name: "exec", ArgsSnip: "pytest", Result: "βœ— 3.2s", - ErrDetail: "FAILED\nExit code: 1"}, - {Name: "read_file", ArgsSnip: "src/auth.py", Result: "βœ“ 0.1s"}, - {Name: "exec", ArgsSnip: "pytest", Result: "⏳"}, - }, - } - - got := buildRichStatus(task, false, "/ws/p") - - // Past error entry should NOT have code fence - if strings.Contains(got, "```") && strings.Contains(got, "FAILED") { - t.Errorf("past error entry should not have code fence detail, got:\n%s", got) - } - // But the error marker should still be visible - if !strings.Contains(got, "βœ— 3.2s") { - t.Errorf("expected error marker in past entry, got:\n%s", got) + t.Errorf("latest entry result should be on indented line, got:\n%s", got) } }