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:
parent
91b8df5844
commit
0b6ed5fcd1
2 changed files with 201 additions and 124 deletions
|
|
@ -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")
|
||||
// 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 !isLast {
|
||||
// Past entries: always exactly 1 compact line
|
||||
sb.WriteString(formatCompactEntry(entry))
|
||||
sb.WriteString("\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 {
|
||||
// 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
|
||||
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)
|
||||
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")
|
||||
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")
|
||||
}
|
||||
} 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 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 {
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue