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:
dj-oyu 2026-02-22 03:20:55 +09:00
parent 06d1795d78
commit d30bda8d2b
2 changed files with 148 additions and 49 deletions

View file

@ -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)
} else {
fmt.Fprintf(&sb, "%s %s\n", entry.Name, entry.Result)
}
if entry.ErrDetail != "" {
for _, line := range strings.Split(entry.ErrDetail, "\n") {
fmt.Fprintf(&sb, "\u2502 %s\n", line)
}
}
if !isLast {
// Past entries: always exactly 1 compact line
sb.WriteString(formatCompactEntry(entry))
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)
// 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") {
sb.WriteString(line)
sb.WriteString("\n")
}
sb.WriteString("```\n")
} 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")

View file

@ -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)
}
// 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)
}
// Pending entry: compact
if !strings.Contains(got, "[3] read_file src/auth.py ⏳") {
t.Errorf("expected compact pending line, 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 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)
}
}