fix: stable chat bubble height for terminal status display
- Past entries: always exactly 1 line, truncated at 36 runes with result marker (✓/✗) always visible at the end regardless of truncation - Latest entry: reserved space below (blank line or code-fenced error detail), so the bubble height doesn't jump when errors appear - Error detail on last entry uses ``` code fence for proper decoration - formatCompactEntry() ensures no line exceeds maxEntryLineWidth (36) to prevent wrapping on Telegram mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
143ce290f7
commit
ff8cd2a75e
2 changed files with 148 additions and 49 deletions
|
|
@ -970,7 +970,42 @@ func buildArgsSnippet(toolName string, args map[string]interface{}, workspace st
|
|||
return utils.Truncate(string(argsJSON), 80)
|
||||
}
|
||||
|
||||
// maxEntryLineWidth is the max rune count for a single-line log entry.
|
||||
// Telegram chat bubbles on mobile are roughly 35-40 chars wide; keeping
|
||||
// entries under this avoids line-wrapping that causes height jitter.
|
||||
const maxEntryLineWidth = 36
|
||||
|
||||
// formatCompactEntry formats a finished tool log entry as a fixed single line.
|
||||
// The result marker (✓/✗) is always shown at the end regardless of truncation.
|
||||
func formatCompactEntry(entry toolLogEntry) string {
|
||||
// result is e.g. "✓ 1.2s" or "✗ 3.0s" — always 6-8 chars
|
||||
result := entry.Result
|
||||
if result == "" {
|
||||
result = "\u23F3" // ⏳
|
||||
}
|
||||
|
||||
prefix := entry.Name
|
||||
if entry.ArgsSnip != "" {
|
||||
prefix += " " + entry.ArgsSnip
|
||||
}
|
||||
|
||||
// Budget: total ≤ maxEntryLineWidth, need space for " " + result
|
||||
budget := maxEntryLineWidth - 1 - utf8.RuneCountInString(result)
|
||||
if budget < 4 {
|
||||
budget = 4
|
||||
}
|
||||
|
||||
prefixRunes := []rune(prefix)
|
||||
if len(prefixRunes) > budget {
|
||||
prefix = string(prefixRunes[:budget-1]) + "\u2026" // …
|
||||
}
|
||||
|
||||
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.
|
||||
func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
|
||||
task.mu.Lock()
|
||||
defer task.mu.Unlock()
|
||||
|
|
@ -979,7 +1014,6 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
|
|||
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 != "" {
|
||||
// Extract last path component as project name
|
||||
project := workspace
|
||||
if idx := strings.LastIndex(workspace, "/"); idx >= 0 {
|
||||
project = workspace[idx+1:]
|
||||
|
|
@ -997,30 +1031,41 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
|
|||
if len(entries) > maxToolLogEntries {
|
||||
entries = entries[len(entries)-maxToolLogEntries:]
|
||||
}
|
||||
for _, entry := range entries {
|
||||
|
||||
for i, entry := range entries {
|
||||
isLast := i == len(entries)-1
|
||||
isErr := strings.HasPrefix(entry.Result, "\u2717")
|
||||
if isErr {
|
||||
// Error: multi-line block with decoration
|
||||
if entry.ArgsSnip != "" {
|
||||
fmt.Fprintf(&sb, "%s %s %s\n", entry.Name, entry.ArgsSnip, entry.Result)
|
||||
|
||||
if !isLast {
|
||||
// Past entries: always exactly 1 compact line
|
||||
sb.WriteString(formatCompactEntry(entry))
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "%s %s\n", entry.Name, entry.Result)
|
||||
}
|
||||
if entry.ErrDetail != "" {
|
||||
// Latest entry: reserved area for detail
|
||||
sb.WriteString(formatCompactEntry(entry))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if isErr && entry.ErrDetail != "" {
|
||||
// Error block in code-fence style
|
||||
sb.WriteString("```\n")
|
||||
for _, line := range strings.Split(entry.ErrDetail, "\n") {
|
||||
fmt.Fprintf(&sb, "\u2502 %s\n", line)
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("```\n")
|
||||
} else {
|
||||
// Success/pending: compact one-liner
|
||||
if entry.ArgsSnip != "" {
|
||||
fmt.Fprintf(&sb, "%s %s %s\n", entry.Name, utils.Truncate(entry.ArgsSnip, 40), entry.Result)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "%s %s\n", entry.Name, entry.Result)
|
||||
// Reserve height: blank line so bubble doesn't shrink
|
||||
// when the next update adds error detail
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -1471,14 +1471,56 @@ func TestBuildArgsSnippet_ExecStripsCD(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatCompactEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry toolLogEntry
|
||||
wantSub string // must be a substring
|
||||
wantMark string // result marker must appear
|
||||
}{
|
||||
{
|
||||
name: "short entry",
|
||||
entry: toolLogEntry{Name: "exec", ArgsSnip: "ls", Result: "✓ 1.0s"},
|
||||
wantSub: "exec ls",
|
||||
wantMark: "✓",
|
||||
},
|
||||
{
|
||||
name: "long entry is truncated with marker preserved",
|
||||
entry: toolLogEntry{Name: "exec", ArgsSnip: "pytest tests/integration/test_very_long_name_that_exceeds_width.py", Result: "✗ 3.0s"},
|
||||
wantMark: "✗",
|
||||
},
|
||||
{
|
||||
name: "no args",
|
||||
entry: toolLogEntry{Name: "list_dir", Result: "✓ 0.1s"},
|
||||
wantSub: "list_dir",
|
||||
wantMark: "✓",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatCompactEntry(tt.entry)
|
||||
if tt.wantSub != "" && !strings.Contains(got, tt.wantSub) {
|
||||
t.Errorf("expected to contain %q, got: %q", tt.wantSub, got)
|
||||
}
|
||||
if !strings.Contains(got, tt.wantMark) {
|
||||
t.Errorf("result marker %q missing from: %q", tt.wantMark, got)
|
||||
}
|
||||
// Must not exceed maxEntryLineWidth
|
||||
if runeLen := len([]rune(got)); runeLen > maxEntryLineWidth {
|
||||
t.Errorf("entry too wide: %d runes (max %d): %q", runeLen, maxEntryLineWidth, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRichStatus(t *testing.T) {
|
||||
task := &activeTask{
|
||||
Iteration: 3,
|
||||
MaxIter: 20,
|
||||
toolLog: []toolLogEntry{
|
||||
{Name: "[1] exec", ArgsSnip: "ls -la", Result: "✓ 1.2s"},
|
||||
{Name: "[2] exec", ArgsSnip: "pytest tests/", Result: "✓ 5.0s"},
|
||||
{Name: "[3] read_file", ArgsSnip: "src/main.go", Result: "⏳"},
|
||||
{Name: "exec", ArgsSnip: "ls -la", Result: "✓ 1.2s"},
|
||||
{Name: "exec", ArgsSnip: "pytest tests/", Result: "✓ 5.0s"},
|
||||
{Name: "read_file", ArgsSnip: "src/main.go", Result: "⏳"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1487,10 +1529,8 @@ func TestBuildRichStatus(t *testing.T) {
|
|||
mustContain := []string{
|
||||
"Task in progress (3/20)",
|
||||
"my-projects",
|
||||
"[1] exec",
|
||||
"pytest tests/",
|
||||
"[3] read_file",
|
||||
"src/main.go",
|
||||
"exec",
|
||||
"read_file",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(got, s) {
|
||||
|
|
@ -1536,44 +1576,58 @@ func TestBuildRichStatus_ShowsLast5(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildRichStatus_ErrorBlock(t *testing.T) {
|
||||
func TestBuildRichStatus_ErrorAsLastEntry(t *testing.T) {
|
||||
task := &activeTask{
|
||||
Iteration: 3,
|
||||
Iteration: 2,
|
||||
MaxIter: 10,
|
||||
toolLog: []toolLogEntry{
|
||||
{Name: "[1] exec", ArgsSnip: "ls -la", Result: "✓ 0.5s"},
|
||||
{Name: "exec", ArgsSnip: "ls -la", Result: "✓ 0.5s"},
|
||||
{
|
||||
Name: "[2] exec",
|
||||
Name: "exec",
|
||||
ArgsSnip: "pytest tests/test_auth.py",
|
||||
Result: "✗ 3.2s",
|
||||
ErrDetail: "FAILED tests/test_auth.py::test_login\nExit code: exit status 1",
|
||||
ErrDetail: "FAILED test_login\nExit code: 1",
|
||||
},
|
||||
{Name: "[3] read_file", ArgsSnip: "src/auth.py", Result: "⏳"},
|
||||
},
|
||||
}
|
||||
|
||||
got := buildRichStatus(task, false, "/ws/my-project")
|
||||
|
||||
// Success entry: compact one-liner
|
||||
if !strings.Contains(got, "[1] exec ls -la ✓ 0.5s") {
|
||||
t.Errorf("expected compact success line, got:\n%s", got)
|
||||
// First entry (past): compact one-liner, result marker present
|
||||
if !strings.Contains(got, "✓ 0.5s") {
|
||||
t.Errorf("expected success marker in past entry, got:\n%s", got)
|
||||
}
|
||||
|
||||
// Error entry: command + mark on first line
|
||||
if !strings.Contains(got, "[2] exec pytest tests/test_auth.py ✗ 3.2s") {
|
||||
t.Errorf("expected error header line, got:\n%s", got)
|
||||
// Last entry: error with code fence detail
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Error detail: │-prefixed lines
|
||||
if !strings.Contains(got, "│ FAILED tests/test_auth.py::test_login") {
|
||||
t.Errorf("expected │-prefixed error detail, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "│ Exit code: exit status 1") {
|
||||
t.Errorf("expected │-prefixed exit code line, got:\n%s", got)
|
||||
func TestBuildRichStatus_ErrorNotLastEntry(t *testing.T) {
|
||||
// When error is a past entry (not the last), it should be 1 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: "⏳"},
|
||||
},
|
||||
}
|
||||
|
||||
// Pending entry: compact
|
||||
if !strings.Contains(got, "[3] read_file src/auth.py ⏳") {
|
||||
t.Errorf("expected compact pending line, got:\n%s", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue