Compare commits

..

6 Commits

Author SHA1 Message Date
Ed Zynda b68b3dd0bf Fix usage widget startup visibility and stop-path updates 2026-03-27 18:21:11 +03:00
Ed Zynda 48521bf76d ui: drop unused tool args from spinner label formatter 2026-03-27 17:54:53 +03:00
Ed Zynda 16df3a738c ui: polish stream/tool tracking comments and event-loop notes 2026-03-27 17:51:41 +03:00
Ed Zynda 9d0b8c8cef ui: simplify stream rendering state and harden stream ticks 2026-03-27 17:49:45 +03:00
Ed Zynda d9326fcf21 fix: auto-initialize extension context in kit.New()
Extensions were being loaded automatically by SetupAgent but the context
was never initialized unless the SDK user explicitly called
SetExtensionContext. This left extensions with a zero-value Context where
all function fields are nil.

Now kit.New() automatically calls SetExtensionContext with minimal defaults
(CWD, Model, Interactive=false) when extensions are loaded. SDK users can
still call SetExtensionContext to override with richer implementations
(TUI callbacks, prompts, etc.).

Combined with the normalizeContext() safety net in the runner, extensions
are now guaranteed to work in SDK mode without explicit context wiring.
2026-03-27 15:54:56 +03:00
Ed Zynda 22c479277e fix: normalize nil Context function fields to no-ops in SetContext
Extensions running via the SDK (without a fully-wired SetExtensionContext
call) would panic with 'reflect.Value.Call: call of nil function' when
calling any ctx method like ctx.PrintBlock().

normalizeContext() now replaces every nil function field in Context with
a safe no-op stub before storing it in the runner, so extension handlers
can never crash on a missing callback regardless of how Kit is embedded.
2026-03-27 15:54:54 +03:00
8 changed files with 530 additions and 495 deletions
+64 -31
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"sync"
"sync/atomic"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
@@ -598,9 +599,10 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
defer unsub()
// Show spinner while the agent works.
@@ -620,8 +622,9 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
// Update usage tracker. If per-step usage was already recorded from
// StepUsageEvent callbacks, avoid double-counting totals.
a.updateUsageFromTurnResult(result, prompt, sawStepUsage.Load())
return result, nil
}
@@ -645,9 +648,10 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
defer unsub()
// Show spinner while the agent works.
@@ -702,8 +706,10 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for tracking).
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
// Update usage tracker (using last item's prompt for fallback estimation).
// If per-step usage was already recorded from StepUsageEvent callbacks,
// avoid double-counting totals.
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt, sawStepUsage.Load())
return result, nil
}
@@ -720,9 +726,10 @@ func (a *App) sendEvent(msg tea.Msg) {
}
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
// SDK events to tea.Msg events and dispatch them via sendFn. When stepUsageSeen
// is provided, it is set to true after any non-zero StepUsageEvent is observed.
// Returns an unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Bool) func() {
k := a.opts.Kit
var unsubs []func()
@@ -756,6 +763,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
})
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen)
}
}))
@@ -925,32 +934,56 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
}
}
// recordStepUsage applies token/cost usage reported for a completed step.
// Step usage events arrive even when a turn is later cancelled, so this keeps
// the usage widget accurate on all stop paths.
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool) {
hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0
if !hasUsage {
return
}
if stepUsageSeen != nil {
stepUsageSeen.Store(true)
}
if a.opts.UsageTracker == nil {
return
}
a.opts.UsageTracker.UpdateUsage(
int(ev.InputTokens),
int(ev.OutputTokens),
int(ev.CacheReadTokens),
int(ev.CacheWriteTokens),
)
// Keep context fill reasonably fresh during long/partial turns.
a.opts.UsageTracker.SetContextTokens(int(ev.InputTokens + ev.OutputTokens))
}
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
// configured UsageTracker. Called once per turn after the turn completes.
//
// Cost/token accumulation uses TotalUsage (sum across all tool-calling steps in
// the turn). Context-window fill uses FinalUsage.InputTokens only — that is the
// number of tokens sent to the model on the last API call, which equals the
// actual context window occupation (all accumulated messages + tool results).
// OutputTokens are not added here because they are the response length, not
// context fill.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
// When sawStepUsage is true, totals were already accumulated incrementally via
// StepUsageEvent callbacks; in that case this method only updates context fill.
// Otherwise it falls back to TotalUsage (or estimation) to keep costs/tokens
// visible for providers/modes that don't emit per-step usage.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string, sawStepUsage bool) {
if a.opts.UsageTracker == nil || result == nil {
return
}
// --- Accumulate cost/token totals for the session ---
if result.TotalUsage != nil && result.TotalUsage.InputTokens > 0 {
a.opts.UsageTracker.UpdateUsage(
int(result.TotalUsage.InputTokens),
int(result.TotalUsage.OutputTokens),
int(result.TotalUsage.CacheReadTokens),
int(result.TotalUsage.CacheCreationTokens),
)
} else {
// Provider didn't report token counts — fall back to character-based
// estimates so the footer shows something rather than nothing.
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
if !sawStepUsage {
if result.TotalUsage != nil && result.TotalUsage.InputTokens > 0 {
a.opts.UsageTracker.UpdateUsage(
int(result.TotalUsage.InputTokens),
int(result.TotalUsage.OutputTokens),
int(result.TotalUsage.CacheReadTokens),
int(result.TotalUsage.CacheCreationTokens),
)
} else {
// Provider didn't report token counts — fall back to character-based
// estimates so the footer shows something rather than nothing.
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
}
}
// --- Context window fill (drives the % bar) ---
+107
View File
@@ -7,6 +7,8 @@ import (
"testing"
"time"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -14,6 +16,47 @@ import (
// Helpers
// --------------------------------------------------------------------------
type usageUpdaterStub struct {
mu sync.Mutex
updateCalls int
estimateCalls int
contextCalls int
lastUpdateInput int
lastUpdateOutput int
lastUpdateCacheRead int
lastUpdateCacheWrite int
lastContextTokens int
lastEstimateInput string
lastEstimateOutput string
}
func (s *usageUpdaterStub) UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateCalls++
s.lastUpdateInput = inputTokens
s.lastUpdateOutput = outputTokens
s.lastUpdateCacheRead = cacheReadTokens
s.lastUpdateCacheWrite = cacheWriteTokens
}
func (s *usageUpdaterStub) EstimateAndUpdateUsage(inputText, outputText string) {
s.mu.Lock()
defer s.mu.Unlock()
s.estimateCalls++
s.lastEstimateInput = inputText
s.lastEstimateOutput = outputText
}
func (s *usageUpdaterStub) SetContextTokens(tokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.contextCalls++
s.lastContextTokens = tokens
}
// turnResult builds a minimal TurnResult with response text t.
func turnResult(t string) *kit.TurnResult {
return &kit.TurnResult{Response: t}
@@ -489,3 +532,67 @@ func TestQueueLength_reflects(t *testing.T) {
t.Fatalf("expected 3, got %d", got)
}
}
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
// recorded immediately (including context tokens) for stop-path correctness.
func TestRecordStepUsage_updatesTracker(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.recordStepUsage(kit.StepUsageEvent{
InputTokens: 120,
OutputTokens: 45,
CacheReadTokens: 5,
CacheWriteTokens: 2,
}, nil)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 120 || usage.lastUpdateOutput != 45 || usage.lastUpdateCacheRead != 5 || usage.lastUpdateCacheWrite != 2 {
t.Fatalf("unexpected usage update payload: in=%d out=%d cache_read=%d cache_write=%d",
usage.lastUpdateInput, usage.lastUpdateOutput, usage.lastUpdateCacheRead, usage.lastUpdateCacheWrite)
}
if usage.contextCalls != 1 {
t.Fatalf("expected 1 context token update, got %d", usage.contextCalls)
}
if usage.lastContextTokens != 165 {
t.Fatalf("expected context tokens 165, got %d", usage.lastContextTokens)
}
}
// TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen ensures we avoid
// double-counting totals once StepUsageEvent-based updates were already applied.
func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &fantasy.Usage{InputTokens: 456},
}, "prompt", true)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 0 {
t.Fatalf("expected no total usage update when sawStepUsage=true, got %d", usage.updateCalls)
}
if usage.estimateCalls != 0 {
t.Fatalf("expected no estimate update when sawStepUsage=true, got %d", usage.estimateCalls)
}
if usage.contextCalls != 1 || usage.lastContextTokens != 456 {
t.Fatalf("expected final context tokens=456, got calls=%d tokens=%d", usage.contextCalls, usage.lastContextTokens)
}
}
+79 -10
View File
@@ -349,7 +349,7 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
@@ -376,7 +376,7 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
got := c.streamContent.String()
want := "Hello, world!"
@@ -396,6 +396,7 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
c := newTestStream()
_, cmd := c.Update(app.ToolExecutionEvent{
ToolCallID: "call-exec-1",
ToolName: "exec_tool",
IsStarting: true,
})
@@ -403,8 +404,9 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
tools := c.activeToolDisplays()
if len(tools) != 1 || !strings.Contains(tools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", tools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -418,11 +420,13 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: false,
})
@@ -440,9 +444,9 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
@@ -455,19 +459,44 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
// TestStreamComponent_ParallelSameToolName_UsesToolCallID verifies finishing one
// tool call does not remove another concurrent call with the same tool name.
func TestStreamComponent_ParallelSameToolName_UsesToolCallID(t *testing.T) {
c := newTestStream()
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: true})
tools := c.activeToolDisplays()
if len(tools) != 2 {
t.Fatalf("expected 2 active read calls, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: false})
tools = c.activeToolDisplays()
if len(tools) != 1 {
t.Fatalf("expected 1 active read call after finishing one ID, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: false})
if len(c.activeToolDisplays()) != 0 {
t.Fatalf("expected no active tools after finishing both IDs, got %v", c.activeToolDisplays())
}
}
// --------------------------------------------------------------------------
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
// text when content is accumulated, and empty string when not.
@@ -621,3 +650,43 @@ func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
t.Fatal("current-gen tick should reschedule")
}
}
// TestStreamComponent_StaleFlushTick_Discarded verifies that flush ticks from a
// previous generation (e.g. pre-Reset) are ignored.
func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
c := newTestStream()
// Start a pending flush and capture its generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "old"})
staleGen := c.flushGeneration
if !c.flushPending {
t.Fatal("precondition: expected flushPending=true after first chunk")
}
// Reset should invalidate in-flight flush ticks.
c.Reset()
if c.flushGeneration == staleGen {
t.Fatal("expected flushGeneration to change after Reset")
}
// New content in a new generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "new"})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("expected pendingStream='new', got %q", got)
}
// Stale flush tick should be ignored.
c = sendStreamMsg(c, streamFlushTickMsg{generation: staleGen})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("stale flush tick should not commit pending stream, got %q", got)
}
// Current generation flush should commit.
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if got := c.pendingStream.String(); got != "" {
t.Fatalf("expected pendingStream empty after current flush, got %q", got)
}
if got := c.streamContent.String(); got != "new" {
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
}
}
+102 -374
View File
@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"strings"
"sync"
"time"
tea "charm.land/bubbletea/v2"
@@ -212,14 +211,6 @@ type StatusBarEntryData struct {
Priority int // lower = further left; built-in entries use 100-110
}
// historyEntry represents a single entry in the conversation history timeline.
// This replaces the scrollback buffer for alt-screen mode.
type historyEntry struct {
Kind string // user|assistant|tool|system|error|extension|startup
Content string // pre-rendered block string
Timestamp time.Time // when the entry was created
}
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible).
type UIVisibility struct {
@@ -444,28 +435,13 @@ type AppModel struct {
// flushed first, preserving chronological order.
pendingUserPrints []string
// History timeline fields (alt-screen mode)
// historyEntries is the timeline of completed conversation blocks.
// Each entry represents a user message, assistant response, tool result,
// system message, error, or extension output.
historyEntries []historyEntry
// historyOffset is the line offset for the history viewport scroll position.
// 0 means showing from the top, higher values scroll down.
historyOffset int
// historyFollow is true when the viewport is pinned to the bottom.
// When true, new entries automatically scroll into view.
// When the user scrolls up, this becomes false.
historyFollow bool
// historyRenderCache holds the last rendered history content.
// Used to avoid redundant re-rendering when history hasn't changed.
historyRenderCache string
// historyDirty is true when history has changed and cache needs rebuilding.
historyDirty bool
// scrollbackBuf collects rendered content during a single Update() call.
// All print helpers append here instead of returning tea.Println directly.
// The buffer is drained into a single atomic tea.Println at the end of
// each Update call via drainScrollback(). If the stream component has
// unflushed content, it is automatically prepended so that new messages
// always appear below the previous assistant response.
scrollbackBuf []string
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
@@ -607,8 +583,8 @@ type AppModel struct {
streamingBashStderr []string
// streamingBashMaxLines caps how many lines to accumulate to prevent memory issues.
streamingBashMaxLines int
// streamingMu protects the streaming bash output fields from concurrent access.
streamingMu sync.RWMutex
// streaming bash fields are only mutated/read from the Bubble Tea event loop
// (Update/View), so no mutex is required here.
// streamingBashCommand holds the command being executed for display as a header.
streamingBashCommand string
}
@@ -692,7 +668,6 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
cwd: opts.Cwd,
width: width,
height: height,
historyFollow: true, // start in follow mode (pinned to bottom)
}
// Store extension commands for dispatch.
@@ -967,6 +942,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
@@ -988,6 +964,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.printSystemMessage("Session switching not available.")
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case SessionSelectorCancelledMsg:
@@ -998,6 +975,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case SessionDeletedMsg:
// Session was deleted from picker — just show a message.
m.printSystemMessage(fmt.Sprintf("Deleted session: %s", msg.Name))
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
// ── Window resize ────────────────────────────────────────────────────────
@@ -1014,15 +992,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
// Adjust history scroll offset for new viewport size.
// - Follow mode: renderHistoryRegion will pin to bottom automatically.
// - Non-follow mode: preserve top-visible line by clamping offset to valid range.
if !m.historyFollow {
vis := m.uiVis()
availableHeight := m.calculateHistoryStreamHeight(vis, "")
maxOffset := m.historyMaxOffset(availableHeight)
m.historyOffset = clamp(m.historyOffset, 0, maxOffset)
}
// ── Keyboard input ───────────────────────────────────────────────────────
case tea.KeyPressMsg:
@@ -1156,66 +1125,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Batch(cmds...)
}
case "pgup", "pageup":
// Page up: scroll history viewport up by approximately one page.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
// Scroll by page height minus a few lines for context overlap.
scrollLines := max(historyHeight-2, 1)
m.scrollHistoryUp(scrollLines, historyHeight)
return m, tea.Batch(cmds...)
}
case "pgdown", "pagedown":
// Page down: scroll history viewport down by approximately one page.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
// Scroll by page height minus a few lines for context overlap.
scrollLines := max(historyHeight-2, 1)
m.scrollHistoryDown(scrollLines, historyHeight)
return m, tea.Batch(cmds...)
}
case "ctrl+home":
// Ctrl+Home: jump to top of history.
if m.state == stateInput || m.state == stateWorking {
m.scrollHistoryToTop()
return m, tea.Batch(cmds...)
}
case "ctrl+end":
// Ctrl+End: jump to bottom of history and re-enable follow mode.
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
m.scrollHistoryToBottom(historyHeight)
return m, tea.Batch(cmds...)
}
case "shift+up":
// Shift+Up: scroll history viewport up by one line.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
m.scrollHistoryUp(1, historyHeight)
return m, tea.Batch(cmds...)
}
case "shift+down":
// Shift+Down: scroll history viewport down by one line.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
m.scrollHistoryDown(1, historyHeight)
return m, tea.Batch(cmds...)
}
}
// Route key events to the focused child. Check for editor
@@ -1279,6 +1188,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1292,36 +1202,43 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/thinking":
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/theme":
if cmd := m.handleThemeCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/name":
if cmd := m.handleNameCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/export":
if cmd := m.handleExportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/import":
if cmd := m.handleImportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1460,9 +1377,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(msg.ToolArgs), &args); err == nil && args.Command != "" {
m.streamingMu.Lock()
m.streamingBashCommand = args.Command
m.streamingMu.Unlock()
}
}
@@ -1477,11 +1392,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Buffer tool result for scrollback.
m.printToolResult(msg)
// Clear streaming bash output since tool completed.
m.streamingMu.Lock()
m.streamingBashOutput = nil
m.streamingBashStderr = nil
m.streamingBashCommand = ""
m.streamingMu.Unlock()
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
@@ -1490,7 +1403,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ToolOutputEvent:
// Accumulate streaming bash output for display.
m.streamingMu.Lock()
if msg.IsStderr {
m.streamingBashStderr = append(m.streamingBashStderr, msg.Chunk)
// Cap stderr lines to prevent memory issues.
@@ -1504,7 +1416,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingBashOutput = m.streamingBashOutput[len(m.streamingBashOutput)-m.streamingBashMaxLines:]
}
}
m.streamingMu.Unlock()
case app.ToolCallContentEvent:
// In streaming mode this text was already delivered via StreamChunkEvents
@@ -1564,6 +1475,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
cmds = append(cmds, m.drainScrollback())
} else {
// Case 2: post-turn — defer so SpinnerEvent orders correctly.
m.pendingUserPrints = append(m.pendingUserPrints, m.steeringMessages...)
@@ -1758,6 +1670,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.printSystemMessage(fmt.Sprintf("Session shared!\n\n Viewer: %s\n Gist: %s", msg.viewerURL, msg.gistURL))
}
return m, m.drainScrollback()
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
@@ -1771,8 +1684,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "block":
m.printExtensionBlock(msg)
default:
// Raw extension output (no level specified).
m.appendHistoryEntry("extension", msg.Text)
m.appendScrollback(msg.Text)
}
default:
@@ -1787,40 +1699,33 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
// View implements tea.Model. It renders the stacked layout:
// history region + stream region + separator + [queued messages] + input region + status bar.
// stream region + separator + [queued messages] + input region + status bar.
// The status bar is always present (1 line) to avoid layout shifts.
// When the tree selector is active, it replaces the stream region.
func (m *AppModel) View() tea.View {
// Tree selector overlay replaces the normal layout.
if m.state == stateTreeSelector && m.treeSelector != nil {
v := m.treeSelector.View()
v.AltScreen = true
return v
return m.treeSelector.View()
}
// Model selector overlay replaces the normal layout.
if m.state == stateModelSelector && m.modelSelector != nil {
v := m.modelSelector.View()
v.AltScreen = true
return v
return m.modelSelector.View()
}
// Session selector overlay replaces the normal layout.
if m.state == stateSessionSelector && m.sessionSelector != nil {
v := m.sessionSelector.View()
v.AltScreen = true
return v
return m.sessionSelector.View()
}
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
v := tea.NewView(m.overlay.Render())
v.AltScreen = true
return v
return tea.NewView(m.overlay.Render())
}
vis := m.uiVis()
@@ -1850,24 +1755,6 @@ func (m *AppModel) View() tea.View {
parts = append(parts, headerView)
}
// Calculate available height for the combined history+stream region.
// This matches the calculation in distributeHeight().
historyStreamHeight := m.calculateHistoryStreamHeight(vis, inputView)
// Render history region (scrollable finalized content).
// Stream gets remaining height after history.
streamHeight := 0
if streamView != "" {
streamHeight = lipgloss.Height(streamView)
}
historyHeight := max(historyStreamHeight-streamHeight, 0)
historyView := m.renderHistoryRegion(historyHeight)
// Include history region if it has content.
if historyView != "" {
parts = append(parts, historyView)
}
// Only include the stream region when it has content. When idle the
// stream renders "" which JoinVertical would pad to a full-width blank
// line, inflating the view unnecessarily.
@@ -1906,50 +1793,7 @@ func (m *AppModel) View() tea.View {
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
v := tea.NewView(content)
v.AltScreen = true
return v
}
// calculateHistoryStreamHeight calculates the available height for the combined
// history+stream region. This mirrors the calculation in distributeHeight().
func (m *AppModel) calculateHistoryStreamHeight(vis UIVisibility, inputView string) int {
separatorLines := 1
if vis.HideSeparator {
separatorLines = 0
}
statusBarLines := 1
if vis.HideStatusBar {
statusBarLines = 0
}
var queuedLines int
if queuedView := m.renderQueuedMessages(); queuedView != "" {
queuedLines = lipgloss.Height(queuedView)
}
inputLines := 9 // fallback
if inputView != "" {
inputLines = lipgloss.Height(inputView)
}
var widgetLines int
if above := m.renderWidgetSlot("above"); above != "" {
widgetLines += lipgloss.Height(above)
}
if below := m.renderWidgetSlot("below"); below != "" {
widgetLines += lipgloss.Height(below)
}
var headerFooterLines int
if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" {
headerFooterLines += lipgloss.Height(headerView)
}
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
headerFooterLines += lipgloss.Height(footerView)
}
return max(m.height-separatorLines-widgetLines-headerFooterLines-queuedLines-inputLines-statusBarLines, 0)
return tea.NewView(content)
}
// --------------------------------------------------------------------------
@@ -1991,151 +1835,16 @@ func (m *AppModel) renderStream() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
// renderHistoryRegion renders the scrollable history viewport containing finalized
// conversation blocks. The history region shows completed user messages, assistant
// responses, tool results, system messages, errors, and extension output.
//
// The viewport is controlled by historyOffset (line offset from top) and historyFollow
// (whether to pin to bottom). When historyDirty is true, the render cache is rebuilt.
//
// Returns empty string if there are no history entries.
func (m *AppModel) renderHistoryRegion(availableHeight int) string {
if len(m.historyEntries) == 0 {
return ""
}
// Rebuild cache if dirty.
if m.historyDirty {
m.rebuildHistoryCache()
}
if m.historyRenderCache == "" {
return ""
}
// Split cache into lines for viewport windowing.
lines := strings.Split(m.historyRenderCache, "\n")
totalLines := len(lines)
// Handle follow mode: pin to bottom when new content arrives.
if m.historyFollow {
// Calculate offset to show the last availableHeight lines.
m.historyOffset = max(totalLines-availableHeight, 0)
}
// Clamp offset to valid range.
maxOffset := max(totalLines-availableHeight, 0)
m.historyOffset = clamp(m.historyOffset, 0, maxOffset)
// Extract visible window.
startLine := m.historyOffset
endLine := min(startLine+availableHeight, totalLines)
if startLine >= totalLines {
return ""
}
visibleLines := lines[startLine:endLine]
return strings.Join(visibleLines, "\n")
}
// rebuildHistoryCache rebuilds the rendered history content from historyEntries.
// This is called when historyDirty is true, typically after new entries are added.
func (m *AppModel) rebuildHistoryCache() {
if len(m.historyEntries) == 0 {
m.historyRenderCache = ""
m.historyDirty = false
return
}
var parts []string
for _, entry := range m.historyEntries {
if entry.Content != "" {
parts = append(parts, entry.Content)
}
}
m.historyRenderCache = strings.Join(parts, "\n")
m.historyDirty = false
}
// historyTotalLines returns the total number of lines in the history cache.
// Used for scroll calculations and follow-mode adjustments.
func (m *AppModel) historyTotalLines() int {
if m.historyRenderCache == "" {
return 0
}
return strings.Count(m.historyRenderCache, "\n") + 1
}
// historyMaxOffset returns the maximum valid scroll offset for the history viewport.
// This depends on the available height for the history region.
func (m *AppModel) historyMaxOffset(availableHeight int) int {
totalLines := m.historyTotalLines()
return max(totalLines-availableHeight, 0)
}
// scrollHistoryUp scrolls the history viewport up by the given number of lines.
// Disables follow-mode since the user is actively scrolling away from the bottom.
func (m *AppModel) scrollHistoryUp(lines int, availableHeight int) {
if lines <= 0 {
return
}
// Disable follow mode when user scrolls up.
m.historyFollow = false
// Decrease offset (scroll toward top).
m.historyOffset = max(m.historyOffset-lines, 0)
}
// scrollHistoryDown scrolls the history viewport down by the given number of lines.
// Re-enables follow-mode if the scroll position reaches the bottom.
func (m *AppModel) scrollHistoryDown(lines int, availableHeight int) {
if lines <= 0 {
return
}
maxOffset := m.historyMaxOffset(availableHeight)
// Increase offset (scroll toward bottom).
m.historyOffset = min(m.historyOffset+lines, maxOffset)
// Re-enable follow mode if we've scrolled to the bottom.
if m.historyOffset >= maxOffset {
m.historyFollow = true
}
}
// scrollHistoryToTop scrolls the history viewport to the very top.
// Disables follow-mode.
func (m *AppModel) scrollHistoryToTop() {
m.historyFollow = false
m.historyOffset = 0
}
// scrollHistoryToBottom scrolls the history viewport to the very bottom.
// Re-enables follow-mode so new content will be visible.
func (m *AppModel) scrollHistoryToBottom(availableHeight int) {
maxOffset := m.historyMaxOffset(availableHeight)
m.historyOffset = maxOffset
m.historyFollow = true
}
// isHistoryAtBottom returns true if the history viewport is at the bottom.
// Used to determine if follow-mode should be active.
func (m *AppModel) isHistoryAtBottom(availableHeight int) bool {
maxOffset := m.historyMaxOffset(availableHeight)
return m.historyOffset >= maxOffset
}
// renderStreamingBashOutput renders accumulated streaming bash output (stdout + stderr)
// below the LLM streaming text. Returns empty string if no bash output is present.
// Lines are truncated to the terminal width and capped to maxBashLines to prevent
// long-running commands from blowing up the TUI layout.
func (m *AppModel) renderStreamingBashOutput(theme Theme) string {
m.streamingMu.RLock()
stdoutLines := make([]string, len(m.streamingBashOutput))
copy(stdoutLines, m.streamingBashOutput)
stderrLines := make([]string, len(m.streamingBashStderr))
copy(stderrLines, m.streamingBashStderr)
command := m.streamingBashCommand
m.streamingMu.RUnlock()
if len(stdoutLines) == 0 && len(stderrLines) == 0 {
return ""
@@ -2495,37 +2204,30 @@ func (m *AppModel) renderQueuedMessages() string {
}
// --------------------------------------------------------------------------
// Print helpers — emit content to history timeline
// Print helpers — emit content to scrollback via tea.Println
// --------------------------------------------------------------------------
//
// These helpers render content and append it to historyEntries for alt-screen
// in-app rendering. The history timeline is rendered in View().
// printUserMessage renders a user message into the history timeline.
// printUserMessage renders a user message into the scrollback buffer.
func (m *AppModel) printUserMessage(text string) {
content := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendHistoryEntry("user", content)
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
}
// printAssistantMessage renders an assistant message into the history timeline.
// printAssistantMessage renders an assistant message into the scrollback buffer.
func (m *AppModel) printAssistantMessage(text string) {
if strings.TrimSpace(text) != "" {
content := m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content
m.appendHistoryEntry("assistant", content)
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
}
}
// printToolResult renders a tool result message into the history timeline.
// printToolResult renders a tool result message into the scrollback buffer.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
content := m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content
m.appendHistoryEntry("tool", content)
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
}
// printErrorResponse renders an error message into the history timeline.
// printErrorResponse renders an error message into the scrollback buffer.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
content := m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content
m.appendHistoryEntry("error", content)
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
}
}
@@ -2596,14 +2298,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
return nil
}
// printSystemMessage renders a system-level message into the history timeline.
// printSystemMessage renders a system-level message into the scrollback buffer.
func (m *AppModel) printSystemMessage(text string) {
content := m.renderer.RenderSystemMessage(text, time.Now()).Content
m.appendHistoryEntry("system", content)
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle into the history timeline.
// caller-chosen border color and optional subtitle into the scrollback buffer.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
@@ -2627,7 +2328,7 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
WithBorderColor(borderClr),
WithMarginBottom(1),
)
m.appendHistoryEntry("extension", rendered)
m.appendScrollback(rendered)
}
// handleExtensionCommand checks if the submitted text matches an extension-
@@ -2848,7 +2549,7 @@ func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
}
// printCompactResult renders the compaction summary in a styled block with
// a distinct border color and a stats subtitle into the history timeline.
// a distinct border color and a stats subtitle into the scrollback buffer.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
theme := GetTheme()
@@ -2871,12 +2572,13 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
WithBorderColor(theme.Secondary),
WithMarginBottom(1),
)
m.appendHistoryEntry("system", rendered)
m.appendScrollback(rendered)
}
// flushStreamContent moves rendered content from the stream component into the
// history timeline, then resets the stream. Called before tool calls
// (streaming completes before tools fire).
// scrollback buffer and resets the stream. Called before tool calls (streaming
// completes before tools fire). The actual tea.Println is deferred to
// drainScrollback() at the end of the Update cycle.
func (m *AppModel) flushStreamContent() {
if m.stream == nil {
return
@@ -2886,44 +2588,73 @@ func (m *AppModel) flushStreamContent() {
return
}
m.stream.Reset()
m.appendHistoryEntry("assistant", content)
m.appendScrollback(content)
}
// flushStreamAndPendingUserMessages moves the previous assistant response and
// any pending queued user messages into the history timeline. Called from
// any pending queued user messages into the scrollback buffer. Called from
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
// have been processed.
// have been processed. The actual tea.Println is deferred to drainScrollback().
func (m *AppModel) flushStreamAndPendingUserMessages() {
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
m.appendHistoryEntry("assistant", content)
m.appendScrollback(content)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendHistoryEntry("user", rendered)
m.appendScrollback(rendered)
}
m.pendingUserPrints = nil
}
// appendHistoryEntry adds a new entry to the history timeline.
// The entry will be rendered in the history viewport during View().
func (m *AppModel) appendHistoryEntry(kind, content string) {
if content == "" {
return
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.
func (m *AppModel) appendScrollback(content string) {
if content != "" {
m.scrollbackBuf = append(m.scrollbackBuf, content)
}
m.historyEntries = append(m.historyEntries, historyEntry{
Kind: kind,
Content: content,
Timestamp: time.Now(),
})
m.historyDirty = true
// In follow mode, new entries should keep the viewport pinned to bottom.
// The actual scroll adjustment happens in View() or a dedicated helper.
}
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
// the stream component has unflushed content, it is automatically prepended so
// that new messages always appear below the previous assistant response. When
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
// rows left after the view height shrinks. Returns nil if there is nothing to
// print.
func (m *AppModel) drainScrollback() tea.Cmd {
if len(m.scrollbackBuf) == 0 {
return nil
}
var parts []string
needsClear := false
// Auto-flush any stream content so it appears before new messages.
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
needsClear = true
}
}
parts = append(parts, m.scrollbackBuf...)
m.scrollbackBuf = m.scrollbackBuf[:0]
printCmd := tea.Println(strings.Join(parts, "\n"))
if needsClear {
return tea.Sequence(
printCmd,
func() tea.Msg { return tea.ClearScreen() },
)
}
return printCmd
}
// distributeHeight recalculates child component heights after a window resize,
@@ -3546,9 +3277,9 @@ func (m *AppModel) handleResumeCommand() tea.Cmd {
}
// renderSessionHistory walks the current session branch and renders all
// messages (user, assistant, tool calls/results) into the scrollback buffer
// and history timeline. This gives the user visual context of the conversation
// when resuming or importing a session. Call this after switchSession succeeds.
// messages (user, assistant, tool calls/results) into the scrollback buffer.
// This gives the user visual context of the conversation when resuming or
// importing a session. Call this after switchSession succeeds.
func (m *AppModel) renderSessionHistory() {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -3599,8 +3330,7 @@ func (m *AppModel) renderSessionHistory() {
case message.RoleUser:
text := msg.Content()
if text != "" {
content := m.renderer.RenderUserMessage(text, msg.CreatedAt).Content
m.appendHistoryEntry("user", content)
m.appendScrollback(m.renderer.RenderUserMessage(text, msg.CreatedAt).Content)
}
case message.RoleAssistant:
@@ -3610,8 +3340,7 @@ func (m *AppModel) renderSessionHistory() {
if msg.Model != "" {
modelName = msg.Model
}
content := m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content
m.appendHistoryEntry("assistant", content)
m.appendScrollback(m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content)
}
// Tool calls from assistant messages are rendered when we
// encounter their corresponding tool results below.
@@ -3626,8 +3355,7 @@ func (m *AppModel) renderSessionHistory() {
}
toolArgs = info.Args
}
content := m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content
m.appendHistoryEntry("tool", content)
m.appendScrollback(m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content)
}
}
}
@@ -4000,7 +3728,7 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
WithMarginBottom(1),
)
m.appendHistoryEntry("system", rendered)
m.appendScrollback(rendered)
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
+31 -29
View File
@@ -543,63 +543,66 @@ func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
}
}
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
// to the history timeline. (Previously checked for tea.Println, now verifies
// history entry was added.)
// TestSubmitMsg_printsUserMessage verifies that submitMsg produces a tea.Println
// cmd for the user message.
func TestSubmitMsg_printsUserMessage(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
initialLen := len(m.historyEntries)
m = sendMsg(m, submitMsg{Text: "user query"})
_, cmd := m.Update(submitMsg{Text: "user query"})
// User message should be added to pending prints, then flushed on next spinner event.
// For now, just verify the model handles submitMsg without error.
// Full history verification is covered in TAS-29.
_ = initialLen
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for user message on submitMsg")
}
}
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent handles
// stream content appropriately. (Previously checked for tea.Println flush,
// now verifies history entry handling.)
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent flushes
// accumulated stream content but does NOT print a tool call block (the unified
// block is printed later on ToolResultEvent).
func TestToolCallStarted_flushesOnly(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
// With no stream content, should handle gracefully.
m = sendMsg(m, app.ToolCallStartedEvent{
// With no stream content, flush returns nil → cmd should be nil.
_, cmd := m.Update(app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
// With stream content, should flush to history.
if cmd != nil {
t.Fatal("expected nil cmd on ToolCallStartedEvent with no stream content")
}
// With stream content, flush returns tea.Println → cmd should be non-nil.
stream.renderedContent = "partial text"
initialLen := len(m.historyEntries)
m = sendMsg(m, app.ToolCallStartedEvent{
_, cmd = m.Update(app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
// Stream content should be flushed to history entries.
// Full history verification is covered in TAS-29.
_ = initialLen
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolCallStartedEvent with stream content to flush")
}
}
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
// the tool result to history and the stream receives a SpinnerEvent.
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent produces
// a non-nil cmd and the stream receives a SpinnerEvent.
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, app.ToolResultEvent{
_, cmd := m.Update(app.ToolResultEvent{
ToolName: "bash",
ToolArgs: "{}",
Result: "output",
IsError: false,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolResultEvent")
}
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
if stream.lastMsg == nil {
t.Fatal("expected stream to receive SpinnerEvent after ToolResultEvent")
@@ -737,18 +740,17 @@ func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
}
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
// adds the error to history. (Previously checked for tea.Println.)
// produces a non-nil cmd (the tea.Println call for the error message).
func TestStepError_printCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
initialLen := len(m.historyEntries)
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
_, cmd := m.Update(app.StepErrorEvent{Err: errors.New("agent failed")})
// Error should be added to history entries.
// Full history verification is covered in TAS-29.
_ = initialLen
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) on StepErrorEvent with error")
}
}
// --------------------------------------------------------------------------
+88 -47
View File
@@ -79,7 +79,12 @@ func streamSpinnerTickCmd(generation uint64) tea.Cmd {
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
type streamFlushTickMsg struct{}
//
// generation ties the tick to the pending flush session that created it so
// stale ticks from a prior Reset() are discarded.
type streamFlushTickMsg struct {
generation uint64
}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
@@ -89,9 +94,9 @@ const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd() tea.Cmd {
func streamFlushTickCmd(generation uint64) tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{}
return streamFlushTickMsg{generation: generation}
})
}
@@ -149,9 +154,11 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// activeTools maps ToolCallID -> display label for currently running tools.
activeTools map[string]string
// activeToolOrder preserves deterministic display order for active tools.
activeToolOrder []string
// streamContent holds committed streaming text (flushed from pending).
streamContent strings.Builder
@@ -172,6 +179,10 @@ type StreamComponent struct {
// the same coalescing window.
flushPending bool
// flushGeneration is incremented when stream state resets so stale flush
// ticks from a previous step can be discarded.
flushGeneration uint64
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
@@ -190,14 +201,8 @@ type StreamComponent struct {
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
// compactRenderer renders assistant messages in compact mode.
compactRenderer *CompactRenderer
// compactMode selects which renderer to use.
compactMode bool
// renderer renders streaming assistant text in either compact or standard mode.
renderer Renderer
// modelName is displayed in the streaming text header.
modelName string
@@ -218,13 +223,19 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo
if width == 0 {
width = 80
}
var renderer Renderer
if compactMode {
renderer = NewCompactRenderer(width, false)
} else {
renderer = newMessageRenderer(width, false)
}
return &StreamComponent{
spinnerFrames: knightRiderFrames(),
compactMode: compactMode,
modelName: modelName,
messageRenderer: newMessageRenderer(width, false),
compactRenderer: NewCompactRenderer(width, false),
width: width,
spinnerFrames: knightRiderFrames(),
modelName: modelName,
renderer: renderer,
width: width,
}
}
@@ -251,11 +262,13 @@ func (s *StreamComponent) Reset() {
s.spinnerGeneration++ // invalidate any in-flight tick commands
s.spinnerFrame = 0
s.activeTools = nil
s.activeToolOrder = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.flushGeneration++
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
@@ -323,8 +336,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
if s.renderer != nil {
s.renderer.SetWidth(s.width)
}
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
@@ -360,6 +374,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case streamFlushTickMsg:
if msg.generation != s.flushGeneration {
break
}
s.flushPending = false
s.commitPending()
@@ -374,7 +391,7 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.StreamChunkEvent:
@@ -389,14 +406,25 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.ToolExecutionEvent:
toolID := msg.ToolCallID
if toolID == "" {
// Defensive fallback for older/third-party emitters that may omit
// ToolCallID. Best-effort only: same-name+args concurrent calls can
// still collide without a stable ID.
toolID = fmt.Sprintf("%s|%s", msg.ToolName, msg.ToolArgs)
}
if msg.IsStarting {
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
if s.activeTools == nil {
s.activeTools = make(map[string]string)
}
if _, exists := s.activeTools[toolID]; !exists {
s.activeToolOrder = append(s.activeToolOrder, toolID)
}
s.activeTools[toolID] = formatToolExecutionMessage(msg.ToolName)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -405,9 +433,10 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
} else {
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
if s.activeTools != nil {
delete(s.activeTools, toolID)
}
s.activeToolOrder = removeToolID(s.activeToolOrder, toolID)
}
}
@@ -568,7 +597,8 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if len(s.activeTools) == 0 {
tools := s.activeToolDisplays()
if len(tools) == 0 {
return " " + frame
}
theme := GetTheme()
@@ -578,10 +608,10 @@ func (s *StreamComponent) SpinnerView() string {
// Format active tools list
var toolsMsg string
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
if len(tools) == 1 {
toolsMsg = tools[0]
} else {
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
toolsMsg = "Running: " + strings.Join(tools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
@@ -593,28 +623,39 @@ func (s *StreamComponent) renderStreamingText(text string) string {
if ts.IsZero() {
ts = time.Now()
}
if s.compactMode {
msg := s.compactRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
if s.renderer == nil {
return text
}
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
msg := s.renderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
func (s *StreamComponent) activeToolDisplays() []string {
if len(s.activeTools) == 0 {
return nil
}
out := make([]string, 0, len(s.activeToolOrder))
for _, id := range s.activeToolOrder {
if display, ok := s.activeTools[id]; ok {
out = append(out, display)
}
}
return slice
return out
}
// removeToolID removes the first occurrence of a tool ID from a slice.
func removeToolID(ids []string, id string) []string {
for i, v := range ids {
if v == id {
return append(ids[:i], ids[i+1:]...)
}
}
return ids
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
// For spawn_subagent, it shows simply as "Subagent".
func formatToolExecutionMessage(toolName string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
-4
View File
@@ -151,10 +151,6 @@ func (ut *UsageTracker) RenderUsageInfo() string {
ut.mu.RLock()
defer ut.mu.RUnlock()
if ut.sessionStats.RequestCount == 0 {
return ""
}
baseStyle := lipgloss.NewStyle()
// Display the current context window token count (from the last API call),
+59
View File
@@ -67,3 +67,62 @@ func TestUsageTracker_RenderUsageInfo_OAuth(t *testing.T) {
t.Errorf("Expected regular rendered output to show actual cost, got: %s", regularRendered)
}
}
func TestUsageTracker_RenderUsageInfo_StartupState(t *testing.T) {
// Create a mock model info with costs and context limit
modelInfo := &models.ModelInfo{
ID: "claude-3-5-sonnet-20241022",
Name: "Claude 3.5 Sonnet v2",
Cost: models.Cost{
Input: 3.0,
Output: 15.0,
},
Limit: models.Limit{
Context: 200000,
Output: 8192,
},
}
// Test startup state (no requests made yet) - Regular API key
regularTracker := NewUsageTracker(modelInfo, "anthropic", 80, false)
rendered := stripAnsi(regularTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if rendered == "" {
t.Errorf("Expected non-empty output on startup, got empty string")
}
// Should show 0 tokens
if !strings.Contains(rendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup, got: %s", rendered)
}
// Should NOT show percentage when tokens are 0
if strings.Contains(rendered, "(%") {
t.Errorf("Expected no percentage on startup with 0 tokens, got: %s", rendered)
}
// Should show $0.0000 cost for regular API key
if !strings.Contains(rendered, "Cost: $0.0000") {
t.Errorf("Expected 'Cost: $0.0000' on startup, got: %s", rendered)
}
// Test startup state (no requests made yet) - OAuth
oauthTracker := NewUsageTracker(modelInfo, "anthropic", 80, true)
oauthRendered := stripAnsi(oauthTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if oauthRendered == "" {
t.Errorf("Expected non-empty output on startup for OAuth, got empty string")
}
// Should show 0 tokens for OAuth
if !strings.Contains(oauthRendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup for OAuth, got: %s", oauthRendered)
}
// Should show $0.00 cost for OAuth
if !strings.Contains(oauthRendered, "Cost: $0.00") {
t.Errorf("Expected 'Cost: $0.00' on startup for OAuth, got: %s", oauthRendered)
}
}