feat: stabilize list output with fixed-height preview (5 lines, 300 chars)

Each session always occupies the same number of lines, preventing
visual jitter between watch refreshes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-03-23 20:50:28 +01:00
parent bdac6d07a9
commit bab681ca30
3 changed files with 19 additions and 14 deletions

View File

@@ -92,10 +92,12 @@ func DisplaySessionInfos(w io.Writer, sessions []SessionInfo, noColor bool, now
fmt.Fprintf(w, "[%s] %s%s%s%s%s\n", stateStr, s.Cwd, branch, workspace, label, waiting) fmt.Fprintf(w, "[%s] %s%s%s%s%s\n", stateStr, s.Cwd, branch, workspace, label, waiting)
if s.Preview != "" {
lines := strings.Split(s.Preview, "\n") lines := strings.Split(s.Preview, "\n")
for _, line := range lines { for i := 0; i < previewLines; i++ {
fmt.Fprintf(w, " %s\n", line) if i < len(lines) && lines[i] != "" {
fmt.Fprintf(w, " %s\n", lines[i])
} else {
fmt.Fprintln(w)
} }
} }
} }

View File

@@ -64,7 +64,7 @@ func DetectState(messages []JSONLMessage, now time.Time) SessionState {
} }
// ExtractPreview finds the last assistant text content and returns the first // ExtractPreview finds the last assistant text content and returns the first
// 3 lines, truncated to 200 characters. // 5 lines, truncated to 300 characters.
func ExtractPreview(messages []JSONLMessage) string { func ExtractPreview(messages []JSONLMessage) string {
for i := len(messages) - 1; i >= 0; i-- { for i := len(messages) - 1; i >= 0; i-- {
msg := messages[i] msg := messages[i]
@@ -80,15 +80,18 @@ func ExtractPreview(messages []JSONLMessage) string {
return "" return ""
} }
const previewLines = 5
const previewMaxChars = 300
func truncatePreview(text string) string { func truncatePreview(text string) string {
lines := strings.SplitN(text, "\n", 4) lines := strings.SplitN(text, "\n", previewLines+1)
if len(lines) > 3 { if len(lines) > previewLines {
lines = lines[:3] lines = lines[:previewLines]
} }
result := strings.Join(lines, "\n") result := strings.Join(lines, "\n")
if len(result) > 200 { if len(result) > previewMaxChars {
result = result[:200] + "..." result = result[:previewMaxChars] + "..."
} }
return result return result
} }

View File

@@ -151,13 +151,13 @@ func TestExtractPreview(t *testing.T) {
Type: "assistant", Type: "assistant",
Message: &MessagePayload{ Message: &MessagePayload{
Role: "assistant", Role: "assistant",
Content: []ContentBlock{{Type: "text", Text: "Voici le resultat\nLigne 2\nLigne 3\nLigne 4\nLigne 5"}}, Content: []ContentBlock{{Type: "text", Text: "Voici le resultat\nLigne 2\nLigne 3\nLigne 4\nLigne 5\nLigne 6\nLigne 7"}},
}, },
}, },
} }
preview := ExtractPreview(msgs) preview := ExtractPreview(msgs)
want := "Voici le resultat\nLigne 2\nLigne 3" want := "Voici le resultat\nLigne 2\nLigne 3\nLigne 4\nLigne 5"
if preview != want { if preview != want {
t.Errorf("ExtractPreview = %q, want %q", preview, want) t.Errorf("ExtractPreview = %q, want %q", preview, want)
} }
@@ -179,7 +179,7 @@ func TestExtractPreview_NoAssistant(t *testing.T) {
} }
func TestExtractPreview_LongText(t *testing.T) { func TestExtractPreview_LongText(t *testing.T) {
// Text longer than 200 chars should be truncated // Text longer than 300 chars should be truncated
longText := "" longText := ""
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
longText += "abcde " longText += "abcde "
@@ -194,7 +194,7 @@ func TestExtractPreview_LongText(t *testing.T) {
}} }}
preview := ExtractPreview(msgs) preview := ExtractPreview(msgs)
if len(preview) > 203 { // 200 + "..." if len(preview) > 303 { // 300 + "..."
t.Errorf("preview too long: %d chars", len(preview)) t.Errorf("preview too long: %d chars", len(preview))
} }
if preview[len(preview)-3:] != "..." { if preview[len(preview)-3:] != "..." {