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
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,14 @@ feat(tui): compact tool steps with Ctrl+E details toggle
wrapped in untrusted-content markers: decode the envelope and fold the
wrappers away before display — don't render either verbatim.
- The transcript model in `internal/tui`: each assistant `message` keeps a
chronological `items []turnItem` timeline (reasoning blocks and step
references interleaved). Preserve arrival order; don't regress to a
single per-turn reasoning blob, and don't reintroduce a separate
in-transcript thinking placeholder alongside it.
chronological `items []turnItem` timeline (reasoning blocks, step
references, and reply segments interleaved — one think→reply cycle per
segment pair, each rendered independently). Preserve arrival order; don't
regress to a single per-turn reasoning blob or a single trailing answer
card, and don't reintroduce a separate in-transcript thinking placeholder
alongside it. `msg.content` stays the "\n\n"-joined blob of all reply
segments (appendReply maintains it) for export, stats, and hand-built
messages; turn markers (`**Cancelled.**` etc.) attach to the last reply.
- Events arrive from `internal/client` already in chronological order —
keep ingestion order-dependent and idempotent.
- `internal/tui` is split by responsibility: `model.go` holds the core
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ one `Esc`.
- **Security approvals** — odek's `danger` engine prompts surface as an inline
panel; your answer is sent straight back over the socket.
- **Live reasoning** — the model's pre-tool thinking streams in dimmed text,
with a running elapsed timer and cycling status while it works.
with a running elapsed timer and cycling status while it works. Long turns
keep every think→reply pair intact: each reasoning block is followed by its
own answer card, in arrival order.
- **Command palette (`/`)** and **file attachments (`@`)** — live, navigable popups.
- **Context-aware progress** — while the agent works, a status line just
above the input (right below your last message) shows what it's actually
Expand Down
102 changes: 73 additions & 29 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
case "token", "token_delta":
// token_delta is the live fragment stream (server --stream); with
// fragments delivered the server suppresses the bulk token re-send,
// so both event types must accumulate the same way.
// so both event types must accumulate the same way. Prose lands on
// the timeline as its own reply segment — appended to the open one,
// or opened fresh after reasoning/tools — so each think→reply cycle
// renders independently (appendReply keeps msg.content in sync).
if i := m.cur(); i >= 0 {
m.msgs[i].content += sanitize(ev.Content)
appendReply(&m.msgs[i], sanitize(ev.Content))
m.msgs[i].streaming = true
}
m.status = "responding"
Expand Down Expand Up @@ -210,7 +213,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
if cancelled {
markCancel(&m.msgs[i])
} else if m.msgs[i].content == "" {
m.msgs[i].content = "**Error:** " + ev.Message
setTurnMarker(&m.msgs[i], "**Error:** "+ev.Message)
} else {
m.addNote("error: " + ev.Message)
}
Expand Down Expand Up @@ -286,11 +289,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// forever. Idempotent — with no open turn this is a no-op, so a
// later resume is untouched.
if i := m.cur(); i >= 0 {
if m.msgs[i].content == "" {
m.msgs[i].content = "**Interrupted:** connection lost"
} else {
m.msgs[i].content += "\n\n**Interrupted:** connection lost"
}
setTurnMarker(&m.msgs[i], "**Interrupted:** connection lost")
}
m.finalize()
m.relayout() // the busy status line is gone with the socket
Expand Down Expand Up @@ -448,6 +447,44 @@ func (m *Model) cur() int {
return -1
}

// appendReply appends answer text to the turn's open reply segment, or
// starts a new one after a reasoning block or tool call — so every
// think→reply cycle renders independently. msg.content stays in sync for
// export, stats, and hand-built messages without a timeline; distinct
// cycles join it with a blank line so copied/exported prose stays readable.
func appendReply(msg *message, s string) {
if s == "" {
return
}
if n := len(msg.items); n > 0 && msg.items[n-1].reply {
msg.items[n-1].text += s
msg.content += s
return
}
if msg.content != "" {
msg.content += "\n\n"
}
msg.content += s
msg.items = append(msg.items, turnItem{reply: true, text: s})
}

// setTurnMarker closes out a turn with a bold status line ("**Cancelled.**",
// "**Interrupted:** …", "**Error:** …"): below the existing reply when the
// turn already produced prose, or as its only reply segment otherwise — the
// marker always renders attached to the final card.
func setTurnMarker(msg *message, marker string) {
if msg.content == "" {
appendReply(msg, marker)
return
}
msg.content += "\n\n" + marker
if n := len(msg.items); n > 0 && msg.items[n-1].reply {
msg.items[n-1].text += "\n\n" + marker
return
}
msg.items = append(msg.items, turnItem{reply: true, text: marker})
}

// isContextCanceled reports whether an error message is the abort a run's
// context returns once a cancel is honored ("context canceled", possibly
// wrapped by the provider client on its way out).
Expand All @@ -458,35 +495,42 @@ func isContextCanceled(msg string) bool {
// markCancel closes out a streaming turn as cancelled — the deliberate
// sibling of the interrupted marker a disconnect leaves behind.
func markCancel(msg *message) {
if msg.content == "" {
msg.content = "**Cancelled.**"
} else {
msg.content += "\n\n**Cancelled.**"
}
setTurnMarker(msg, "**Cancelled.**")
}

// finalize closes out the streaming assistant message, rendering its markdown.
// Reasoning blocks the renderer auto-opened for the live stream collapse here
// (the WebUI's accordion rule): the next turn starts with history folded, and
// only blocks the user opened themselves stay open.
// finalize closes out the streaming assistant message and drops the cursor.
func (m *Model) finalize() {
if i := m.cur(); i >= 0 {
m.msgs[i].streaming = false
m.msgs[i].rendered = m.render(m.msgs[i].content)
var thoughts []string
for j := range m.msgs[i].items {
if m.msgs[i].items[j].thinking {
thoughts = append(thoughts, m.msgs[i].items[j].text)
m.msgs[i].items[j].open = false
}
}
// Keep the turn's reasoning concatenated on the message for
// compatibility; the timeline (items) drives the actual rendering.
m.msgs[i].thinking = strings.Join(thoughts, "\n")
m.closeTurn(&m.msgs[i])
}
m.curIdx = -1
}

// closeTurn renders finalized markdown for one assistant turn: each reply
// segment goes through glamour individually (cached on the item, re-rendered
// only on resize), reasoning folds into msg.thinking, and reasoning blocks
// the renderer auto-opened for the live stream collapse here (the WebUI's
// accordion rule): the next turn starts with history folded, and only blocks
// the user opened themselves stay open. Shared by finalize() and the
// session-replay flush.
func (m *Model) closeTurn(msg *message) {
msg.streaming = false
var thoughts []string
for j := range msg.items {
switch {
case msg.items[j].thinking:
thoughts = append(thoughts, msg.items[j].text)
msg.items[j].open = false
case msg.items[j].reply:
msg.items[j].rendered = m.render(msg.items[j].text)
}
}
// Keep the turn's reasoning concatenated on the message for
// compatibility; the timeline (items) drives the actual rendering.
msg.thinking = strings.Join(thoughts, "\n")
msg.rendered = m.render(msg.content)
}

// addNote appends a sticky notice (errors, disconnects) that stays until
// pushed out by newer ones.
func (m *Model) addNote(s string) {
Expand Down
22 changes: 16 additions & 6 deletions internal/tui/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,11 +624,12 @@ func TestSessionDetailReplay(t *testing.T) {
if asst.role != roleAsst {
t.Fatalf("second message role = %v, want assistant", asst.role)
}
// Both assistant text parts join into the reply.
// Both assistant text parts join into the reply blob…
if asst.content != "I will inspect the file.\n\nFixed the bug." {
t.Errorf("assistant content = %q", asst.content)
}
// Reasoning folds into msg.thinking like finalize() does.
// …and each persisted record becomes its own reply segment on the
// timeline, mirroring live think→reply ingestion.
if asst.thinking != "let me look at the code\nfound it" {
t.Errorf("assistant thinking = %q", asst.thinking)
}
Expand All @@ -651,19 +652,28 @@ func TestSessionDetailReplay(t *testing.T) {
t.Errorf("step 1 result = %q (frame not stripped?)", s1.result)
}

// The timeline interleaves thinking → step → step → thinking, in order.
// The timeline interleaves think→reply pairs with tool steps in arrival
// order; reply segments cache their glamour render at flush time.
want := []turnItem{
{thinking: true, text: "let me look at the code"},
{reply: true, text: "I will inspect the file."},
{stepIdx: 0},
{stepIdx: 1},
{thinking: true, text: "found it"},
{reply: true, text: "Fixed the bug."},
}
if len(asst.items) != len(want) {
t.Fatalf("assistant items = %v, want %v", asst.items, want)
t.Fatalf("assistant items = %+v, want %+v", asst.items, want)
}
for i, w := range want {
if asst.items[i] != w {
t.Errorf("item %d = %+v, want %+v", i, asst.items[i], w)
got := asst.items[i]
if got.thinking != w.thinking || got.reply != w.reply || got.text != w.text || got.stepIdx != w.stepIdx {
t.Errorf("item %d = %+v, want %+v", i, got, w)
}
}
for i, it := range asst.items {
if it.reply && it.rendered == "" {
t.Errorf("reply segment %d has no cached render", i)
}
}

Expand Down
20 changes: 14 additions & 6 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,16 @@ type stepRef struct {
line int
}

// turnItem is one entry in a turn's chronological timeline: either a
// reasoning block or a tool call, in arrival order.
// turnItem is one entry in a turn's chronological timeline: a reasoning
// block, a tool call, or a response text segment (one think→reply cycle),
// in arrival order.
type turnItem struct {
thinking bool // false = tool step
text string // thinking excerpt when thinking (capped per block)
stepIdx int // index into msg.steps when !thinking
thinking bool // true = reasoning block
reply bool // true = response text segment
text string // thinking / reply text (stored in full)
stepIdx int // index into msg.steps when a tool call
open bool // reasoning: user wants the full block (live turns auto-open)
rendered string // cached glamour render (finalized reply segments)
}

// turnStats is the telemetry of one finalized assistant turn, captured from the
Expand Down Expand Up @@ -350,7 +353,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// keeps a phantom streaming assistant message with no reply. Same
// inline styling as a server-side error event.
if i := m.cur(); i >= 0 && m.msgs[i].content == "" {
m.msgs[i].content = "**Error:** " + msg.err.Error()
setTurnMarker(&m.msgs[i], "**Error:** "+msg.err.Error())
}
m.finalize()
m.relayout() // the busy status line releases its row
Expand Down Expand Up @@ -851,6 +854,11 @@ func (m *Model) resize(w, h int) tea.Cmd {
for i := range m.msgs {
if m.msgs[i].role == roleAsst && !m.msgs[i].streaming && !m.msgs[i].raw {
m.msgs[i].rendered = m.render(m.msgs[i].content)
for j := range m.msgs[i].items {
if m.msgs[i].items[j].reply {
m.msgs[i].items[j].rendered = m.render(m.msgs[i].items[j].text)
}
}
}
}
}
Expand Down
Loading