diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go index 901db23fe..e971d392c 100644 --- a/strix/interface/tui/internal/app/model_test.go +++ b/strix/interface/tui/internal/app/model_test.go @@ -1169,3 +1169,120 @@ func TestChatContentRerendersOnWidthAndExpansionChange(t *testing.T) { } } } + +// A model or backend failure can be a wrapped exception hundreds of columns +// wide and several lines long. The status row is one line of the chat column, so +// an oversized one widens the whole column - JoinHorizontal pads every row to the +// widest - which pushed the sidebar off screen and wrapped the frame. +func TestLongErrorDoesNotBreakTheFrame(t *testing.T) { + model := New(nil) + model.width, model.height = 120, 24 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"})) + bootstrap := protocol.CollectionBootstrap{ + Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true, + Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})}, + } + model.handleEnvelope(protocol.Envelope{ + Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap), + }) + model.errorText = "litellm.APIConnectionError: OpenrouterException - Connection error " + + "while calling https://openrouter.ai/api/v1/chat/completions: HTTPSConnectionPool" + + "(host='openrouter.ai', port=443): Max retries exceeded\nTraceback (most recent " + + "call last):\n File \"/x/y.py\", line 42, in send\n raise err" + model.resizeViewport() + + lines := strings.Split(model.View(), "\n") + if len(lines) > model.height { + t.Fatalf("frame is %d rows in a %d-row terminal", len(lines), model.height) + } + for i, line := range lines { + if width := ansi.StringWidth(line); width > model.width { + t.Fatalf("row %d is %d columns in a %d-column terminal", i, width, model.width) + } + } + // The sidebar has to survive: its panels are the right edge of the frame. + if !strings.Contains(ansi.Strip(model.View()), "Strix") { + t.Fatal("the agent tree was pushed out of the frame") + } +} + +func TestStatusMessageFlattensAndKeepsItsHint(t *testing.T) { + row := ansi.Strip(statusMessage("boom\nsecond line\twith tabs", red, " · Send message to resume", 60)) + + if strings.Contains(row, "\n") || strings.Contains(row, "\t") { + t.Fatalf("status row is not a single line: %q", row) + } + if !strings.HasSuffix(row, " · Send message to resume") { + t.Fatalf("the hint was lost: %q", row) + } + if !strings.Contains(row, "boom second line with tabs") { + t.Fatalf("the message was mangled: %q", row) + } + // A message far too long for the row keeps the hint readable. + long := ansi.Strip(statusMessage(strings.Repeat("x", 500), red, " · Send message to resume", 60)) + if width := ansi.StringWidth(long); width > 60 { + t.Fatalf("status message is %d columns, want at most 60", width) + } + if !strings.HasSuffix(long, " · Send message to resume") { + t.Fatalf("the hint was clipped away: %q", long) + } +} + +// The status row must be exactly as wide as the column it sits in, at every +// terminal size. A narrow terminal cannot fit the quit hint alongside any status +// text, and keeping it anyway made the row wider than the terminal. +func TestStatusRowIsExactlyItsWidth(t *testing.T) { + quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") + + lipgloss.NewStyle().Foreground(dim).Render(" quit") + longMessage := lipgloss.NewStyle().Foreground(red).Render(strings.Repeat("boom ", 40)) + + for width := 1; width <= 60; width++ { + for _, testCase := range []struct { + name string + left, right string + }{ + {"empty", "", ""}, + {"hint only", "", quitHint}, + {"long message and hint", longMessage, quitHint}, + {"long message alone", longMessage, ""}, + } { + row := composeStatusRow(testCase.left, testCase.right, width) + if got := ansi.StringWidth(row); got != width { + t.Fatalf("%s at width %d rendered %d columns: %q", + testCase.name, width, got, ansi.Strip(row)) + } + if strings.Contains(row, "\n") { + t.Fatalf("%s at width %d spans rows", testCase.name, width) + } + } + } + if row := composeStatusRow("x", "y", 0); row != "" { + t.Fatalf("a zero-width row should be empty, got %q", row) + } +} + +// A running scan in a narrow terminal must not wrap the frame. +func TestNarrowTerminalKeepsTheFrameIntact(t *testing.T) { + for _, width := range []int{8, 10, 13, 14, 20, 40} { + model := New(nil) + model.width, model.height = width, 20 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"})) + bootstrap := protocol.CollectionBootstrap{ + Collection: "agents", Revision: 1, Cursor: 0, NextCursor: 1, Done: true, + Items: []json.RawMessage{rawJSON(t, protocol.Agent{ID: "a0", Name: "Strix", Status: "running"})}, + } + model.handleEnvelope(protocol.Envelope{ + Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, bootstrap), + }) + model.errorText = strings.Repeat("connection failed ", 20) + model.resizeViewport() + + for i, line := range strings.Split(model.View(), "\n") { + if got := ansi.StringWidth(line); got > width { + t.Fatalf("at width %d row %d is %d columns", width, i, got) + } + } + } +} diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index ec3b13611..4a6c4b18b 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -214,8 +214,11 @@ func (m *Model) setupLogAppend(line string) { } // setupMsg appends a styled feedback line (success green, error red, notice dim). +// The log budgets rows by entry, so a message is flattened to one line first: a +// wrapped exception would otherwise render as several rows and push the launch +// column past the bottom of the terminal. func (m *Model) setupMsg(text string, style lipgloss.Style) { - m.setupLogAppend(style.Render(text)) + m.setupLogAppend(style.Render(flattenStatus(text))) } // setupLogRows is how many feedback lines the launch column shows before the diff --git a/strix/interface/tui/internal/app/setup_log_test.go b/strix/interface/tui/internal/app/setup_log_test.go index b737bcb86..603fbe1b8 100644 --- a/strix/interface/tui/internal/app/setup_log_test.go +++ b/strix/interface/tui/internal/app/setup_log_test.go @@ -93,3 +93,25 @@ func TestFocusedPanelsCarryTheGreenBorder(t *testing.T) { } } } + +// A wrapped exception is several lines. The log budgets rows by entry, so it has +// to become one row or the launch column grows past the terminal. +func TestSetupLogKeepsMultiLineErrorsToOneRow(t *testing.T) { + model := New(nil) + model.width, model.height = 100, 26 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"})) + model.setupMsg("boom\nTraceback (most recent call last):\n File \"x.py\", line 1\n raise", render.Col(red)) + model.resizeViewport() + + if entries := len(model.setupLog); entries != 1 { + t.Fatalf("one message became %d log entries", entries) + } + if strings.Contains(model.setupLog[0], "\n") { + t.Fatalf("log entry spans rows: %q", model.setupLog[0]) + } + lines := strings.Split(model.View(), "\n") + if len(lines) > model.height { + t.Fatalf("start screen is %d rows in a %d-row terminal", len(lines), model.height) + } +} diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index f50f4a46d..dec2b09b1 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -654,8 +654,7 @@ func (m Model) statusView(width int) string { case "waiting": left = lipgloss.NewStyle().Foreground(dim).Render("Send message to resume") if msg := agent.ErrorMessage; msg != "" { - left = lipgloss.NewStyle().Foreground(red).Render(msg) + - lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume") + left = statusMessage(msg, red, " · Send message to resume", width) } case "budget_paused": left = lipgloss.NewStyle().Foreground(amber).Render("Budget limit reached") + @@ -670,15 +669,54 @@ func (m Model) statusView(width int) string { if msg == "" { msg = "Agent failed" } - left = lipgloss.NewStyle().Foreground(red).Render(msg) + - lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume") + left = statusMessage(msg, red, " · Send message to resume", width) } } if m.errorText != "" { - left = lipgloss.NewStyle().Foreground(red).Render(m.errorText) + left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right)) } - gap := max(1, width-lipgloss.Width(left)-lipgloss.Width(right)) - return " " + left + strings.Repeat(" ", max(1, gap-1)) + right + return composeStatusRow(left, right, width) +} + +// composeStatusRow lays the status text and the corner hint on one row exactly +// width columns wide. A wider row would widen the whole chat column, because +// JoinHorizontal pads every row of a block to its widest, which pushes the +// sidebar off screen and wraps the frame. +func composeStatusRow(left, right string, width int) string { + if width <= 0 { + return "" + } + const leading = 1 // the row is indented one column, like the panels above it + // A terminal can be narrower than the hint itself. Drop the hint rather than + // keep it at the cost of the status, which is the part carrying information; + // ctrl-q works whether or not the row has room to say so. + if lipgloss.Width(right) > 0 && width < lipgloss.Width(right)+leading+2 { + right = "" + } + separator := 0 + if lipgloss.Width(right) > 0 { + separator = 1 + } + left = truncate(left, max(0, width-leading-lipgloss.Width(right)-separator)) + padding := max(0, width-leading-lipgloss.Width(left)-lipgloss.Width(right)) + return " " + left + strings.Repeat(" ", padding) + right +} + +// statusMessage fits a message and its trailing hint on the one status row. A +// model or backend error can be a wrapped exception several lines long, so it is +// flattened to a single line and clipped, leaving the hint readable. +func statusMessage(message string, color lipgloss.Color, hint string, width int) string { + styledHint := lipgloss.NewStyle().Foreground(dim).Render(hint) + room := max(1, width-2-lipgloss.Width(styledHint)) + flat := truncate(flattenStatus(message), room) + return lipgloss.NewStyle().Foreground(color).Render(flat) + styledHint +} + +// flattenStatus turns a multi-line message into one line, collapsing the runs of +// whitespace that joining its lines leaves behind. +func flattenStatus(message string) string { + message = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ", "\t", " ").Replace(message) + return strings.Join(strings.Fields(message), " ") } func (m Model) sweepView() string {