feat: fixed-height terminal display with sticky error and filename priority

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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 03:34:24 +09:00
parent 91b8df5844
commit 0b6ed5fcd1
2 changed files with 201 additions and 124 deletions

View file

@ -42,6 +42,7 @@ type activeTask struct {
cancel context.CancelFunc cancel context.CancelFunc
interrupt chan string // buffered 1, for user message injection interrupt chan string // buffered 1, for user message injection
toolLog []toolLogEntry toolLog []toolLogEntry
lastError *toolLogEntry // sticky: most recent error, persists across iterations
mu sync.Mutex 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, workspace)
path = strings.TrimPrefix(path, "/") path = strings.TrimPrefix(path, "/")
} }
extra := "" // Prioritize filename: if path is too long, show "…/filename"
if toolName == "edit_file" { const maxPath = 60
if old, ok := args["old_text"].(string); ok && old != "" { if runes := []rune(path); len(runes) > maxPath {
extra = " old:" + utils.Truncate(old, 30) // 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 // Default: raw JSON truncated
@ -1003,16 +1018,40 @@ func formatCompactEntry(entry toolLogEntry) string {
return prefix + " " + result return prefix + " " + result
} }
// buildRichStatus builds a terminal-like status display from the active task's tool log. // Display layout constants.
// Layout is designed for fixed height: past entries are always 1 line, const (
// and the latest entry has reserved space for potential error detail. 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 { func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
task.mu.Lock() task.mu.Lock()
defer task.mu.Unlock() defer task.mu.Unlock()
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "\U0001F504 Task in progress (%d/%d)\n", task.Iteration, task.MaxIter) 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 != "" { if workspace != "" {
project := workspace project := workspace
if idx := strings.LastIndex(workspace, "/"); idx >= 0 { 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") 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 entries := task.toolLog
if len(entries) > maxToolLogEntries { if len(entries) > maxToolLogEntries {
entries = entries[len(entries)-maxToolLogEntries:] entries = entries[len(entries)-maxToolLogEntries:]
} }
for i, entry := range entries { // Split into past entries and latest entry
isLast := i == len(entries)-1 var pastEntries []toolLogEntry
isErr := strings.HasPrefix(entry.Result, "\u2717") 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 !isLast { // Past entries: always exactly displayPastEntries lines (pad if fewer)
// Past entries: always exactly 1 compact line for i := 0; i < displayPastEntries; i++ {
sb.WriteString(formatCompactEntry(entry)) if i < len(pastEntries) {
sb.WriteString("\n") sb.WriteString(formatCompactEntry(pastEntries[i]))
} else { } else {
// Latest entry: show command up to ~2 lines worth, then result on next line sb.WriteString("\u2800") // braille blank — invisible but holds the line
const maxLatestWidth = 70 }
prefix := entry.Name sb.WriteString("\n")
if entry.ArgsSnip != "" { }
prefix += " " + entry.ArgsSnip
// 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 { if runes := []rune(prefix); len(runes) > maxLatestWidth {
prefix = string(runes[:maxLatestWidth-1]) + "\u2026" prefix = string(runes[:maxLatestWidth-1]) + "\u2026"
} }
fmt.Fprintf(&sb, "%s\n", prefix) fmt.Fprintf(&sb, "%s\n", prefix)
fmt.Fprintf(&sb, " %s\n", entry.Result) // 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")
if isErr && entry.ErrDetail != "" { // --- Error region (separator + code fence with displayErrorLines) ---
sb.WriteString("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n")
sb.WriteString("```\n") sb.WriteString("```\n")
for _, line := range strings.Split(entry.ErrDetail, "\n") {
sb.WriteString(line) 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") 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") sb.WriteString("```\n")
}
}
}
// If no entries yet, still reserve the space
if len(entries) == 0 {
sb.WriteString("\u23F3 waiting...\n\n")
}
sb.WriteString("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n")
if isBackground { if isBackground {
sb.WriteString("\u21A9\uFE0F Reply to intervene") sb.WriteString("\u21A9\uFE0F Reply to intervene")
} }
@ -1393,6 +1472,9 @@ func (al *AgentLoop) runLLMIteration(
task.toolLog[logIdx].ErrDetail = utils.Truncate( task.toolLog[logIdx].ErrDetail = utils.Truncate(
strings.Join(lines[start:], "\n"), 200) strings.Join(lines[start:], "\n"), 200)
} }
// Sticky error: remember most recent error for persistent display
entry := task.toolLog[logIdx]
task.lastError = &entry
} else { } else {
task.toolLog[logIdx].Result = fmt.Sprintf("\u2713 %.1fs", toolDuration.Seconds()) task.toolLog[logIdx].Result = fmt.Sprintf("\u2713 %.1fs", toolDuration.Seconds())
} }

View file

@ -1447,11 +1447,18 @@ func TestBuildArgsSnippet_ExecStripsCD(t *testing.T) {
wantSnip: "src/main.go", wantSnip: "src/main.go",
}, },
{ {
name: "edit_file with old_text", name: "edit_file shows path",
tool: "edit_file", tool: "edit_file",
args: map[string]interface{}{"path": "/ws/config.json", "old_text": "old value here"}, args: map[string]interface{}{"path": "/ws/config.json", "old_text": "old value here"},
workspace: "/ws", 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", name: "unknown tool shows raw JSON",
@ -1529,8 +1536,8 @@ func TestBuildRichStatus(t *testing.T) {
mustContain := []string{ mustContain := []string{
"Task in progress (3/20)", "Task in progress (3/20)",
"my-projects", "my-projects",
"exec", "read_file", // latest entry
"read_file", "No errors", // no error yet
} }
for _, s := range mustContain { for _, s := range mustContain {
if !strings.Contains(got, s) { 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{ task := &activeTask{
Iteration: 8, Iteration: 5,
MaxIter: 20, MaxIter: 10,
} toolLog: []toolLogEntry{
for i := 1; i <= 8; i++ { {Name: "[3] read_file", ArgsSnip: "src/auth.py", Result: "✓ 0.1s"},
task.toolLog = append(task.toolLog, toolLogEntry{ {Name: "[4] edit_file", ArgsSnip: "src/auth.py", Result: "✓ 0.2s"},
Name: fmt.Sprintf("[%d] exec", i), {Name: "[5] exec", ArgsSnip: "pytest --retry", Result: "⏳"},
Result: "✓ 1.0s", },
}) 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 // Error section should show the sticky error in code block
if strings.Contains(got, "[3] exec") { if !strings.Contains(got, "FAILED test_login") {
t.Error("should not contain entry [3] (only last 5)") t.Errorf("expected sticky error detail in error section, got:\n%s", got)
} }
if !strings.Contains(got, "[4] exec") { if !strings.Contains(got, "\u274C") { // ❌
t.Error("should contain entry [4]") 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") got := buildRichStatus(task, false, "/ws/my-project")
// Last entry: shows more than compact (36) but truncated at ~70 // Shows more than compact 36 chars
// "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
if !strings.Contains(got, "test_state_backend") { 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 // Result on separate indented line
if !strings.Contains(got, " ⏳") { if !strings.Contains(got, " ⏳") {
t.Errorf("last entry result should be on indented line, got:\n%s", got) t.Errorf("latest 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)
} }
} }