From 3853c62a5395310f41b065394c612af89ae5ca0c Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 27 Jul 2026 13:34:18 +0200 Subject: [PATCH 1/3] refactor(tui): split model.go into events, input, and approval files model.go had grown to 1268 lines mixing event ingestion, keyboard handling, autocomplete, and approvals. Move whole functions into events.go (client.Event handling, notices, step previews), input.go (completion popup state + key capture, prompt submit), and approval.go (approval key capture + answer). Pure refactor: no behavior change, tests pass unchanged. --- internal/tui/approval.go | 39 +++ internal/tui/events.go | 415 +++++++++++++++++++++++++ internal/tui/input.go | 236 ++++++++++++++ internal/tui/model.go | 655 +-------------------------------------- 4 files changed, 692 insertions(+), 653 deletions(-) create mode 100644 internal/tui/approval.go create mode 100644 internal/tui/events.go create mode 100644 internal/tui/input.go diff --git a/internal/tui/approval.go b/internal/tui/approval.go new file mode 100644 index 0000000..d56cc30 --- /dev/null +++ b/internal/tui/approval.go @@ -0,0 +1,39 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// handleApprovalKey answers the pending approval (or quits); any other key +// is swallowed while the panel waits for a decision. +func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "a", "y": + return m, m.answer("approve") + case "d", "n": + return m, m.answer("deny") + case "t": + if m.approval.AllowTrust { + return m, m.answer("trust") + } + case "ctrl+c": + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +func (m *Model) answer(action string) tea.Cmd { + id := m.approval.ID + m.approval = nil + m.status = "thinking" + m.relayout() + m.refresh() + cl := m.cl + return func() tea.Msg { + if err := cl.SendApproval(id, action); err != nil { + return errMsg{err} + } + return nil + } +} diff --git a/internal/tui/events.go b/internal/tui/events.go new file mode 100644 index 0000000..d6f10d4 --- /dev/null +++ b/internal/tui/events.go @@ -0,0 +1,415 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + "time" + "unicode" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// noticeTTL is how long transient info traces (skill / memory / signal / +// subagent) stay on screen before fading out. +const noticeTTL = 3 * time.Second + +// noticeExpireMsg fires noticeTTL after a transient notice was added. +type noticeExpireMsg struct { + seq int +} + +func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { + prevSeq := m.noticeSeq + switch ev.Type { + case "session": + m.sessionID = ev.SessionID + if ev.AuthToken != "" { + m.authToken = ev.AuthToken + m.tokens.Set(ev.SessionID, ev.AuthToken) + } + if ev.Model != "" { + m.model = ev.Model + m.resolveMaxContext() + } + m.sandbox = ev.Sandbox + + case "thinking": + // Append to the open reasoning block (the last timeline item when it is + // a thinking item), or start a new one after a tool call. + if i := m.cur(); i >= 0 { + msg := &m.msgs[i] + if n := len(msg.items); n > 0 && msg.items[n-1].thinking { + msg.items[n-1].text = capThinkingText(msg.items[n-1].text+sanitize(ev.Content), maxThinkingLen) + } else { + msg.items = append(msg.items, turnItem{thinking: true, + text: capThinkingText(sanitize(ev.Content), maxThinkingLen)}) + } + } + m.status = "thinking" + + case "token": + if i := m.cur(); i >= 0 { + m.msgs[i].content += sanitize(ev.Content) + m.msgs[i].streaming = true + } + m.status = "responding" + + case "tool_call": + arg := argPreview(ev.Data) + if i := m.cur(); i >= 0 { + m.msgs[i].steps = append(m.msgs[i].steps, + step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name), started: time.Now()}) + m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: len(m.msgs[i].steps) - 1}) + } + m.lastTool = ev.Name + m.lastArg = arg + m.status = "running " + ev.Name + + case "tool_result": + if i := m.cur(); i >= 0 { + steps := m.msgs[i].steps + for j := len(steps) - 1; j >= 0; j-- { + if steps[j].name == ev.Name && !steps[j].done { + steps[j].done = true + steps[j].result = resultPreview(ev.Data) + steps[j].isErr = looksLikeError(steps[j].result) + if !steps[j].started.IsZero() { + steps[j].dur = time.Since(steps[j].started) + } + break + } + } + } + m.lastTool = "" + m.lastArg = "" + + case "done": + // Capture per-turn telemetry from the live message BEFORE finalize() + // clears curIdx. + if i := m.cur(); i >= 0 { + wall := time.Duration(0) + if !m.runStart.IsZero() { + wall = time.Since(m.runStart) + } + thought := false + for _, it := range m.msgs[i].items { + if it.thinking { + thought = true + break + } + } + ts := turnStats{ + latency: ev.Latency, + wall: wall, + ctxTok: ev.ContextTokens, + outTok: ev.OutputTokens, + toolCount: len(m.msgs[i].steps), + toolGlyphs: stepGlyphs(m.msgs[i].steps), + thought: thought, + } + m.msgs[i].stats = &ts + m.turnStats = append(m.turnStats, ts) + m.toolTotal += ts.toolCount + } + m.finalize() + m.busy = false + m.lastTool = "" + m.lastArg = "" + m.status = "ready" + m.sessCtxTok = ev.SessionContextTokens + m.sessOutTok = ev.SessionOutputTokens + m.winCtxTok = ev.ContextTokens + m.lastLatency = ev.Latency + + case "error": + if i := m.cur(); i >= 0 && m.msgs[i].content == "" { + m.msgs[i].content = "**Error:** " + ev.Message + } else { + m.addNote("error: " + ev.Message) + } + m.finalize() + m.busy = false + m.lastTool = "" + m.lastArg = "" + m.status = "error" + + case "approval_request": + e := ev + m.approval = &e + m.status = "approval required" + m.relayout() // the panel is taller than the textarea — shrink the viewport + + case "skill_event": + m.addTransientNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev)) + case "memory_event": + m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev)) + case "agent_signal": + m.addTransientNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) + case "subagent_log": + line := strings.TrimSpace(ev.SubType + " " + ev.Name) + if d := collapse(ev.Detail); d != "" { + line = strings.TrimSpace(line + " · " + d) + } + line += eventTail(ev) + // Nest the log under the in-flight sub-agent step when there is one; + // otherwise (resumed turn, idle, or an unwrapped log) keep it as a notice. + if i := m.cur(); i >= 0 && m.attachSubLog(i, line) { + break + } + m.addTransientNote("subagent · " + line) + + case client.EventDisconnected: + m.disconn = true + m.busy = false + m.status = "disconnected" + m.addNote("disconnected from odek serve") + if m.opts.LogPath != "" { + m.addNote("server log · " + m.opts.LogPath) + } + m.refresh() + return m, nil + } + + m.refresh() + return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq)) +} + +// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in +// first-seen order, for the per-turn stat line. +func stepGlyphs(steps []step) []string { + const max = 4 + seen := make(map[string]bool, len(steps)) + out := make([]string, 0, max) + for _, s := range steps { + g := toolGlyph(s.name) + if seen[g] { + continue + } + seen[g] = true + out = append(out, g) + if len(out) == max { + break + } + } + return out +} + +// eventTail renders the optional ×count / #task-index suffix shared by the +// engine-event notices (skill / memory / signal / subagent). +func eventTail(ev client.Event) string { + s := "" + if ev.Count > 0 { + s += fmt.Sprintf(" ×%d", ev.Count) + } + if ev.TaskIdx > 0 { + s += fmt.Sprintf(" #%d", ev.TaskIdx) + } + return s +} + +// cur returns the index of the active streaming assistant message, or -1. +func (m *Model) cur() int { + if m.curIdx >= 0 && m.curIdx < len(m.msgs) { + return m.curIdx + } + return -1 +} + +// finalize closes out the streaming assistant message, rendering its markdown. +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) + // Keep the turn's reasoning concatenated on the message for + // compatibility; the timeline (items) drives the actual rendering. + var thoughts []string + for _, it := range m.msgs[i].items { + if it.thinking { + thoughts = append(thoughts, it.text) + } + } + m.msgs[i].thinking = strings.Join(thoughts, "\n") + } + m.curIdx = -1 +} + +// addNote appends a sticky notice (errors, disconnects) that stays until +// pushed out by newer ones. +func (m *Model) addNote(s string) { + m.pushNote(s, time.Time{}) +} + +// addTransientNote appends an info trace that fades after noticeTTL. +func (m *Model) addTransientNote(s string) { + m.pushNote(s, time.Now().Add(noticeTTL)) + m.noticeSeq++ +} + +func (m *Model) pushNote(s string, exp time.Time) { + m.notices = append(m.notices, sanitize(s)) + m.noticeExp = append(m.noticeExp, exp) + if len(m.notices) > 6 { + m.notices = m.notices[len(m.notices)-6:] + m.noticeExp = m.noticeExp[len(m.noticeExp)-6:] + } +} + +// pruneNotices drops transient notices whose expiry has passed. +func (m *Model) pruneNotices(now time.Time) { + kept := m.notices[:0] + keptExp := m.noticeExp[:0] + for i, n := range m.notices { + if exp := m.noticeExp[i]; exp.IsZero() || now.Before(exp) { + kept = append(kept, n) + keptExp = append(keptExp, exp) + } + } + m.notices = kept + m.noticeExp = keptExp +} + +// noticeTimer schedules the expiry sweep when a transient notice was added +// since prevSeq; otherwise it returns nil. +func (m *Model) noticeTimer(prevSeq int) tea.Cmd { + if m.noticeSeq == prevSeq { + return nil + } + seq := m.noticeSeq + return tea.Tick(noticeTTL, func(time.Time) tea.Msg { + return noticeExpireMsg{seq: seq} + }) +} + +// argPreview extracts a short, human-friendly summary from a tool's JSON args. +func argPreview(data string) string { + data = strings.TrimSpace(data) + if data == "" { + return "" + } + var m map[string]any + if err := json.Unmarshal([]byte(data), &m); err != nil { + return truncate(collapse(data), 72) + } + for _, key := range []string{ + "command", "cmd", "path", "file", "pattern", "query", "url", + "prompt", "task", "description", "instruction", + } { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok && s != "" { + return truncate(collapse(s), 72) + } + } + } + parts := make([]string, 0, len(m)) + for _, v := range m { + if s, ok := v.(string); ok && s != "" { + parts = append(parts, s) + } + } + return truncate(collapse(strings.Join(parts, " ")), 72) +} + +// resultPreview sanitizes tool output and caps it to a generous number of +// lines, so the transcript can show a useful excerpt (rendered by renderSteps) +// without retaining the unbounded output of a chatty tool. +func resultPreview(data string) string { + s := sanitize(data) + lines := strings.Split(s, "\n") + const cap = 200 + if len(lines) > cap { + lines = lines[:cap] + } + return strings.Join(lines, "\n") +} + +// isSubagent reports whether a tool name denotes a sub-agent delegation. The +// substrings mirror toolGlyph / toolProgress so the three stay consistent. +func isSubagent(name string) bool { + n := strings.ToLower(name) + return strings.Contains(n, "delegate") || + strings.Contains(n, "subagent") || + strings.Contains(n, "task") +} + +// looksLikeError reports whether a tool result reads as a failure. It is +// deliberately conservative — keyed off leading error tokens and a couple of +// unambiguous shell phrases — so ordinary output that merely mentions "error" +// is not tinted red. +func looksLikeError(s string) bool { + t := strings.ToLower(strings.TrimSpace(s)) + switch { + case strings.HasPrefix(t, "error"), + strings.HasPrefix(t, "fatal"), + strings.HasPrefix(t, "panic:"), + strings.HasPrefix(t, "traceback"), + strings.HasPrefix(t, "exception"), + strings.HasPrefix(t, "exit status"): + return true + } + return strings.Contains(t, "command not found") || + strings.Contains(t, "no such file or directory") +} + +// attachSubLog appends a sub-agent activity line to the most recent sub-agent +// step in message i, reporting whether one was found. +func (m *Model) attachSubLog(i int, line string) bool { + const maxSubLogs = 8 + steps := m.msgs[i].steps + for j := len(steps) - 1; j >= 0; j-- { + if !steps[j].subagent { + continue + } + if len(steps[j].logs) < maxSubLogs { + steps[j].logs = append(steps[j].logs, sanitize(line)) + } + return true + } + return false +} + +// maxThinkingLen caps the live "thinking…" excerpt so a verbose reasoning +// stream does not push the transcript off-screen. +const maxThinkingLen = 240 + +// capThinkingText trims s to at most n runes, starting at the next whitespace +// so the visible excerpt does not begin mid-word. +func capThinkingText(s string, n int) string { + if len(s) <= n { + return s + } + r := []rune(s) + if len(r) <= n { + return s + } + r = r[len(r)-n:] + for i, c := range r { + if unicode.IsSpace(c) { + r = r[i+1:] + break + } + } + return string(r) +} + +func collapse(s string) string { + return strings.Join(strings.Fields(sanitize(s)), " ") +} + +// stripToolResultFrame unwraps the delimiter frame odek adds around persisted +// tool results (a "┌── TOOL RESULT: …" header line and a matching +// "└── END TOOL RESULT: …" footer) for prompt-injection safety, returning the +// raw inner output. Live tool_result events carry the unframed output, so +// resume strips the frame to render both identically. Unframed input is +// returned unchanged. +func stripToolResultFrame(s string) string { + lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") + if len(lines) >= 3 && + strings.HasPrefix(lines[0], "┌── TOOL RESULT:") && + strings.HasPrefix(lines[len(lines)-1], "└── END TOOL RESULT:") { + return strings.Join(lines[1:len(lines)-1], "\n") + } + return s +} diff --git a/internal/tui/input.go b/internal/tui/input.go new file mode 100644 index 0000000..b50ffec --- /dev/null +++ b/internal/tui/input.go @@ -0,0 +1,236 @@ +package tui + +import ( + "regexp" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// handleACKey navigates/accepts/dismisses the completion popup (@ files and +// sessions, or / commands) while it has keyboard capture. +func (m *Model) handleACKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "ctrl+p": + if m.ac.sel > 0 { + m.ac.sel-- + m.refresh() + } + return m, nil + case "down", "ctrl+n": + if m.ac.sel < len(m.ac.items)-1 { + m.ac.sel++ + m.refresh() + } + return m, nil + case "tab": + m.acceptCompletion() + return m, nil + case "enter": + // A fully-typed command executes; a reference is inserted. + if m.ac.mode == acCmd { + return m, m.runSelectedCommand() + } + m.acceptCompletion() + return m, nil + case "esc": + m.closeAC() + return m, nil + } + return m, nil +} + +// acMode selects what the completion popup is completing. +type acMode int + +const ( + acRef acMode = iota // @-references (files/sessions), searched server-side + acCmd // slash commands, filtered locally +) + +// autocomplete holds the completion popup state (shared by @ and / modes). +type autocomplete struct { + open bool + loading bool + mode acMode + query string + items []client.Resource + sel int + seq int // request sequence, to drop stale responses +} + +// rows is the number of list rows the popup renders. +func (a autocomplete) rows() int { + if len(a.items) == 0 { + return 1 // "searching…" / "no matches" + } + return len(a.items) +} + +// height is the total rendered height of the popup (border + title + rows). +func (a autocomplete) height() int { + return a.rows() + 3 +} + +// acResultMsg carries the result of an async resource search. +type acResultMsg struct { + seq int + items []client.Resource +} + +func (m *Model) submit() tea.Cmd { + text := strings.TrimSpace(m.ta.Value()) + if text == "" { + return nil + } + // Slash commands run locally and are allowed even mid-turn (e.g. /cancel). + if strings.HasPrefix(text, "/") { + return m.runCommandLine(text) + } + if m.busy || m.disconn { + return nil + } + m.msgs = append(m.msgs, message{role: roleUser, content: text}) + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = len(m.msgs) - 1 + m.ta.Reset() + m.closeAC() + m.busy = true + m.status = "thinking" + m.runStart = time.Now() + if m.sessionStart.IsZero() { + m.sessionStart = m.runStart + } + m.refresh() + + thinking := "" + if m.thinkOn { + thinking = "enabled" + } + opts := client.PromptOpts{ + Thinking: thinking, + Model: m.pendModel, + SessionID: m.sessionID, + AuthToken: m.authToken, + } + m.pendModel = "" // applied + cl := m.cl + return func() tea.Msg { + if err := cl.SendPrompt(text, opts); err != nil { + return errMsg{err} + } + return nil + } +} + +// ── @-reference autocomplete ──────────────────────────────────────────────── + +// refRe matches a trailing @-reference token at the end of the input. +var refRe = regexp.MustCompile(`(^|\s)@([^\s@]*)$`) + +// activeRef returns the query of the trailing @-token, if the cursor is in one. +func activeRef(s string) (string, bool) { + mm := refRe.FindStringSubmatch(s) + if mm == nil { + return "", false + } + return mm[2], true +} + +// refStart returns the byte index of the '@' that begins the trailing token. +func refStart(s string) (int, bool) { + loc := refRe.FindStringSubmatchIndex(s) + if loc == nil { + return 0, false + } + return loc[4] - 1, true // group 2 start, minus the '@' +} + +// syncAC re-evaluates the input and drives the completion popup — slash +// commands (filtered locally) or @-references (searched server-side). +func (m *Model) syncAC() tea.Cmd { + val := m.ta.Value() + + // Line-initial slash command completion. + if name, ok := commandPrefix(val); ok { + if m.ac.open && m.ac.mode == acCmd && m.ac.query == name { + return nil + } + m.openCmdAC(name) + return nil + } + + q, ok := activeRef(val) + if !ok { + if m.ac.open { + m.closeAC() + } + return nil + } + if m.ac.open && m.ac.mode == acRef && q == m.ac.query { + return nil // nothing changed + } + m.ac.open = true + m.ac.loading = true + m.ac.mode = acRef + m.ac.query = q + m.ac.sel = 0 + m.ac.seq++ + seq := m.ac.seq + m.relayout() + m.refresh() + + cl := m.cl + return func() tea.Msg { + // @ is for file attachments only; sessions are reached via /sessions + // (or ^R). Over-fetch, then keep just files. + items, err := cl.Resources(q, 12) + if err != nil { + return acResultMsg{seq: seq, items: nil} + } + files := make([]client.Resource, 0, len(items)) + for _, it := range items { + if it.Type == "file" { + files = append(files, it) + } + } + if len(files) > 6 { + files = files[:6] + } + return acResultMsg{seq: seq, items: files} + } +} + +// acceptCompletion inserts the highlighted item into the input. +func (m *Model) acceptCompletion() { + if len(m.ac.items) == 0 { + m.closeAC() + return + } + item := m.ac.items[m.ac.sel] + if m.ac.mode == acCmd { + m.ta.SetValue(item.ID + " ") + m.ta.CursorEnd() + m.closeAC() + return + } + val := m.ta.Value() + if idx, ok := refStart(val); ok { + m.ta.SetValue(val[:idx] + item.ID + " ") + m.ta.CursorEnd() + } + m.closeAC() +} + +// closeAC dismisses the completion popup and restores the layout. +func (m *Model) closeAC() { + if !m.ac.open && m.ac.items == nil { + return + } + m.ac = autocomplete{seq: m.ac.seq} + m.relayout() + m.refresh() +} diff --git a/internal/tui/model.go b/internal/tui/model.go index da6fe01..777b468 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,12 +1,9 @@ package tui import ( - "encoding/json" "fmt" - "regexp" "strings" "time" - "unicode" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -164,53 +161,6 @@ type Model struct { stepLineIndex []stepRef // full transcript step index for mouse hit-testing } -// acMode selects what the completion popup is completing. -type acMode int - -const ( - acRef acMode = iota // @-references (files/sessions), searched server-side - acCmd // slash commands, filtered locally -) - -// autocomplete holds the completion popup state (shared by @ and / modes). -type autocomplete struct { - open bool - loading bool - mode acMode - query string - items []client.Resource - sel int - seq int // request sequence, to drop stale responses -} - -// rows is the number of list rows the popup renders. -func (a autocomplete) rows() int { - if len(a.items) == 0 { - return 1 // "searching…" / "no matches" - } - return len(a.items) -} - -// height is the total rendered height of the popup (border + title + rows). -func (a autocomplete) height() int { - return a.rows() + 3 -} - -// noticeTTL is how long transient info traces (skill / memory / signal / -// subagent) stay on screen before fading out. -const noticeTTL = 3 * time.Second - -// noticeExpireMsg fires noticeTTL after a transient notice was added. -type noticeExpireMsg struct { - seq int -} - -// acResultMsg carries the result of an async resource search. -type acResultMsg struct { - seq int - items []client.Resource -} - // New builds the initial model. func New(cl *client.Client, opts Options) *Model { th := newTheme() @@ -358,20 +308,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Approval mode captures the keyboard until answered. if m.approval != nil { - switch msg.String() { - case "a", "y": - return m, m.answer("approve") - case "d", "n": - return m, m.answer("deny") - case "t": - if m.approval.AllowTrust { - return m, m.answer("trust") - } - case "ctrl+c": - m.quitting = true - return m, tea.Quit - } - return m, nil + return m.handleApprovalKey(msg) } // A full-area panel (sessions / models) captures the keyboard while open. @@ -381,33 +318,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // The @-reference popup captures navigation keys while open. if m.ac.open { - switch msg.String() { - case "up", "ctrl+p": - if m.ac.sel > 0 { - m.ac.sel-- - m.refresh() - } - return m, nil - case "down", "ctrl+n": - if m.ac.sel < len(m.ac.items)-1 { - m.ac.sel++ - m.refresh() - } - return m, nil - case "tab": - m.acceptCompletion() - return m, nil - case "enter": - // A fully-typed command executes; a reference is inserted. - if m.ac.mode == acCmd { - return m, m.runSelectedCommand() - } - m.acceptCompletion() - return m, nil - case "esc": - m.closeAC() - return m, nil - } + return m.handleACKey(msg) } switch msg.String() { @@ -472,162 +383,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmd, m.syncAC()) } -func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { - prevSeq := m.noticeSeq - switch ev.Type { - case "session": - m.sessionID = ev.SessionID - if ev.AuthToken != "" { - m.authToken = ev.AuthToken - m.tokens.Set(ev.SessionID, ev.AuthToken) - } - if ev.Model != "" { - m.model = ev.Model - m.resolveMaxContext() - } - m.sandbox = ev.Sandbox - - case "thinking": - // Append to the open reasoning block (the last timeline item when it is - // a thinking item), or start a new one after a tool call. - if i := m.cur(); i >= 0 { - msg := &m.msgs[i] - if n := len(msg.items); n > 0 && msg.items[n-1].thinking { - msg.items[n-1].text = capThinkingText(msg.items[n-1].text+sanitize(ev.Content), maxThinkingLen) - } else { - msg.items = append(msg.items, turnItem{thinking: true, - text: capThinkingText(sanitize(ev.Content), maxThinkingLen)}) - } - } - m.status = "thinking" - - case "token": - if i := m.cur(); i >= 0 { - m.msgs[i].content += sanitize(ev.Content) - m.msgs[i].streaming = true - } - m.status = "responding" - - case "tool_call": - arg := argPreview(ev.Data) - if i := m.cur(); i >= 0 { - m.msgs[i].steps = append(m.msgs[i].steps, - step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name), started: time.Now()}) - m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: len(m.msgs[i].steps) - 1}) - } - m.lastTool = ev.Name - m.lastArg = arg - m.status = "running " + ev.Name - - case "tool_result": - if i := m.cur(); i >= 0 { - steps := m.msgs[i].steps - for j := len(steps) - 1; j >= 0; j-- { - if steps[j].name == ev.Name && !steps[j].done { - steps[j].done = true - steps[j].result = resultPreview(ev.Data) - steps[j].isErr = looksLikeError(steps[j].result) - if !steps[j].started.IsZero() { - steps[j].dur = time.Since(steps[j].started) - } - break - } - } - } - m.lastTool = "" - m.lastArg = "" - - case "done": - // Capture per-turn telemetry from the live message BEFORE finalize() - // clears curIdx. - if i := m.cur(); i >= 0 { - wall := time.Duration(0) - if !m.runStart.IsZero() { - wall = time.Since(m.runStart) - } - thought := false - for _, it := range m.msgs[i].items { - if it.thinking { - thought = true - break - } - } - ts := turnStats{ - latency: ev.Latency, - wall: wall, - ctxTok: ev.ContextTokens, - outTok: ev.OutputTokens, - toolCount: len(m.msgs[i].steps), - toolGlyphs: stepGlyphs(m.msgs[i].steps), - thought: thought, - } - m.msgs[i].stats = &ts - m.turnStats = append(m.turnStats, ts) - m.toolTotal += ts.toolCount - } - m.finalize() - m.busy = false - m.lastTool = "" - m.lastArg = "" - m.status = "ready" - m.sessCtxTok = ev.SessionContextTokens - m.sessOutTok = ev.SessionOutputTokens - m.winCtxTok = ev.ContextTokens - m.lastLatency = ev.Latency - - case "error": - if i := m.cur(); i >= 0 && m.msgs[i].content == "" { - m.msgs[i].content = "**Error:** " + ev.Message - } else { - m.addNote("error: " + ev.Message) - } - m.finalize() - m.busy = false - m.lastTool = "" - m.lastArg = "" - m.status = "error" - - case "approval_request": - e := ev - m.approval = &e - m.status = "approval required" - m.relayout() // the panel is taller than the textarea — shrink the viewport - - case "skill_event": - m.addTransientNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev)) - case "memory_event": - m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev)) - case "agent_signal": - m.addTransientNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) - case "subagent_log": - line := strings.TrimSpace(ev.SubType + " " + ev.Name) - if d := collapse(ev.Detail); d != "" { - line = strings.TrimSpace(line + " · " + d) - } - line += eventTail(ev) - // Nest the log under the in-flight sub-agent step when there is one; - // otherwise (resumed turn, idle, or an unwrapped log) keep it as a notice. - if i := m.cur(); i >= 0 && m.attachSubLog(i, line) { - break - } - m.addTransientNote("subagent · " + line) - - case client.EventDisconnected: - m.disconn = true - m.busy = false - m.status = "disconnected" - m.addNote("disconnected from odek serve") - if m.opts.LogPath != "" { - m.addNote("server log · " + m.opts.LogPath) - } - m.refresh() - return m, nil - } - - m.refresh() - return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq)) -} - // ── actions ────────────────────────────────────────────────────────────── // clearConversation wipes the transcript and the session-scoped telemetry, so @@ -650,66 +405,6 @@ func (m *Model) clearConversation() { m.refresh() } -func (m *Model) submit() tea.Cmd { - text := strings.TrimSpace(m.ta.Value()) - if text == "" { - return nil - } - // Slash commands run locally and are allowed even mid-turn (e.g. /cancel). - if strings.HasPrefix(text, "/") { - return m.runCommandLine(text) - } - if m.busy || m.disconn { - return nil - } - m.msgs = append(m.msgs, message{role: roleUser, content: text}) - m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) - m.curIdx = len(m.msgs) - 1 - m.ta.Reset() - m.closeAC() - m.busy = true - m.status = "thinking" - m.runStart = time.Now() - if m.sessionStart.IsZero() { - m.sessionStart = m.runStart - } - m.refresh() - - thinking := "" - if m.thinkOn { - thinking = "enabled" - } - opts := client.PromptOpts{ - Thinking: thinking, - Model: m.pendModel, - SessionID: m.sessionID, - AuthToken: m.authToken, - } - m.pendModel = "" // applied - cl := m.cl - return func() tea.Msg { - if err := cl.SendPrompt(text, opts); err != nil { - return errMsg{err} - } - return nil - } -} - -func (m *Model) answer(action string) tea.Cmd { - id := m.approval.ID - m.approval = nil - m.status = "thinking" - m.relayout() - m.refresh() - cl := m.cl - return func() tea.Msg { - if err := cl.SendApproval(id, action); err != nil { - return errMsg{err} - } - return nil - } -} - // ── helpers ──────────────────────────────────────────────────────────────── // resolveMaxContext sets m.maxContext from the active model's advertised @@ -725,39 +420,6 @@ func (m *Model) resolveMaxContext() { } } -// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in -// first-seen order, for the per-turn stat line. -func stepGlyphs(steps []step) []string { - const max = 4 - seen := make(map[string]bool, len(steps)) - out := make([]string, 0, max) - for _, s := range steps { - g := toolGlyph(s.name) - if seen[g] { - continue - } - seen[g] = true - out = append(out, g) - if len(out) == max { - break - } - } - return out -} - -// eventTail renders the optional ×count / #task-index suffix shared by the -// engine-event notices (skill / memory / signal / subagent). -func eventTail(ev client.Event) string { - s := "" - if ev.Count > 0 { - s += fmt.Sprintf(" ×%d", ev.Count) - } - if ev.TaskIdx > 0 { - s += fmt.Sprintf(" #%d", ev.TaskIdx) - } - return s -} - // fetchModels loads the advertised model list at startup so the context-window // gauge knows the active model's budget without the picker ever being opened. func (m *Model) fetchModels() tea.Cmd { @@ -768,79 +430,6 @@ func (m *Model) fetchModels() tea.Cmd { } } -// cur returns the index of the active streaming assistant message, or -1. -func (m *Model) cur() int { - if m.curIdx >= 0 && m.curIdx < len(m.msgs) { - return m.curIdx - } - return -1 -} - -// finalize closes out the streaming assistant message, rendering its markdown. -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) - // Keep the turn's reasoning concatenated on the message for - // compatibility; the timeline (items) drives the actual rendering. - var thoughts []string - for _, it := range m.msgs[i].items { - if it.thinking { - thoughts = append(thoughts, it.text) - } - } - m.msgs[i].thinking = strings.Join(thoughts, "\n") - } - m.curIdx = -1 -} - -// addNote appends a sticky notice (errors, disconnects) that stays until -// pushed out by newer ones. -func (m *Model) addNote(s string) { - m.pushNote(s, time.Time{}) -} - -// addTransientNote appends an info trace that fades after noticeTTL. -func (m *Model) addTransientNote(s string) { - m.pushNote(s, time.Now().Add(noticeTTL)) - m.noticeSeq++ -} - -func (m *Model) pushNote(s string, exp time.Time) { - m.notices = append(m.notices, sanitize(s)) - m.noticeExp = append(m.noticeExp, exp) - if len(m.notices) > 6 { - m.notices = m.notices[len(m.notices)-6:] - m.noticeExp = m.noticeExp[len(m.noticeExp)-6:] - } -} - -// pruneNotices drops transient notices whose expiry has passed. -func (m *Model) pruneNotices(now time.Time) { - kept := m.notices[:0] - keptExp := m.noticeExp[:0] - for i, n := range m.notices { - if exp := m.noticeExp[i]; exp.IsZero() || now.Before(exp) { - kept = append(kept, n) - keptExp = append(keptExp, exp) - } - } - m.notices = kept - m.noticeExp = keptExp -} - -// noticeTimer schedules the expiry sweep when a transient notice was added -// since prevSeq; otherwise it returns nil. -func (m *Model) noticeTimer(prevSeq int) tea.Cmd { - if m.noticeSeq == prevSeq { - return nil - } - seq := m.noticeSeq - return tea.Tick(noticeTTL, func(time.Time) tea.Msg { - return noticeExpireMsg{seq: seq} - }) -} - // render runs content through glamour; falls back to raw text on error. func (m *Model) render(content string) string { if m.glam == nil || strings.TrimSpace(content) == "" { @@ -918,115 +507,6 @@ func (m *Model) inputAreaHeight() int { return h } -// ── @-reference autocomplete ──────────────────────────────────────────────── - -// refRe matches a trailing @-reference token at the end of the input. -var refRe = regexp.MustCompile(`(^|\s)@([^\s@]*)$`) - -// activeRef returns the query of the trailing @-token, if the cursor is in one. -func activeRef(s string) (string, bool) { - mm := refRe.FindStringSubmatch(s) - if mm == nil { - return "", false - } - return mm[2], true -} - -// refStart returns the byte index of the '@' that begins the trailing token. -func refStart(s string) (int, bool) { - loc := refRe.FindStringSubmatchIndex(s) - if loc == nil { - return 0, false - } - return loc[4] - 1, true // group 2 start, minus the '@' -} - -// syncAC re-evaluates the input and drives the completion popup — slash -// commands (filtered locally) or @-references (searched server-side). -func (m *Model) syncAC() tea.Cmd { - val := m.ta.Value() - - // Line-initial slash command completion. - if name, ok := commandPrefix(val); ok { - if m.ac.open && m.ac.mode == acCmd && m.ac.query == name { - return nil - } - m.openCmdAC(name) - return nil - } - - q, ok := activeRef(val) - if !ok { - if m.ac.open { - m.closeAC() - } - return nil - } - if m.ac.open && m.ac.mode == acRef && q == m.ac.query { - return nil // nothing changed - } - m.ac.open = true - m.ac.loading = true - m.ac.mode = acRef - m.ac.query = q - m.ac.sel = 0 - m.ac.seq++ - seq := m.ac.seq - m.relayout() - m.refresh() - - cl := m.cl - return func() tea.Msg { - // @ is for file attachments only; sessions are reached via /sessions - // (or ^R). Over-fetch, then keep just files. - items, err := cl.Resources(q, 12) - if err != nil { - return acResultMsg{seq: seq, items: nil} - } - files := make([]client.Resource, 0, len(items)) - for _, it := range items { - if it.Type == "file" { - files = append(files, it) - } - } - if len(files) > 6 { - files = files[:6] - } - return acResultMsg{seq: seq, items: files} - } -} - -// acceptCompletion inserts the highlighted item into the input. -func (m *Model) acceptCompletion() { - if len(m.ac.items) == 0 { - m.closeAC() - return - } - item := m.ac.items[m.ac.sel] - if m.ac.mode == acCmd { - m.ta.SetValue(item.ID + " ") - m.ta.CursorEnd() - m.closeAC() - return - } - val := m.ta.Value() - if idx, ok := refStart(val); ok { - m.ta.SetValue(val[:idx] + item.ID + " ") - m.ta.CursorEnd() - } - m.closeAC() -} - -// closeAC dismisses the completion popup and restores the layout. -func (m *Model) closeAC() { - if !m.ac.open && m.ac.items == nil { - return - } - m.ac = autocomplete{seq: m.ac.seq} - m.relayout() - m.refresh() -} - // elapsed formats the current run's wall-clock time in whole seconds (the live // badge re-renders with every spinner tick, so tenths would flicker). The // finalized per-turn stat line keeps tenths via formatDuration. @@ -1041,137 +521,6 @@ func (m *Model) elapsed() string { return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) } -// argPreview extracts a short, human-friendly summary from a tool's JSON args. -func argPreview(data string) string { - data = strings.TrimSpace(data) - if data == "" { - return "" - } - var m map[string]any - if err := json.Unmarshal([]byte(data), &m); err != nil { - return truncate(collapse(data), 72) - } - for _, key := range []string{ - "command", "cmd", "path", "file", "pattern", "query", "url", - "prompt", "task", "description", "instruction", - } { - if v, ok := m[key]; ok { - if s, ok := v.(string); ok && s != "" { - return truncate(collapse(s), 72) - } - } - } - parts := make([]string, 0, len(m)) - for _, v := range m { - if s, ok := v.(string); ok && s != "" { - parts = append(parts, s) - } - } - return truncate(collapse(strings.Join(parts, " ")), 72) -} - -// resultPreview sanitizes tool output and caps it to a generous number of -// lines, so the transcript can show a useful excerpt (rendered by renderSteps) -// without retaining the unbounded output of a chatty tool. -func resultPreview(data string) string { - s := sanitize(data) - lines := strings.Split(s, "\n") - const cap = 200 - if len(lines) > cap { - lines = lines[:cap] - } - return strings.Join(lines, "\n") -} - -// isSubagent reports whether a tool name denotes a sub-agent delegation. The -// substrings mirror toolGlyph / toolProgress so the three stay consistent. -func isSubagent(name string) bool { - n := strings.ToLower(name) - return strings.Contains(n, "delegate") || - strings.Contains(n, "subagent") || - strings.Contains(n, "task") -} - -// looksLikeError reports whether a tool result reads as a failure. It is -// deliberately conservative — keyed off leading error tokens and a couple of -// unambiguous shell phrases — so ordinary output that merely mentions "error" -// is not tinted red. -func looksLikeError(s string) bool { - t := strings.ToLower(strings.TrimSpace(s)) - switch { - case strings.HasPrefix(t, "error"), - strings.HasPrefix(t, "fatal"), - strings.HasPrefix(t, "panic:"), - strings.HasPrefix(t, "traceback"), - strings.HasPrefix(t, "exception"), - strings.HasPrefix(t, "exit status"): - return true - } - return strings.Contains(t, "command not found") || - strings.Contains(t, "no such file or directory") -} - -// attachSubLog appends a sub-agent activity line to the most recent sub-agent -// step in message i, reporting whether one was found. -func (m *Model) attachSubLog(i int, line string) bool { - const maxSubLogs = 8 - steps := m.msgs[i].steps - for j := len(steps) - 1; j >= 0; j-- { - if !steps[j].subagent { - continue - } - if len(steps[j].logs) < maxSubLogs { - steps[j].logs = append(steps[j].logs, sanitize(line)) - } - return true - } - return false -} - -// maxThinkingLen caps the live "thinking…" excerpt so a verbose reasoning -// stream does not push the transcript off-screen. -const maxThinkingLen = 240 - -// capThinkingText trims s to at most n runes, starting at the next whitespace -// so the visible excerpt does not begin mid-word. -func capThinkingText(s string, n int) string { - if len(s) <= n { - return s - } - r := []rune(s) - if len(r) <= n { - return s - } - r = r[len(r)-n:] - for i, c := range r { - if unicode.IsSpace(c) { - r = r[i+1:] - break - } - } - return string(r) -} - -func collapse(s string) string { - return strings.Join(strings.Fields(sanitize(s)), " ") -} - -// stripToolResultFrame unwraps the delimiter frame odek adds around persisted -// tool results (a "┌── TOOL RESULT: …" header line and a matching -// "└── END TOOL RESULT: …" footer) for prompt-injection safety, returning the -// raw inner output. Live tool_result events carry the unframed output, so -// resume strips the frame to render both identically. Unframed input is -// returned unchanged. -func stripToolResultFrame(s string) string { - lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") - if len(lines) >= 3 && - strings.HasPrefix(lines[0], "┌── TOOL RESULT:") && - strings.HasPrefix(lines[len(lines)-1], "└── END TOOL RESULT:") { - return strings.Join(lines[1:len(lines)-1], "\n") - } - return s -} - // sanitize strips terminal control sequences from untrusted content before it // is rendered. Agent output — streamed tokens, tool results, file contents, // resumed transcripts — is attacker-influenced; raw C0 control bytes (notably From ffe79d11a0d649418082a7144ba362272e28572b Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 27 Jul 2026 13:38:48 +0200 Subject: [PATCH 2/3] perf(tui): coalesce streaming renders into one flush per 80ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token/thinking events used to rebuild the viewport — re-running glamour on the streaming tail — on every single event. They now share one coalesced flush (renderFlushMsg) per streamRenderInterval; done, error, and disconnect still render eagerly and drop any pending flush. --- internal/tui/events.go | 9 +++ internal/tui/model.go | 10 ++++ internal/tui/scroll_test.go | 3 + internal/tui/stream_test.go | 108 ++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 26 +++++++++ 5 files changed, 156 insertions(+) create mode 100644 internal/tui/stream_test.go diff --git a/internal/tui/events.go b/internal/tui/events.go index d6f10d4..ab15230 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -23,6 +23,7 @@ type noticeExpireMsg struct { func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { prevSeq := m.noticeSeq + stream := false // high-frequency event: coalesce the render (see queueRender) switch ev.Type { case "session": m.sessionID = ev.SessionID @@ -49,6 +50,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } } m.status = "thinking" + stream = true case "token": if i := m.cur(); i >= 0 { @@ -56,6 +58,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.msgs[i].streaming = true } m.status = "responding" + stream = true case "tool_call": arg := argPreview(ev.Data) @@ -114,6 +117,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.turnStats = append(m.turnStats, ts) m.toolTotal += ts.toolCount } + m.renderPending = false // the turn's final state renders now, not on a flush m.finalize() m.busy = false m.lastTool = "" @@ -130,6 +134,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } else { m.addNote("error: " + ev.Message) } + m.renderPending = false m.finalize() m.busy = false m.lastTool = "" @@ -164,6 +169,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case client.EventDisconnected: m.disconn = true m.busy = false + m.renderPending = false m.status = "disconnected" m.addNote("disconnected from odek serve") if m.opts.LogPath != "" { @@ -173,6 +179,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { return m, nil } + if stream { + return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.queueRender()) + } m.refresh() return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq)) } diff --git a/internal/tui/model.go b/internal/tui/model.go index 777b468..843242c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -159,6 +159,9 @@ type Model struct { convPrefixRefs []stepRef // step header line index for the cached prefix convCount int // messages the prefix covers (-1 = invalidated) stepLineIndex []stepRef // full transcript step index for mouse hit-testing + + renderPending bool // a coalesced streaming render is scheduled + renderSeq int // bumped per scheduled flush, to drop stale ticks } // New builds the initial model. @@ -220,6 +223,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, cmd + case renderFlushMsg: + if msg.seq == m.renderSeq && m.renderPending { + m.renderPending = false + m.refresh() + } + return m, nil + case errMsg: m.busy = false m.status = "error" diff --git a/internal/tui/scroll_test.go b/internal/tui/scroll_test.go index 9a21830..16c7a99 100644 --- a/internal/tui/scroll_test.go +++ b/internal/tui/scroll_test.go @@ -133,6 +133,7 @@ func TestBusyRefreshKeepsScrollback(t *testing.T) { m.busy = true m.runStart = time.Now() m.handleEvent(client.Event{Type: "token", Content: "streaming…"}) + m.Update(renderFlushMsg{seq: m.renderSeq}) // deliver the coalesced render if m.vp.YOffset != top { t.Errorf("busy refresh yanked scrollback: yoffset %d → %d", top, m.vp.YOffset) } @@ -140,6 +141,7 @@ func TestBusyRefreshKeepsScrollback(t *testing.T) { // Once back at the bottom, the reader follows the stream again. m.vp.GotoBottom() m.handleEvent(client.Event{Type: "token", Content: "more"}) + m.Update(renderFlushMsg{seq: m.renderSeq}) // deliver the coalesced render if !m.vp.AtBottom() { t.Error("at-bottom reader should follow the stream") } @@ -166,6 +168,7 @@ func TestTranscriptPrefixCached(t *testing.T) { prefix := m.convPrefix // A streaming tick re-renders only the tail; the prefix is untouched. m.handleEvent(client.Event{Type: "token", Content: "…"}) + m.Update(renderFlushMsg{seq: m.renderSeq}) // deliver the coalesced render if m.convPrefix != prefix { t.Error("streaming tick rebuilt the finalized prefix") } diff --git a/internal/tui/stream_test.go b/internal/tui/stream_test.go new file mode 100644 index 0000000..55bf00b --- /dev/null +++ b/internal/tui/stream_test.go @@ -0,0 +1,108 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// streamingTurn puts m mid-turn with one streaming assistant message. +func streamingTurn(m *Model) { + m.msgs = append(m.msgs, + message{role: roleUser, content: "q"}, + message{role: roleAsst, streaming: true}, + ) + m.curIdx = len(m.msgs) - 1 + m.busy = true + m.runStart = time.Now() +} + +// flush delivers the pending coalesced-render tick, as the event loop would. +func flush(t *testing.T, m *Model) { + t.Helper() + if !m.renderPending { + t.Fatal("flush: no coalesced render pending") + } + m.Update(renderFlushMsg{seq: m.renderSeq}) + if m.renderPending { + t.Fatal("flush: render still pending after flush") + } +} + +// TestStreamingRenderCoalesced: token/thinking events must not rebuild the +// viewport per event (the streaming tail is glamour re-rendered on every +// rebuild); they queue one flush per streamRenderInterval instead. +func TestStreamingRenderCoalesced(t *testing.T) { + m := newTestModel() + streamingTurn(m) + m.refresh() // baseline viewport: turn visible, no tokens yet + base := m.vp.View() + + m.handleEvent(client.Event{Type: "token", Content: "hello"}) + if !m.renderPending { + t.Fatal("token event should queue a coalesced render") + } + if vp := m.vp.View(); vp != base { + t.Error("token event re-rendered eagerly instead of coalescing") + } + + // More tokens while pending coalesce into the same flush. + m.handleEvent(client.Event{Type: "token", Content: " world"}) + m.handleEvent(client.Event{Type: "thinking", Content: "hmm"}) + seq := m.renderSeq + + // A stale flush (superseded sequence) is ignored. + stale := m.renderPending + m.Update(renderFlushMsg{seq: seq + 1}) + if m.renderPending != stale { + t.Error("stale flush changed render state") + } + + flush(t, m) + if vp := plain(m.vp.View()); !strings.Contains(vp, "hello world") { + t.Error("flushed render missing streamed tokens") + } +} + +// TestQueueRenderFlushCarriesSeq: the scheduled flush fires with the sequence +// it was scheduled under, and a second schedule while pending is a no-op. +func TestQueueRenderFlushCarriesSeq(t *testing.T) { + m := newTestModel() + cmd := m.queueRender() + if cmd == nil { + t.Fatal("first queueRender should schedule a flush") + } + if again := m.queueRender(); again != nil { + t.Error("queueRender while pending must not reschedule") + } + fm, ok := cmd().(renderFlushMsg) // blocks for streamRenderInterval + if !ok || fm.seq != m.renderSeq { + t.Errorf("flush = %#v, want renderFlushMsg{seq: %d}", fm, m.renderSeq) + } +} + +// TestNonStreamingEventsRenderImmediately: low-frequency events (tool calls, +// done, errors) still refresh eagerly — only the token/thinking firehose is +// coalesced. +func TestNonStreamingEventsRenderImmediately(t *testing.T) { + m := newTestModel() + streamingTurn(m) + m.refresh() + + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls"}`}) + if m.renderPending { + t.Error("tool_call should render immediately, not queue a flush") + } + if vp := plain(m.vp.View()); !strings.Contains(vp, "shell") { + t.Error("tool_call not visible without a flush") + } + + // The terminal event of a turn must never wait on a flush either. + m.handleEvent(client.Event{Type: "token", Content: "x"}) + m.handleEvent(client.Event{Type: "done"}) + if m.renderPending { + t.Error("done left a coalesced render pending") + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 8e2645a..0405698 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -5,6 +5,7 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -202,6 +203,31 @@ func (m *Model) statusBadge() string { // ── transcript ─────────────────────────────────────────────────────────── +// streamRenderInterval is the coalescing window for high-frequency streaming +// events (tokens, thinking): instead of rebuilding the viewport — which +// re-runs glamour on the streaming tail — per event, they share one rebuild. +const streamRenderInterval = 80 * time.Millisecond + +// renderFlushMsg fires streamRenderInterval after the first coalesced +// streaming event; a stale seq means a newer flush superseded it. +type renderFlushMsg struct { + seq int +} + +// queueRender schedules the coalesced streaming-render flush, or returns nil +// when one is already pending (all events since then share that flush). +func (m *Model) queueRender() tea.Cmd { + if m.renderPending { + return nil + } + m.renderPending = true + m.renderSeq++ + seq := m.renderSeq + return tea.Tick(streamRenderInterval, func(time.Time) tea.Msg { + return renderFlushMsg{seq: seq} + }) +} + // refresh rebuilds the viewport content and scrolls to the latest output only // when the reader is already at the bottom — a run in progress must not yank // them away from scrollback they are reading. From d7600bceb7cda04b9aa3a7bf3befe3433625cef4 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 27 Jul 2026 14:01:06 +0200 Subject: [PATCH 3/3] feat(tui): reconnect with backoff and resume the session after a socket drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dropped WebSocket used to be terminal: one _disconnected event and the TUI settled into a dead state even though the session (id + auth token) survives server-side. Now a drop schedules redials with exponential backoff (500ms-8s, 5 attempts) via a Reconnect hook wired in main. On success the client and event stream are swapped and the next prompt re-binds the session transparently; on exhaustion the previous terminal disconnected state (with the server log hint) is kept. The badge reads "reconnecting…" while retries are in flight. --- README.md | 3 + cmd/bodek/main.go | 3 + internal/tui/events.go | 6 ++ internal/tui/model.go | 8 ++ internal/tui/reconnect.go | 73 +++++++++++++++++ internal/tui/reconnect_test.go | 142 +++++++++++++++++++++++++++++++++ internal/tui/view.go | 3 + 7 files changed, 238 insertions(+) create mode 100644 internal/tui/reconnect.go create mode 100644 internal/tui/reconnect_test.go diff --git a/README.md b/README.md index 3c607a1..c17a525 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,9 @@ When the agent requests approval for a dangerous operation, answer inline: what it's actually doing (`🧪 running tests`, `📖 reading client.go`, `🚀 pushing`) with a live elapsed timer. - **Session browser** (`^R`) — resume, replay, or delete past conversations. +- **Auto-reconnect** — if the socket drops, bodek redials with backoff + (500ms → 8s, 5 attempts) and the session resumes transparently on your next + prompt; only a server that stays down leaves the `disconnected` badge. - **Model switcher** (`^O`) — change the model for the next turn. - **Cancellation** (`Esc`) — abort a running turn via odek's cancel API. - **Sandbox aware** — the header shows `🛡 sandboxed` or `⚠ host access`; pass diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 47b8b87..829cfc8 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -154,6 +154,9 @@ func run() error { LogPath: logPath, OdekVersion: srv.Version, Version: currentVersion(), + Reconnect: func() (*client.Client, error) { + return client.Dial(srv.WSURL, srv.Origin, srv.BaseURL, srv.Token) + }, }) // Mouse reporting enables wheel scrolling in the transcript, but it also diff --git a/internal/tui/events.go b/internal/tui/events.go index ab15230..fb751a0 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -170,6 +170,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.disconn = true m.busy = false m.renderPending = false + if cmd := m.scheduleReconnect(0); cmd != nil { + m.status = "reconnecting…" + m.addNote("connection lost — reconnecting…") + m.refresh() + return m, cmd + } m.status = "disconnected" m.addNote("disconnected from odek serve") if m.opts.LogPath != "" { diff --git a/internal/tui/model.go b/internal/tui/model.go index 843242c..ec58548 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -89,6 +89,11 @@ type Options struct { LogPath string // file the spawned server's stderr is captured to, if any OdekVersion string // engine version for the header (empty when attached/unknown) Version string // bodek's own version; drives the startup update check + + // Reconnect, when set, redials the server after the socket drops. The + // session resumes transparently: every prompt already carries + // session_id + auth_token, so the next send re-binds it server-side. + Reconnect func() (*client.Client, error) } // Model is the Bubble Tea model for bodek. @@ -286,6 +291,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case eventMsg: return m.handleEvent(client.Event(msg)) + case reconnectMsg: + return m.handleReconnect(msg) + case noticeExpireMsg: m.pruneNotices(time.Now()) m.refresh() diff --git a/internal/tui/reconnect.go b/internal/tui/reconnect.go new file mode 100644 index 0000000..df4e09b --- /dev/null +++ b/internal/tui/reconnect.go @@ -0,0 +1,73 @@ +package tui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// maxReconnectAttempts bounds how many redials follow a socket drop before +// the TUI settles into its terminal disconnected state. +const maxReconnectAttempts = 5 + +// reconnectMsg carries the outcome of one redial attempt. +type reconnectMsg struct { + attempt int + cl *client.Client + err error +} + +// reconnectBackoff delays attempt n by 500ms·2ⁿ, capped at 8s. +func reconnectBackoff(attempt int) time.Duration { + d := 500 * time.Millisecond << uint(attempt) + if d > 8*time.Second { + return 8 * time.Second + } + return d +} + +// scheduleReconnect runs one redial (via the Reconnect hook main wires in) +// after the attempt's backoff tick. Nil hook means reconnects are disabled. +func (m *Model) scheduleReconnect(attempt int) tea.Cmd { + hook := m.opts.Reconnect + if hook == nil { + return nil + } + return tea.Tick(reconnectBackoff(attempt), func(time.Time) tea.Msg { + cl, err := hook() + return reconnectMsg{attempt: attempt, cl: cl, err: err} + }) +} + +// handleReconnect applies a redial outcome: success swaps the client and +// re-arms the event stream; failure retries with backoff until the attempt +// budget is spent, then keeps the terminal disconnected state. +func (m *Model) handleReconnect(msg reconnectMsg) (tea.Model, tea.Cmd) { + if !m.disconn { + return m, nil // stale result (e.g. the user quit and restarted) + } + if msg.err == nil && msg.cl != nil { + m.cl = msg.cl + m.events = msg.cl.Events + m.disconn = false + m.status = "ready" + // Session continuity survives the drop: every prompt already carries + // session_id + auth_token, so the next send restores the session + // (including the server-side memory buffer) transparently. + m.addNote("reconnected to odek serve — the session resumes on your next prompt") + m.refresh() + return m, listen(m.events) + } + if msg.attempt+1 < maxReconnectAttempts { + return m, m.scheduleReconnect(msg.attempt + 1) + } + m.status = "disconnected" + m.addNote("reconnect failed — " + msg.err.Error()) + if m.opts.LogPath != "" { + m.addNote("server log · " + m.opts.LogPath) + } + m.refresh() + return m, nil +} diff --git a/internal/tui/reconnect_test.go b/internal/tui/reconnect_test.go new file mode 100644 index 0000000..a5688c7 --- /dev/null +++ b/internal/tui/reconnect_test.go @@ -0,0 +1,142 @@ +package tui + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// A disconnect with a Reconnect hook must schedule a redial instead of +// settling straight into the dead "disconnected" state. +func TestDisconnectSchedulesReconnect(t *testing.T) { + m := newTestModel() + m.opts.Reconnect = func() (*client.Client, error) { return nil, errors.New("down") } + + _, cmd := m.handleEvent(client.Event{Type: client.EventDisconnected}) + + if cmd == nil { + t.Fatal("expected a reconnect attempt to be scheduled") + } + if !strings.Contains(m.status, "reconnecting") { + t.Errorf("status = %q, want it to mention reconnecting", m.status) + } + if !m.disconn { + t.Error("input must stay blocked while reconnecting") + } +} + +// Without a Reconnect hook the disconnect keeps the terminal behavior. +func TestDisconnectWithoutHookStaysDead(t *testing.T) { + m := newTestModel() + + _, cmd := m.handleEvent(client.Event{Type: client.EventDisconnected}) + + if cmd != nil { + t.Error("no reconnect hook: expected no scheduled command") + } + if m.status != "disconnected" { + t.Errorf("status = %q, want disconnected", m.status) + } +} + +// A successful redial swaps the client, re-arms the event stream, and +// unblocks input. +func TestReconnectSuccess(t *testing.T) { + m := newTestModel() + m.disconn = true + m.status = "reconnecting…" + + ch := make(chan client.Event, 1) + cl := &client.Client{Events: ch} + _, cmd := m.Update(reconnectMsg{attempt: 0, cl: cl}) + + if m.disconn { + t.Error("should be connected again") + } + if m.cl != cl { + t.Error("client was not swapped") + } + if m.events != ch { + t.Error("event channel was not swapped") + } + if cmd == nil { + t.Error("event listener was not re-armed") + } + found := false + for _, n := range m.notices { + if strings.Contains(n, "reconnected") { + found = true + } + } + if !found { + t.Error("expected a reconnected note") + } +} + +// Failed redials retry with backoff until the attempt budget is exhausted, +// then settle into the terminal disconnected state. +func TestReconnectRetriesThenGivesUp(t *testing.T) { + m := newTestModel() + m.disconn = true + m.opts.Reconnect = func() (*client.Client, error) { return nil, errors.New("down") } + + _, cmd := m.Update(reconnectMsg{attempt: 0, err: errors.New("down")}) + if cmd == nil { + t.Fatal("an early failure should schedule the next attempt") + } + + _, cmd = m.Update(reconnectMsg{attempt: maxReconnectAttempts - 1, err: errors.New("down")}) + if cmd != nil { + t.Error("no more attempts once the budget is spent") + } + if m.status != "disconnected" { + t.Errorf("status = %q, want disconnected", m.status) + } + if !m.disconn { + t.Error("input must stay blocked after giving up") + } +} + +// A reconnect result arriving after the disconnect was already resolved is +// dropped, not applied. +func TestReconnectStaleResultIgnored(t *testing.T) { + m := newTestModel() // disconn == false + + _, cmd := m.Update(reconnectMsg{attempt: 0, cl: &client.Client{Events: make(chan client.Event)}}) + if cmd != nil { + t.Error("stale reconnect result must not re-arm the listener") + } + if m.cl != nil { + t.Error("stale reconnect result must not swap the client") + } +} + +// While a redial is in flight the badge must say so, not "disconnected". +func TestReconnectingBadge(t *testing.T) { + m := newTestModel() + m.disconn = true + m.status = "reconnecting…" + + badge := plain(m.statusBadge()) + if !strings.Contains(badge, "reconnecting") { + t.Errorf("badge = %q, want it to mention reconnecting", badge) + } + if strings.Contains(badge, "disconnected") { + t.Errorf("badge = %q, must not read disconnected mid-retry", badge) + } +} + +func TestReconnectBackoff(t *testing.T) { + if got := reconnectBackoff(0); got != 500*time.Millisecond { + t.Errorf("backoff(0) = %v, want 500ms", got) + } + if got := reconnectBackoff(1); got != time.Second { + t.Errorf("backoff(1) = %v, want 1s", got) + } + if got := reconnectBackoff(20); got != 8*time.Second { + t.Errorf("backoff(20) = %v, want the 8s cap", got) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 0405698..635dac6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -177,6 +177,9 @@ func (m *Model) statusBadge() string { th := m.th switch { case m.disconn: + if strings.HasPrefix(m.status, "reconnecting") { + return th.statusBusy.Render("● reconnecting…") + } return th.badgeDanger.Render("● disconnected") case m.approval != nil: return th.statusBusy.Render("⚠ approval required")