From 4bffe31481623de7b67246540d18f90a22ecffcd Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 13:41:10 +0200 Subject: [PATCH 1/2] feat(tui): acknowledge queued prompts and show the count on the status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-turn ⏎ queued the prompt silently — the input cleared with zero feedback, which reads as a lost message. A transient note now acknowledges each hold (same wording as the retry path), and the queue depth rides the busy status line, next to the spinner where the eyes already are. One turn-end drains exactly one held prompt; the count steps down until empty. The footer indicator predates this; tests cover both surfaces and the drain decrements. --- README.md | 7 +-- internal/tui/input.go | 6 ++- internal/tui/promptflow_test.go | 7 ++- internal/tui/queue_visibility_test.go | 68 +++++++++++++++++++++++++++ internal/tui/view.go | 8 +++- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 internal/tui/queue_visibility_test.go 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 From 1c2c589dac5c335a8d39147c86afe8de3d1311ab Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 13:41:10 +0200 Subject: [PATCH 2/2] ci: run the race suite on ubuntu and macos, gate windows on build+vet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Darwin and windows binaries ship in every release while CI only ever ran ubuntu. The test job gains a macos leg (the suite passes locally on arm64), coverage artifacts stay ubuntu-only to avoid name collisions, and a windows-build leg gates compile + vet per GOOS. The full suite lands on windows only after a green probe run — the server and tui suites lean on tty/process behaviour that may not hold there. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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