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
06d1795d78
commit
d30bda8d2b
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)
|
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.
|
// 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 {
|
func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
|
||||||
task.mu.Lock()
|
task.mu.Lock()
|
||||||
defer task.mu.Unlock()
|
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)
|
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
|
// Show project directory name so user knows which workspace is active
|
||||||
if workspace != "" {
|
if workspace != "" {
|
||||||
// Extract last path component as project name
|
|
||||||
project := workspace
|
project := workspace
|
||||||
if idx := strings.LastIndex(workspace, "/"); idx >= 0 {
|
if idx := strings.LastIndex(workspace, "/"); idx >= 0 {
|
||||||
project = workspace[idx+1:]
|
project = workspace[idx+1:]
|
||||||
|
|
@ -997,30 +1031,41 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
|
||||||
if len(entries) > maxToolLogEntries {
|
if len(entries) > maxToolLogEntries {
|
||||||
entries = entries[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")
|
isErr := strings.HasPrefix(entry.Result, "\u2717")
|
||||||
if isErr {
|
|
||||||
// Error: multi-line block with decoration
|
if !isLast {
|
||||||
if entry.ArgsSnip != "" {
|
// Past entries: always exactly 1 compact line
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", entry.Name, entry.ArgsSnip, entry.Result)
|
sb.WriteString(formatCompactEntry(entry))
|
||||||
|
sb.WriteString("\n")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&sb, "%s %s\n", entry.Name, entry.Result)
|
// Latest entry: reserved area for detail
|
||||||
}
|
sb.WriteString(formatCompactEntry(entry))
|
||||||
if entry.ErrDetail != "" {
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if isErr && entry.ErrDetail != "" {
|
||||||
|
// Error block in code-fence style
|
||||||
|
sb.WriteString("```\n")
|
||||||
for _, line := range strings.Split(entry.ErrDetail, "\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 {
|
} else {
|
||||||
// Success/pending: compact one-liner
|
// Reserve height: blank line so bubble doesn't shrink
|
||||||
if entry.ArgsSnip != "" {
|
// when the next update adds error detail
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", entry.Name, utils.Truncate(entry.ArgsSnip, 40), entry.Result)
|
sb.WriteString("\n")
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&sb, "%s %s\n", entry.Name, entry.Result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestBuildRichStatus(t *testing.T) {
|
||||||
task := &activeTask{
|
task := &activeTask{
|
||||||
Iteration: 3,
|
Iteration: 3,
|
||||||
MaxIter: 20,
|
MaxIter: 20,
|
||||||
toolLog: []toolLogEntry{
|
toolLog: []toolLogEntry{
|
||||||
{Name: "[1] exec", ArgsSnip: "ls -la", Result: "✓ 1.2s"},
|
{Name: "exec", ArgsSnip: "ls -la", Result: "✓ 1.2s"},
|
||||||
{Name: "[2] exec", ArgsSnip: "pytest tests/", Result: "✓ 5.0s"},
|
{Name: "exec", ArgsSnip: "pytest tests/", Result: "✓ 5.0s"},
|
||||||
{Name: "[3] read_file", ArgsSnip: "src/main.go", Result: "⏳"},
|
{Name: "read_file", ArgsSnip: "src/main.go", Result: "⏳"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1487,10 +1529,8 @@ func TestBuildRichStatus(t *testing.T) {
|
||||||
mustContain := []string{
|
mustContain := []string{
|
||||||
"Task in progress (3/20)",
|
"Task in progress (3/20)",
|
||||||
"my-projects",
|
"my-projects",
|
||||||
"[1] exec",
|
"exec",
|
||||||
"pytest tests/",
|
"read_file",
|
||||||
"[3] read_file",
|
|
||||||
"src/main.go",
|
|
||||||
}
|
}
|
||||||
for _, s := range mustContain {
|
for _, s := range mustContain {
|
||||||
if !strings.Contains(got, s) {
|
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{
|
task := &activeTask{
|
||||||
Iteration: 3,
|
Iteration: 2,
|
||||||
MaxIter: 10,
|
MaxIter: 10,
|
||||||
toolLog: []toolLogEntry{
|
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",
|
ArgsSnip: "pytest tests/test_auth.py",
|
||||||
Result: "✗ 3.2s",
|
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")
|
got := buildRichStatus(task, false, "/ws/my-project")
|
||||||
|
|
||||||
// Success entry: compact one-liner
|
// First entry (past): compact one-liner, result marker present
|
||||||
if !strings.Contains(got, "[1] exec ls -la ✓ 0.5s") {
|
if !strings.Contains(got, "✓ 0.5s") {
|
||||||
t.Errorf("expected compact success line, got:\n%s", got)
|
t.Errorf("expected success marker in past entry, got:\n%s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error entry: command + mark on first line
|
// Last entry: error with code fence detail
|
||||||
if !strings.Contains(got, "[2] exec pytest tests/test_auth.py ✗ 3.2s") {
|
if !strings.Contains(got, "✗ 3.2s") {
|
||||||
t.Errorf("expected error header line, got:\n%s", got)
|
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
|
func TestBuildRichStatus_ErrorNotLastEntry(t *testing.T) {
|
||||||
if !strings.Contains(got, "│ FAILED tests/test_auth.py::test_login") {
|
// When error is a past entry (not the last), it should be 1 line
|
||||||
t.Errorf("expected │-prefixed error detail, got:\n%s", got)
|
task := &activeTask{
|
||||||
}
|
Iteration: 3,
|
||||||
if !strings.Contains(got, "│ Exit code: exit status 1") {
|
MaxIter: 10,
|
||||||
t.Errorf("expected │-prefixed exit code line, got:\n%s", got)
|
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
|
got := buildRichStatus(task, false, "/ws/p")
|
||||||
if !strings.Contains(got, "[3] read_file src/auth.py ⏳") {
|
|
||||||
t.Errorf("expected compact pending line, got:\n%s", got)
|
// 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