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
32 changes: 30 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion internal/tui/promptflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
68 changes: 68 additions & 0 deletions internal/tui/queue_visibility_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 7 additions & 1 deletion internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down