diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 613ae96..007dbdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,12 @@ permissions: jobs: build-test: - name: Build & Test - runs-on: ubuntu-latest + name: Build & Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -31,14 +35,38 @@ jobs: run: go test -race -coverpkg=./internal/... -coverprofile=coverage.out ./... - name: Coverage summary + if: matrix.os == 'ubuntu-latest' run: go tool cover -func=coverage.out | tail -1 - name: Upload coverage profile + if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v7 with: name: coverage path: coverage.out + # Windows ships in every release yet never ran in CI. The server and tui + # suites lean on tty/process behaviour that may not hold on windows-latest, + # so the full suite lands there only after a green probe run — not before. + # Until then this leg gates compile + vet per GOOS. + windows-build: + name: Build & Vet (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + lint: name: Lint runs-on: ubuntu-latest diff --git a/README.md b/README.md index 2fb087c..d698471 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,9 @@ be written by hand and seed the matching flag defaults. Resolution order: | `^C` | Quit | Prompts sent while a turn is running are **queued** and sent automatically -when the turn ends — the footer shows how many are waiting. While the -transcript is scrolled up mid-run, the footer flags `↓ new output`; press +when the turn ends — a transient note acknowledges each hold, and the count +rides both the busy status line and the footer (one drains per turn-end). +While the transcript is scrolled up mid-run, the footer flags `↓ new output`; press `^G` to jump to the latest. If the connection drops, bodek retries with backoff and, after giving up, keeps your draft and offers a manual retry on `⏎` with an empty input. @@ -298,7 +299,7 @@ one `Esc`. - **Context-aware progress** — while the agent works, a status line just above the input (right below your last message) shows what it's actually doing (`🧪 running tests`, `📖 reading client.go`, `🚀 pushing`) with a live - elapsed timer. + elapsed timer and the queued-prompt count when prompts are held. - **Session browser** (`^R`) — resume, replay, delete, pin (`p`), rename (`r`), export a transcript (`e` markdown, `E` JSON), and search server-side (`/`); `n` loads the next page. Resuming sends a `session_switch` so the diff --git a/internal/tui/input.go b/internal/tui/input.go index f3f32c1..f7cb573 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -121,13 +121,15 @@ func (m *Model) submit() tea.Cmd { } if m.busy { // Queue mid-turn prompts instead of dropping them; the queue drains - // automatically when the running turn ends. + // automatically when the running turn ends. Acknowledge the hold — + // the input clearing silently reads as a lost message (same + // rationale as the disconnected-draft warning below). m.queue = append(m.queue, text) m.ta.Reset() m.closeAC() m.refresh() m.vp.GotoBottom() // Enter means "show me the latest", even mid-turn - return nil + return m.transientNoteCmd("queued — it sends when the turn ends") } m.ta.Reset() m.closeAC() diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go index e91ab57..c5a2c1f 100644 --- a/internal/tui/promptflow_test.go +++ b/internal/tui/promptflow_test.go @@ -153,7 +153,12 @@ func TestSubmitWhileBusyQueues(t *testing.T) { busyTurn(m) m.ta.SetValue("follow up") - if cmd := m.submit(); cmd != nil { + // Queueing returns the acknowledgment note's sweep cmd — but nothing is + // dispatched: no prompt send, no transcript pair. + if cmd := m.submit(); cmd == nil { + t.Error("queueing should acknowledge with the note-sweep cmd") + } + if m.lastPrompt == "follow up" { t.Error("queueing a prompt should not send anything yet") } if len(m.queue) != 1 || m.queue[0] != "follow up" { diff --git a/internal/tui/queue_visibility_test.go b/internal/tui/queue_visibility_test.go new file mode 100644 index 0000000..530c817 --- /dev/null +++ b/internal/tui/queue_visibility_test.go @@ -0,0 +1,68 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestQueuedCountOnStatusLine verifies the in-flight status row — the line +// the eyes are on while a turn runs — carries the queue depth, and that the +// count clears once the turn ends and the queue drains into the next turn. +func TestQueuedCountOnStatusLine(t *testing.T) { + m := newTestModel() + busyTurn(m) + + for _, p := range []string{"first follow-up", "second follow-up"} { + m.ta.SetValue(p) + m.submit() + } + if line := plain(m.statusLine()); !strings.Contains(line, "2 queued") { + t.Errorf("status line missing queued count: %q", line) + } + if foot := plain(m.footer()); !strings.Contains(foot, "2 queued") { + t.Errorf("footer missing queued count: %q", foot) + } + + // Each turn-end drains exactly one queued prompt: the count steps down + // until the queue is empty, then disappears from the row. + m.handleEvent(client.Event{Type: "done", Latency: 1}) + if len(m.queue) != 1 { + t.Fatalf("one done should drain one prompt, queue = %v", m.queue) + } + if line := plain(m.statusLine()); !strings.Contains(line, "1 queued") { + t.Errorf("status line should show the remaining prompt: %q", line) + } + m.handleEvent(client.Event{Type: "done", Latency: 1}) + if len(m.queue) != 0 { + t.Fatalf("queue should be empty now, got %v", m.queue) + } + if line := plain(m.statusLine()); strings.Contains(line, "queued") { + t.Errorf("status line still shows a queue after the drain: %q", line) + } +} + +// TestQueuedPromptAcknowledged verifies a mid-turn ⏎ tells the user the +// prompt was held — the input clearing with zero feedback reads as a lost +// message (same rationale as the disconnected-draft warning and the +// retry-queued note). +func TestQueuedPromptAcknowledged(t *testing.T) { + m := newTestModel() + busyTurn(m) + + m.ta.SetValue("follow up") + if cmd := m.submit(); cmd == nil { + t.Error("queueing should return the notice-sweep cmd so the note can fade") + } + if n := len(m.notices); n == 0 { + t.Fatal("no acknowledgment note after queueing a prompt mid-turn") + } + if got := m.notices[len(m.notices)-1]; !strings.Contains(got, "queued") { + t.Errorf("acknowledgment note = %q, want it to mention the queue", got) + } + // The acknowledgment is not a dispatch: nothing was sent. + if m.lastPrompt == "follow up" { + t.Error("queueing must not dispatch the prompt") + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 5877f9c..0ffd7d4 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -291,6 +291,12 @@ func (m *Model) statusLine() string { if e := m.elapsed(); e != "" { el = th.headerMeta.Render(" · " + e) } + // Held prompts ride the same row: mid-turn ⏎ queues invisibly, so the + // count shows where the eyes already are (mirrors the footer indicator). + q := "" + if n := len(m.queue); n > 0 { + q = th.acDetail.Render(fmt.Sprintf(" · %d queued", n)) + } // Live plan strip (docs/PLANNING_MODE_UI.md §4B): rides the same row, // silent unless a run is active AND a plan exists — absence costs zero // pixels. Bounded to a short label so small terminals keep the row sane. @@ -300,7 +306,7 @@ func (m *Model) statusLine() string { } // A blank row above separates the indicator from the transcript tail — // inputAreaHeight accounts for it so the layout math stays exact. - return "\n" + th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el + strip + return "\n" + th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el + q + strip } // statusLineVisible reports whether the status line occupies a row, keeping