Skip to content
Open
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
5 changes: 5 additions & 0 deletions cmd/mecatui/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ func (t *Theme) compile() {
BorderTop(true).
BorderForeground(col(p.Border)),

// Activity line above the input box: muted, same inset as the footer.
"activity": lipgloss.NewStyle().
Foreground(col(p.TextMuted)).
Padding(0, 1),

// Conversation viewport surface.
"viewport": lipgloss.NewStyle().
Foreground(col(p.Text)),
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/theme/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestBuiltinsSlotCompleteness(t *testing.T) {
// silently returns an empty style — this guards against that drift.
func TestStylesCompiled(t *testing.T) {
want := []string{
"header", "footer", "viewport", "userBlock", "userLabel",
"header", "footer", "activity", "viewport", "userBlock", "userLabel",
"assistantLabel", "toolCard", "toolName", "toolArgs", "toolOk",
"toolErr", "askCard", "askTitle", "askArgs", "askButton", "askButtonActive",
"spinner", "muted", "warning", "dangerPill", "errorText", "selection",
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/ui/agents_overlay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ func TestSubagentRosterRowSeparatesTitleAndDetails(t *testing.T) {
func TestSubagentRosterWindowed(t *testing.T) {
const n = 20
m := newMCPModel(t, aztec(), nil)
m = resize(m, 100, 24)
m = resize(m, 100, 25)
msgs := make([]client.SubagentMsg, 0, n)
for i := 0; i < n; i++ {
child := "child-" + string(rune('a'+i))
Expand Down
29 changes: 16 additions & 13 deletions cmd/mecatui/ui/ask_queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ func resumeApprovalAskIDs(send *fakeSender) []string {
}

// TestAskEnqueuedWhileModalOpen: a second PermissionAskMsg while a modal is open
// ENQUEUES behind the visible head (never clobbers it), and both the footer and
// the modal title advertise the queue with a "(1 of 2)" badge.
// ENQUEUES behind the visible head (never clobbers it), and both the activity line
// and the modal title advertise the queue with a "(1 of 2)" badge.
func TestAskEnqueuedWhileModalOpen(t *testing.T) {
m, _ := queuedAskModel(t)
if approvalSurfaceOf(t, m).ask.AskID != askA {
Expand All @@ -64,8 +64,11 @@ func TestAskEnqueuedWhileModalOpen(t *testing.T) {
if m.phase != phaseAwaitingApproval {
t.Fatalf("phase = %v, want phaseAwaitingApproval", m.phase)
}
if footer := stripANSIstr(m.renderFooter()); !strings.Contains(footer, "(1 of 2)") {
t.Errorf("footer must carry the queue badge, got %q", footer)
if activity := stripANSIstr(m.renderActivity()); !strings.Contains(activity, "(1 of 2)") {
t.Errorf("activity line must carry the queue badge, got %q", activity)
}
if footer := stripANSIstr(m.renderFooter()); strings.Contains(footer, "(1 of 2)") {
t.Errorf("the queue badge must not be duplicated in the footer, got %q", footer)
}
modal := stripANSIstr(approvalSurfaceOf(t, m).renderPermissionModal(100, 24))
if !strings.Contains(modal, "Permission required (1 of 2)") {
Expand All @@ -77,7 +80,7 @@ func TestAskEnqueuedWhileModalOpen(t *testing.T) {
// queued successor sends the approval for the ANSWERED ask, pops the successor
// into the modal, STAYS awaitingApproval, and does NOT re-arm the spinner (the
// spinner is still off-screen under the successor modal — keep in sync with
// TestSpinnerVisibleMatchesFooterRender).
// TestSpinnerVisibleMatchesActivityRender).
func TestResolveAskAdvancesQueueNoSpinnerRearm(t *testing.T) {
m, send := queuedAskModel(t)
m, cmd := pressKey(m, tea.KeyPressMsg{Code: 'a', Text: "a"})
Expand Down Expand Up @@ -122,8 +125,8 @@ func TestResolveAskFIFOOrderTwoDeep(t *testing.T) {
if approvalSurfaceOf(t, m).ask.AskID != askB {
t.Fatalf("FIFO violated: head = %q, want %q (B before C)", approvalSurfaceOf(t, m).ask.AskID, askB)
}
if footer := stripANSIstr(m.renderFooter()); !strings.Contains(footer, "(1 of 2)") {
t.Errorf("with one ask still queued the footer badge must read (1 of 2), got %q", footer)
if activity := stripANSIstr(m.renderActivity()); !strings.Contains(activity, "(1 of 2)") {
t.Errorf("with one ask still queued the activity badge must read (1 of 2), got %q", activity)
}

// Answer B: C heads, queue now EMPTY → no badge anywhere (never "(1 of 1)").
Expand All @@ -134,8 +137,8 @@ func TestResolveAskFIFOOrderTwoDeep(t *testing.T) {
if approvalSurfaceOf(t, m).ask.AskID != askC || len(approvalSurfaceOf(t, m).queue) != 0 {
t.Fatalf("want C visible with an empty queue, got head %q queue %+v", approvalSurfaceOf(t, m).ask.AskID, approvalSurfaceOf(t, m).queue)
}
if footer := stripANSIstr(m.renderFooter()); strings.Contains(footer, "(1 of") {
t.Errorf("the badge must vanish at queue-empty, footer = %q", footer)
if activity := stripANSIstr(m.renderActivity()); strings.Contains(activity, "(1 of") {
t.Errorf("the badge must vanish at queue-empty, activity = %q", activity)
}
modal := stripANSIstr(approvalSurfaceOf(t, m).renderPermissionModal(100, 24))
if strings.Contains(modal, "(1 of") {
Expand Down Expand Up @@ -290,8 +293,8 @@ func TestEndRunClearsAskQueue(t *testing.T) {
if m.phase != phaseAwaitingApproval || approvalSurfaceOf(t, m).ask.AskID != askB {
t.Fatalf("a fresh ask after run end must open the modal (not be swallowed by a stale answered-set), got phase=%v ask=%+v", m.phase, approvalSurfaceOf(t, m).ask)
}
if footer := stripANSIstr(m.renderFooter()); strings.Contains(footer, "(1 of") {
t.Errorf("a fresh single ask must carry no phantom queue badge, footer = %q", footer)
if activity := stripANSIstr(m.renderActivity()); strings.Contains(activity, "(1 of") {
t.Errorf("a fresh single ask must carry no phantom queue badge, activity = %q", activity)
}
})
}
Expand Down Expand Up @@ -319,8 +322,8 @@ func TestResetSessionDropsAskQueue(t *testing.T) {
func TestAskQueueBadgeWithLongArgsHeadAsk(t *testing.T) {
m, _ := queuedAskModel(t) // A (Shell) visible, B (Write) queued
approvalSurfaceOf(t, m).ask.Args = longShellArgs
if footer := stripANSIstr(m.renderFooter()); !strings.Contains(footer, "(1 of 2)") {
t.Errorf("footer must carry the queue badge, got %q", footer)
if activity := stripANSIstr(m.renderActivity()); !strings.Contains(activity, "(1 of 2)") {
t.Errorf("activity line must carry the queue badge, got %q", activity)
}
modal := stripANSIstr(m.renderBody())
if !strings.Contains(modal, "Permission required (1 of 2)") {
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/ui/canonical_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestCanonicalStatus_Scenario1_EmptySourceDoesNotFallBack(t *testing.T) {
if strings.Contains(header, "session") || strings.Contains(header, "canonical-model") || strings.Contains(header, "oversize") || strings.Contains(header, "·") || strings.Contains(footer, "ctx") || strings.Contains(footer, "oversize") {
t.Fatalf("source fallback: %q / %q", header, footer)
}
if !strings.Contains(header, "auto") || !strings.Contains(footer, "ready") || !strings.Contains(footer, "help") {
if !strings.Contains(header, "auto") || !strings.Contains(stripANSIstr(m.renderActivity()), "ready") || !strings.Contains(footer, "help") {
t.Fatalf("lost mandatory chrome: %q / %q", header, footer)
}
}
Expand All @@ -84,7 +84,7 @@ func TestCanonicalStatus_Scenario1_NoSourceMinimalIdentity(t *testing.T) {
} else if !strings.Contains(header, "session "+handle) {
t.Fatalf("known identity: %q", header)
}
if strings.Contains(header, "canonical-model") || strings.Contains(header, "[31m") || strings.Contains(footer, "ctx") || strings.Contains(footer, "↑") || !strings.Contains(header, "auto") || !strings.Contains(footer, "ready") || !strings.Contains(footer, "help") {
if strings.Contains(header, "canonical-model") || strings.Contains(header, "[31m") || strings.Contains(footer, "ctx") || strings.Contains(footer, "↑") || !strings.Contains(header, "auto") || !strings.Contains(stripANSIstr(m.renderActivity()), "ready") || !strings.Contains(footer, "help") {
t.Fatalf("unsafe/legacy identity or lost chrome: %q / %q", header, footer)
}
}
Expand Down
42 changes: 21 additions & 21 deletions cmd/mecatui/ui/footer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ import (
)

// TestFooterSelectionCount: an idle model with a known multi-line, non-empty
// selection shows the live "N chars · M lines" count in the footer-left.
// selection shows the live "N chars · M lines" count in the activity line.
func TestFooterSelectionCount(t *testing.T) {
m, _ := selModel(t)
m.vp.SetContent("hello world\nsecond line\nthird row")
// Select "world\nsecond line\nthird" — line0col6 .. line2col5.
m.sel = selection{active: true, anchorL: 0, anchorC: 6, headL: 2, headC: 5}
m.phase = phaseIdle
got := stripANSIstr(m.renderFooter())
got := stripANSIstr(m.renderActivity())
// "world" (5) + "\n" + "second line" (11) + "\n" + "third" (5) = 23 chars.
if !strings.Contains(got, "23 chars · 3 lines") {
t.Errorf("footer = %q, want it to contain %q", got, "23 chars · 3 lines")
t.Errorf("activity = %q, want it to contain %q", got, "23 chars · 3 lines")
}
}

Expand All @@ -34,14 +34,14 @@ func TestFooterSelectionCountSingular(t *testing.T) {
// Select a single character on line0: col0..col1 ("h").
m.sel = selection{active: true, anchorL: 0, anchorC: 0, headL: 0, headC: 1}
m.phase = phaseIdle
got := stripANSIstr(m.renderFooter())
got := stripANSIstr(m.renderActivity())
if !strings.Contains(got, "1 char · 1 line") {
t.Errorf("footer = %q, want it to contain %q", got, "1 char · 1 line")
t.Errorf("activity = %q, want it to contain %q", got, "1 char · 1 line")
}
}

// TestFooterSelectionCountAfterCopy: after a copy (statusMsg carries "copied …")
// while the selection is still active, the footer prefixes the count with
// while the selection is still active, the activity line prefixes the count with
// "copied · " — the selection persists past the copy (Req 7). The selection is
// made via the REAL drag gesture so the identity snapshot matches and the
// copy→refreshView path KEEPS it (a manual SetContent would be overwritten by the
Expand All @@ -55,7 +55,7 @@ func TestFooterSelectionCountAfterCopy(t *testing.T) {
if !m.sel.active || m.sel.empty() {
t.Fatal("precondition: an active non-empty selection")
}
// Compute the count the footer should report from the model's own state.
// Compute the count the activity line should report from the model's own state.
chars := len([]rune(selectedText(m.vp.GetContent(), m.sel)))
startL, _, endL, _ := m.sel.normalize()
wantLines := endL - startL + 1
Expand All @@ -66,46 +66,46 @@ func TestFooterSelectionCountAfterCopy(t *testing.T) {
if !m.sel.active {
t.Fatal("selection must persist past a copy (Req 7)")
}
got := stripANSIstr(m.renderFooter())
got := stripANSIstr(m.renderActivity())
if !strings.Contains(got, want) {
t.Errorf("footer = %q, want it to contain %q", got, want)
t.Errorf("activity = %q, want it to contain %q", got, want)
}
}

// TestFooterNoSelectionShowsStatus: with no active selection the footer shows the
// TestFooterNoSelectionShowsStatus: with no active selection the activity line shows the
// existing statusMsg, or "ready" when it's empty.
func TestFooterNoSelectionShowsStatus(t *testing.T) {
m, _ := selModel(t)
m.sel = selection{} // inactive
m.phase = phaseIdle
m.statusMsg = ""
if got := stripANSIstr(m.renderFooter()); !strings.Contains(got, "ready") {
t.Errorf("footer = %q, want it to contain %q", got, "ready")
if got := stripANSIstr(m.renderActivity()); !strings.Contains(got, "ready") {
t.Errorf("activity = %q, want it to contain %q", got, "ready")
}
m.statusMsg = "connected"
if got := stripANSIstr(m.renderFooter()); !strings.Contains(got, "connected") {
t.Errorf("footer = %q, want it to contain %q", got, "connected")
if got := stripANSIstr(m.renderActivity()); !strings.Contains(got, "connected") {
t.Errorf("activity = %q, want it to contain %q", got, "connected")
}
}

// TestFooterSelectionCountSuppressedWhileRunning: a running phase owns the
// footer-left (spinner path), so even with an active selection the count is NOT
// activity line (spinner path), so even with an active selection the count is NOT
// shown — the count is idle/default-only by construction (Req 5).
func TestFooterSelectionCountSuppressedWhileRunning(t *testing.T) {
m, _ := selModel(t)
m.vp.SetContent("hello world\nsecond line\nthird row")
m.sel = selection{active: true, anchorL: 0, anchorC: 6, headL: 2, headC: 5}
m.phase = phaseRunning
got := stripANSIstr(m.renderFooter())
got := stripANSIstr(m.renderActivity())
if strings.Contains(got, "chars · ") {
t.Errorf("footer while running must NOT show the selection count, got %q", got)
t.Errorf("activity while running must NOT show the selection count, got %q", got)
}
}

// TestResultMsgStopReachesFooter is the end-to-end regression guard for the stop
// reason wiring (issue #81 Part 5): a terminal client.ResultMsg{Stop} must drive
// applyResult → endRun → renderfmt.StopReasonLabel → m.statusMsg, and the rendered footer
// must show the human label. It covers the explicit-mapped reasons and an unknown
// applyResult → endRun → renderfmt.StopReasonLabel → m.statusMsg, and the rendered activity
// line must show the human label. It covers the explicit-mapped reasons and an unknown
// passthrough. structured_output is now explicitly phrased ("stopped · schema
// unmet") so the raw underscore'd token never leaks even though it is a
// subagent-only stop that does not reach the main footer today.
Expand All @@ -125,9 +125,9 @@ func TestResultMsgStopReachesFooter(t *testing.T) {
m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()))
m.phase = phaseRunning
m = applyAll(m, client.ResultMsg{Stop: c.stop})
got := stripANSIstr(m.renderFooter())
got := stripANSIstr(m.renderActivity())
if !strings.Contains(got, c.want) {
t.Errorf("ResultMsg{Stop:%q} → footer = %q, want it to contain %q", c.stop, got, c.want)
t.Errorf("ResultMsg{Stop:%q} → activity = %q, want it to contain %q", c.stop, got, c.want)
}
}
}
52 changes: 32 additions & 20 deletions cmd/mecatui/ui/input_rail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,34 @@ import (
// match. Defined here because it is the regression guard for renderInputRail.
var plainTail = regexp.MustCompile(`\x1b\[0?m {1,}(?:\x1b\[0?m)*$`)

// TestInputRailAddsOnlyTopPadRow is the load-bearing layout invariant for the input
// TestInputRailAddsOnlyPadRows is the load-bearing layout invariant for the input
// mode-rail: the BorderLeft adds ZERO rows, and the ONLY vertical growth is the
// intentional inputRailPadTop top-padding row, so the rail-wrapped input is exactly
// bare + inputRailPadTop rows tall. The layout measures region heights via
// lipgloss.Height, so a height that matches this keeps regionInput/relayout correct; any
// OTHER drift would silently steal (or add) a viewport row.
func TestInputRailAddsOnlyTopPadRow(t *testing.T) {
// intentional top and bottom padding rows, so the rail-wrapped input is exactly
// bare + inputRailPadTop + inputRailPadBottom rows tall — whether empty or holding
// multi-row text. The layout measures region heights via lipgloss.Height, so a height
// that matches this keeps regionInput/relayout correct; any OTHER drift would silently
// steal (or add) a viewport row.
func TestInputRailAddsOnlyPadRows(t *testing.T) {
th := theme.New("aztec", theme.AztecPalette())
for _, w := range []int{40, 80, 120} {
m, _, _ := newTestModel(t, th)
m = applyAll(m, tea.WindowSizeMsg{Width: w, Height: 30},
client.SessionReadyMsg{SessionID: "sess-test-0001"})

bare := m.prompt.View()
railed := m.renderInput()
if got, want := lipgloss.Height(railed), lipgloss.Height(bare)+inputRailPadTop; got != want {
t.Errorf("width %d: rail input height = %d rows, want %d (bare %d + top pad %d)", w, got, want, lipgloss.Height(bare), inputRailPadTop)
for _, text := range []string{"", strings.Repeat("wrapped text ", 20)} {
m, _, _ := newTestModel(t, th)
m = applyAll(m, tea.WindowSizeMsg{Width: w, Height: 30},
client.SessionReadyMsg{SessionID: "sess-test-0001"})
m.prompt.Rewrite(text)
m.rend.inputValid = false

bare := m.prompt.View()
railed := m.renderInput()
pad := inputRailPadTop + inputRailPadBottom
if got, want := lipgloss.Height(railed), lipgloss.Height(bare)+pad; got != want {
t.Errorf("width %d, %d text rows: rail input height = %d rows, want %d (bare %d + pad %d)", w, m.prompt.Height(), got, want, lipgloss.Height(bare), pad)
}
// Typed text never consumes the bottom padding row.
rows := strings.Split(stripANSIstr(railed), "\n")
if last := strings.TrimLeft(rows[len(rows)-1], "│ "); last != "" {
t.Errorf("width %d, %d text rows: bottom pad row holds text %q", w, m.prompt.Height(), last)
}
}
}
}
Expand Down Expand Up @@ -81,13 +92,14 @@ func TestInputRailFillsUniformly(t *testing.T) {
if emptyW != typedW {
t.Errorf("width %d: empty (%d) and typed (%d) input widths differ — the tint must be uniform regardless of content", w, emptyW, typedW)
}
// Every textarea row is present and tinted, PLUS the one top-padding row
// (inputRailPadTop): height == ta.Height() + the pad. No zero-width empty rows.
if got, want := lipgloss.Height(empty), me.prompt.Height()+inputRailPadTop; got != want {
t.Errorf("width %d: empty input block height = %d rows, want %d (textarea rows + %d top pad)", w, got, want, inputRailPadTop)
// Every textarea row is present and tinted, PLUS the top and bottom padding rows:
// height == ta.Height() + the pads. No zero-width empty rows.
pad := inputRailPadTop + inputRailPadBottom
if got, want := lipgloss.Height(empty), me.prompt.Height()+pad; got != want {
t.Errorf("width %d: empty input block height = %d rows, want %d (textarea rows + %d pad)", w, got, want, pad)
}
if got, want := lipgloss.Height(typed), mt.prompt.Height()+inputRailPadTop; got != want {
t.Errorf("width %d: typed input block height = %d rows, want %d (textarea rows + %d top pad)", w, got, want, inputRailPadTop)
if got, want := lipgloss.Height(typed), mt.prompt.Height()+pad; got != want {
t.Errorf("width %d: typed input block height = %d rows, want %d (textarea rows + %d pad)", w, got, want, pad)
}
// EVEN tint: every row's fill must run flush to the right edge — NO trailing
// PLAIN (unstyled) cells. The bug's signature is a bare reset (\x1b[m / \x1b[0m)
Expand Down
Loading
Loading