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/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..fb751a0 --- /dev/null +++ b/internal/tui/events.go @@ -0,0 +1,430 @@ +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 + stream := false // high-frequency event: coalesce the render (see queueRender) + 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" + stream = true + + case "token": + if i := m.cur(); i >= 0 { + m.msgs[i].content += sanitize(ev.Content) + m.msgs[i].streaming = true + } + m.status = "responding" + stream = true + + 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.renderPending = false // the turn's final state renders now, not on a flush + 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.renderPending = false + 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.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 != "" { + m.addNote("server log Β· " + m.opts.LogPath) + } + m.refresh() + 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)) +} + +// 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..ec58548 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" @@ -92,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. @@ -162,53 +164,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 -} -// 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 + renderPending bool // a coalesced streaming render is scheduled + renderSeq int // bumped per scheduled flush, to drop stale ticks } // New builds the initial model. @@ -270,6 +228,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" @@ -326,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() @@ -358,20 +326,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 +336,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 +401,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 +423,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 +438,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 +448,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 +525,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 +539,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 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/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..635dac6 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" ) @@ -176,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") @@ -202,6 +206,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.