feat: redesign status layout — wider entries, compact errors, strip exec flags

- Increase displayPastEntries 3→4, maxEntryLineWidth 36→42
- Latest entry: command on own line, result below (no inline result)
- Remove 2nd separator before error section, increase error lines 3→5
- Show full workspace path (📁) instead of just project name
- Strip exec option flags (--opt, -O) in buildArgsSnippet via regex
- Add compressRepeats: runs of 3+ identical symbols → 2 (e.g. ====→==)
- Truncate error detail lines to maxEntryLineWidth
- Reduce reserved lines from 2 to 1
- Increase ErrDetail truncation limit 120/200→300

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 05:08:50 +09:00
parent 1e3e1d48e4
commit 2081c447ba
2 changed files with 125 additions and 70 deletions

View file

@ -16,6 +16,7 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"unicode"
"unicode/utf8" "unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -948,6 +949,11 @@ func buildPlanReminder(planStatus string) (providers.Message, bool) {
// cdPrefixPattern matches "cd /some/path && " at the start of a shell command. // cdPrefixPattern matches "cd /some/path && " at the start of a shell command.
var cdPrefixPattern = regexp.MustCompile(`^cd\s+\S+\s*&&\s*`) var cdPrefixPattern = regexp.MustCompile(`^cd\s+\S+\s*&&\s*`)
// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q.
// Only standalone flags are removed; flags whose value is the next positional
// argument (e.g. "-A 20") are kept because removing them would lose context.
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
// buildArgsSnippet produces a human-friendly snippet for the tool log. // buildArgsSnippet produces a human-friendly snippet for the tool log.
// For exec: extracts the command and strips the leading "cd <workspace> && ". // For exec: extracts the command and strips the leading "cd <workspace> && ".
// For file tools: extracts the path and strips the workspace prefix. // For file tools: extracts the path and strips the workspace prefix.
@ -960,6 +966,7 @@ func buildArgsSnippet(toolName string, args map[string]interface{}, workspace st
break break
} }
cmd = cdPrefixPattern.ReplaceAllString(cmd, "") cmd = cdPrefixPattern.ReplaceAllString(cmd, "")
cmd = optFlagPattern.ReplaceAllString(cmd, "")
return utils.Truncate(cmd, 80) return utils.Truncate(cmd, 80)
case "read_file", "write_file", "edit_file", "append_file", "list_dir": case "read_file", "write_file", "edit_file", "append_file", "list_dir":
@ -1000,9 +1007,8 @@ func buildArgsSnippet(toolName string, args map[string]interface{}, workspace st
} }
// maxEntryLineWidth is the max rune count for a single-line log entry. // maxEntryLineWidth is the max rune count for a single-line log entry.
// Telegram chat bubbles on mobile are roughly 35-40 chars wide; keeping // Telegram chat bubbles on mobile are roughly 40-45 chars wide.
// entries under this avoids line-wrapping that causes height jitter. const maxEntryLineWidth = 42
const maxEntryLineWidth = 36
// isFileToolEntry returns true if the entry name contains a file-operation tool. // isFileToolEntry returns true if the entry name contains a file-operation tool.
func isFileToolEntry(name string) bool { func isFileToolEntry(name string) bool {
@ -1055,11 +1061,61 @@ func formatCompactEntry(entry toolLogEntry) string {
return entry.Name + " " + result return entry.Name + " " + result
} }
// formatLatestEntry formats the latest entry command without its result marker.
// Since the result goes on the next line, the full width is available for the command.
func formatLatestEntry(entry toolLogEntry) string {
nameLen := utf8.RuneCountInString(entry.Name)
argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result)
args := entry.ArgsSnip
if args != "" && argsBudget > 3 {
argsRunes := []rune(args)
if len(argsRunes) > argsBudget {
if strings.Contains(args, "/") {
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
} else {
args = string(argsRunes[:argsBudget-1]) + "\u2026"
}
}
return entry.Name + " " + args
}
return entry.Name
}
// compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space
// characters to just 2. e.g. "======" → "==", "---" → "--".
func compressRepeats(s string) string {
runes := []rune(s)
if len(runes) < 3 {
return s
}
var sb strings.Builder
sb.Grow(len(s))
i := 0
for i < len(runes) {
r := runes[i]
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) {
j := i + 1
for j < len(runes) && runes[j] == r {
j++
}
if j-i >= 3 {
sb.WriteRune(r)
sb.WriteRune(r)
i = j
continue
}
}
sb.WriteRune(r)
i++
}
return sb.String()
}
// Display layout constants. // Display layout constants.
const ( const (
displayPastEntries = 3 // number of compact 1-line past entries displayPastEntries = 4 // number of compact 1-line past entries
displayErrorLines = 3 // content lines inside the error code block displayErrorLines = 5 // content lines inside the error code block
maxLatestWidth = 70 // rune limit for the latest entry command (~2 Telegram lines)
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
) )
@ -1068,20 +1124,17 @@ const (
// Layout (always the same number of lines): // Layout (always the same number of lines):
// //
// 🔄 Task in progress (N/M) header // 🔄 Task in progress (N/M) header
// 📂 project-name header // 📁 workspace-path 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 // ━━━━━━━━━━ separator
// [N] compact-past-1 ✓ Xs past (1 line each)
// [N] compact-past-2 ✗ Xs past
// [N] compact-past-3 ✓ Xs past
// [N] compact-past-4 ✓ Xs past
// [N] latest-command latest (no result, wider args)
// ⏳ latest result
// reserved
// ``` error fence // ``` error fence
// err-line / placeholder error body // err-line / placeholder error body (5 lines)
// err-line / placeholder error body
// err-line / placeholder error body
// ``` error fence // ``` error fence
// ↩️ Reply to intervene footer (background only) // ↩️ Reply to intervene footer (background only)
func buildRichStatus(task *activeTask, isBackground bool, workspace string) string { func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
@ -1089,33 +1142,27 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
defer task.mu.Unlock() defer task.mu.Unlock()
var sb strings.Builder var sb strings.Builder
// --- Header ---
sb.WriteString("\U0001F504 Task in progress (") sb.WriteString("\U0001F504 Task in progress (")
sb.WriteString(strconv.Itoa(task.Iteration)) sb.WriteString(strconv.Itoa(task.Iteration))
sb.WriteByte('/') sb.WriteByte('/')
sb.WriteString(strconv.Itoa(task.MaxIter)) sb.WriteString(strconv.Itoa(task.MaxIter))
sb.WriteString(")\n") sb.WriteString(")\n")
// Workspace: always emit for fixed height
sb.WriteString("\U0001F4C1 ")
if workspace != "" { if workspace != "" {
project := workspace sb.WriteString(workspace)
if idx := strings.LastIndex(workspace, "/"); idx >= 0 {
project = workspace[idx+1:]
} else if idx := strings.LastIndex(workspace, "\\"); idx >= 0 {
project = workspace[idx+1:]
} }
if project != "" {
sb.WriteString("\U0001F4C2 ")
sb.WriteString(project)
sb.WriteByte('\n') sb.WriteByte('\n')
}
}
sb.WriteString(statusSeparator) sb.WriteString(statusSeparator)
// --- Task entries region (displayPastEntries + 4 lines) --- // --- Task entries (displayPastEntries + 2 lines for latest) ---
entries := task.toolLog entries := task.toolLog
if len(entries) > maxToolLogEntries { if len(entries) > maxToolLogEntries {
entries = entries[len(entries)-maxToolLogEntries:] entries = entries[len(entries)-maxToolLogEntries:]
} }
// Split into past entries and latest entry
var pastEntries []toolLogEntry var pastEntries []toolLogEntry
var latest *toolLogEntry var latest *toolLogEntry
if len(entries) > 0 { if len(entries) > 0 {
@ -1129,68 +1176,61 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
} }
} }
// Past entries: always exactly displayPastEntries lines (pad if fewer) // Past entries: exactly displayPastEntries lines (pad if fewer)
for i := 0; i < displayPastEntries; i++ { for i := 0; i < displayPastEntries; i++ {
if i < len(pastEntries) { if i < len(pastEntries) {
sb.WriteString(formatCompactEntry(pastEntries[i])) sb.WriteString(formatCompactEntry(pastEntries[i]))
} else { } else {
sb.WriteString("\u2800") // braille blank — invisible but holds the line sb.WriteString("\u2800")
} }
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 {
sb.WriteString(string(runes[:maxLatestWidth-1]))
sb.WriteString("\u2026\n")
} else {
sb.WriteString(prefix)
sb.WriteByte('\n') sb.WriteByte('\n')
} }
// Line 3: result
// Latest entry: command on one line, result on next
if latest != nil {
sb.WriteString(formatLatestEntry(*latest))
sb.WriteByte('\n')
sb.WriteString(" ") sb.WriteString(" ")
if latest.Result != "" {
sb.WriteString(latest.Result) sb.WriteString(latest.Result)
} else {
sb.WriteString("\u23F3")
}
sb.WriteByte('\n') sb.WriteByte('\n')
} else { } else {
sb.WriteString("\u23F3 waiting...\n") sb.WriteString("\u23F3 waiting...\n")
sb.WriteString("\u2800\n") sb.WriteString("\u2800\n")
} }
// Lines 3-4: reserved (keep bubble height stable when result wraps or error appears)
sb.WriteString("\u2800\n") // Reserved (1 line)
sb.WriteString("\u2800\n") sb.WriteString("\u2800\n")
// --- Error region (separator + code fence with displayErrorLines) --- // --- Error region (code fence, no separator) ---
sb.WriteString(statusSeparator)
sb.WriteString("```\n") sb.WriteString("```\n")
errEntry := task.lastError errEntry := task.lastError
if errEntry != nil { if errEntry != nil {
// Header: tool name + result marker
sb.WriteString("\u274C ") sb.WriteString("\u274C ")
sb.WriteString(formatCompactEntry(*errEntry)) sb.WriteString(formatCompactEntry(*errEntry))
sb.WriteByte('\n') sb.WriteByte('\n')
// Detail lines
var detailLines []string var detailLines []string
if errEntry.ErrDetail != "" { if errEntry.ErrDetail != "" {
detailLines = strings.Split(errEntry.ErrDetail, "\n") detailLines = strings.Split(errEntry.ErrDetail, "\n")
} }
for i := 0; i < displayErrorLines-1; i++ { for i := 0; i < displayErrorLines-1; i++ {
if i < len(detailLines) { if i < len(detailLines) {
sb.WriteString(detailLines[i]) line := compressRepeats(detailLines[i])
if runes := []rune(line); len(runes) > maxEntryLineWidth {
line = string(runes[:maxEntryLineWidth-1]) + "\u2026"
}
sb.WriteString(line)
} else { } else {
sb.WriteString("\u2800") sb.WriteString("\u2800")
} }
sb.WriteString("\n") sb.WriteByte('\n')
} }
} else { } else {
// No error: placeholder lines
sb.WriteString("\u2714 No errors\n") sb.WriteString("\u2714 No errors\n")
for i := 0; i < displayErrorLines-1; i++ { for i := 0; i < displayErrorLines-1; i++ {
sb.WriteString("\u2800\n") sb.WriteString("\u2800\n")
@ -1546,7 +1586,7 @@ func (al *AgentLoop) runLLMIteration(
task.toolLog[logIdx].Result = fmt.Sprintf("\u2717 %.1fs", toolDuration.Seconds()) task.toolLog[logIdx].Result = fmt.Sprintf("\u2717 %.1fs", toolDuration.Seconds())
// Extract error detail for block display // Extract error detail for block display
if toolResult.Err != nil { if toolResult.Err != nil {
task.toolLog[logIdx].ErrDetail = utils.Truncate(toolResult.Err.Error(), 120) task.toolLog[logIdx].ErrDetail = utils.Truncate(toolResult.Err.Error(), 300)
} else if toolResult.ForLLM != "" { } else if toolResult.ForLLM != "" {
// exec returns IsError with exit info in ForLLM, not Err // exec returns IsError with exit info in ForLLM, not Err
// Show last few lines (stderr / exit code) // Show last few lines (stderr / exit code)
@ -1556,7 +1596,7 @@ func (al *AgentLoop) runLLMIteration(
start = 0 start = 0
} }
task.toolLog[logIdx].ErrDetail = utils.Truncate( task.toolLog[logIdx].ErrDetail = utils.Truncate(
strings.Join(lines[start:], "\n"), 200) strings.Join(lines[start:], "\n"), 300)
} }
// Sticky error: remember most recent error for persistent display // Sticky error: remember most recent error for persistent display
entry := task.toolLog[logIdx] entry := task.toolLog[logIdx]

View file

@ -1426,11 +1426,11 @@ func TestBuildArgsSnippet_ExecStripsCD(t *testing.T) {
wantSnip: "pytest tests/test_integration.py", wantSnip: "pytest tests/test_integration.py",
}, },
{ {
name: "exec no cd prefix", name: "exec no cd prefix, flags stripped",
tool: "exec", tool: "exec",
args: map[string]interface{}{"command": "ls -la"}, args: map[string]interface{}{"command": "ls -la"},
workspace: "/ws", workspace: "/ws",
wantSnip: "ls -la", wantSnip: "ls",
}, },
{ {
name: "exec empty command", name: "exec empty command",
@ -1642,25 +1642,40 @@ func TestBuildRichStatus_StickyError(t *testing.T) {
} }
} }
func TestBuildRichStatus_LastEntryShowsMoreCommand(t *testing.T) { func TestBuildRichStatus_LatestEntryNoInlineResult(t *testing.T) {
longCmd := "uv run pytest tests/hot/test_state_backend_integration.py -v --timeout=60" longCmd := "uv run pytest tests/hot/test_state_backend_integration.py"
task := &activeTask{ task := &activeTask{
Iteration: 2, Iteration: 2,
MaxIter: 10, MaxIter: 10,
toolLog: []toolLogEntry{ toolLog: []toolLogEntry{
{Name: "exec", ArgsSnip: "ls -la", Result: " 0.5s"}, {Name: "exec", ArgsSnip: "ls -la", Result: "\u2713 0.5s"},
{Name: "exec", ArgsSnip: longCmd, Result: ""}, {Name: "exec", ArgsSnip: longCmd, Result: "\u23F3"},
}, },
} }
got := buildRichStatus(task, false, "/ws/my-project") got := buildRichStatus(task, false, "/ws/my-project")
// Shows more than compact 36 chars // Latest entry shows command (possibly truncated) with filename visible
if !strings.Contains(got, "test_state_backend") { if !strings.Contains(got, "integration.py") {
t.Errorf("latest entry should show more than compact format, got:\n%s", got) t.Errorf("latest entry should show filename, got:\n%s", got)
} }
// Result on separate indented line // Result on separate indented line
if !strings.Contains(got, " ") { if !strings.Contains(got, " \u23F3") {
t.Errorf("latest 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)
} }
// Full workspace path shown (not just project name)
if !strings.Contains(got, "/ws/my-project") {
t.Errorf("should show full workspace path, got:\n%s", got)
}
// No second separator before error section
lines := strings.Split(got, "\n")
sepCount := 0
for _, l := range lines {
if strings.HasPrefix(l, "\u2501") {
sepCount++
}
}
if sepCount != 1 {
t.Errorf("expected exactly 1 separator, got %d in:\n%s", sepCount, got)
}
} }