Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions strix/interface/tui/internal/app/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
5 changes: 4 additions & 1 deletion strix/interface/tui/internal/app/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions strix/interface/tui/internal/app/setup_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
52 changes: 45 additions & 7 deletions strix/interface/tui/internal/app/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") +
Expand All @@ -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 {
Expand Down