Compare commits

...
Author SHA1 Message Date
anandh8x 0fd0bc5cee fix(tui): exclude permission waits from TTFT 2026-07-30 00:13:43 +05:30
anandh8x 12eedef920 fix(tui): exclude permission waits from turn time 2026-07-29 19:00:45 +05:30
3 changed files with 228 additions and 23 deletions

No files matched your search

+41 -20
View File
@@ -221,6 +221,9 @@ type model struct {
// renders the live elapsed time from it so a long or stalled turn never looks
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
// turnTimer is shared with the agent command so both the live status and the
// settled "worked for" duration exclude time blocked on a user permission.
turnTimer *activeTurnTimer
// lastCharTime tracks when the last non-Enter key was received, for paste detection.
lastCharTime time.Time
// lastKeyTime tracks every keypress timestamp for burst calculation.
@@ -3502,7 +3505,7 @@ func (m model) workingStatusLine() string {
// (reasoning, waiting on the model, or running a tool).
line += zeroTheme.faint.Render(" · " + m.workingActivity())
if !m.turnStartedAt.IsZero() {
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.now().Sub(m.turnStartedAt)))
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.activeTurnElapsed(m.turnStartedAt)))
}
// Live token estimate so the working line visibly climbs as the model reasons
// and writes, instead of a static figure. Shown from the start of the turn (at
@@ -3587,7 +3590,7 @@ const quietWorkingHint = 8 * time.Second
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
if m.activeRunID == 0 || m.pendingPermission != nil {
return ""
}
last := m.lastStreamActivity
@@ -4136,6 +4139,10 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
})
}
m.pendingPermission = nil
// Time spent at the prompt is user wait, not provider silence. Restart the
// quiet-generation clock so resuming a long-blocked run does not immediately
// claim the model has been inactive for the entire approval interval.
m.lastStreamActivity = m.now()
return m, nil
}
@@ -4859,6 +4866,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
// suppressed by a stale preference.
m.sidebarHidden = false
m.turnStartedAt = m.now()
m.turnTimer = newActiveTurnTimer(m.turnStartedAt)
m.lastStreamActivity = m.turnStartedAt
m.turnStreamedRunes = 0
m.spinnerTicking = true
@@ -5039,9 +5047,21 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
return func() tea.Msg {
started := m.now()
// firstTokenAt is stamped when the first token (reasoning or text) streams,
// so the turn can report time-to-first-token alongside total wall time.
var firstTokenAt time.Time
if m.turnTimer != nil {
m.turnTimer.start(started)
}
// firstTokenElapsed is stamped from the pause-aware turn timer when the
// first reasoning or text token streams, so TTFT and total elapsed use
// the same clock.
var firstTokenElapsed time.Duration
firstTokenSeen := false
stampFirstToken := func(at time.Time) {
if firstTokenSeen {
return
}
firstTokenSeen = true
firstTokenElapsed = m.activeTurnElapsedAt(started, at)
}
toolCalls := 0
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
@@ -5157,11 +5177,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
onText := options.OnText
options.OnText = func(delta string) {
if firstTokenAt.IsZero() {
firstTokenAt = m.now()
}
now := m.now()
stampFirstToken(now)
if strings.TrimSpace(reasoningText) != "" {
flushReasoning(m.now())
flushReasoning(now)
}
m.sendAgentText(runID, delta)
if onText != nil {
@@ -5178,6 +5197,12 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
onPermissionRequest := options.OnPermissionRequest
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
if m.turnTimer != nil {
m.turnTimer.pause(m.now())
defer func() {
m.turnTimer.resume(m.now())
}()
}
if onPermissionRequest != nil {
return onPermissionRequest(ctx, request)
}
@@ -5255,8 +5280,8 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
onReasoning := options.OnReasoning
options.OnReasoning = func(delta string) {
now := m.now()
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
firstTokenAt = now
if strings.TrimSpace(delta) != "" {
stampFirstToken(now)
}
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
reasoningStarted = now
@@ -5481,7 +5506,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
@@ -5491,17 +5516,13 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
flushReasoning(m.now())
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
ttft := time.Duration(0)
if !firstTokenAt.IsZero() {
ttft = firstTokenAt.Sub(started)
}
elapsed := m.activeTurnElapsed(started)
rows = append(rows, transcriptRow{
kind: rowAssistant,
text: result.FinalAnswer,
@@ -5519,7 +5540,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: firstTokenElapsed}
}
}
+94 -3
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -19,9 +20,10 @@ import (
)
type scriptedProvider struct {
scripts [][]zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
calls int
scripts [][]zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
beforeCall func(int)
calls int
}
func (provider *scriptedProvider) StreamCompletion(
@@ -39,6 +41,9 @@ func (provider *scriptedProvider) StreamCompletion(
if index >= len(provider.scripts) {
index = len(provider.scripts) - 1
}
if provider.beforeCall != nil {
provider.beforeCall(index)
}
ch := make(chan zeroruntime.StreamEvent, len(provider.scripts[index]))
for _, event := range provider.scripts[index] {
ch <- event
@@ -358,6 +363,92 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) {
}
}
func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
store := testSessionStore(t)
root := t.TempDir()
base := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
var clockNanos atomic.Int64
clockNanos.Store(base.UnixNano())
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
writeFileToolScript("call_write", "notes.txt", "hello"),
textScript("write blocked"),
}}
provider.beforeCall = func(index int) {
switch index {
case 0:
clockNanos.Store(base.Add(2 * time.Second).UnixNano())
case 1:
clockNanos.Store(base.Add(95 * time.Second).UnixNano())
}
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
runtimeMessageCh := make(chan tea.Msg, 8)
m := newPermissionTestModel(root, provider, registry, store, nil, runtimeMessageCh)
m.now = func() time.Time {
return time.Unix(0, clockNanos.Load()).UTC()
}
m.input.SetValue("write notes")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
}
finalCh := make(chan tea.Msg, 1)
go func() {
finalCh <- execCmd(cmd)
}()
for received := 0; received < 4; {
runtimeMsg := receiveRuntimeMessage(t, runtimeMessageCh)
updated, _ = next.Update(runtimeMsg)
next = updated.(model)
switch runtimeMsg.(type) {
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
continue
case permissionRequestMsg:
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
if status := plainRender(t, next.workingStatusLine()); !strings.Contains(status, "· 2s ·") || strings.Contains(status, "still generating") {
t.Fatalf("live elapsed advanced during permission wait: %q", status)
}
updated, _ = next.Update(testKeyText("d"))
next = updated.(model)
if hint := next.quietGenerationHint(); hint != "" {
t.Fatalf("permission wait leaked into quiet-generation age: %q", hint)
}
}
received++
}
finalMsg := receiveFinalMessage(t, finalCh)
response, ok := finalMsg.(agentResponseMsg)
if !ok {
t.Fatalf("expected agent response, got %T", finalMsg)
}
if response.turnElapsed != 5*time.Second {
t.Fatalf("turn elapsed = %s, want 5s of active work", response.turnElapsed)
}
if response.ttft != 5*time.Second {
t.Fatalf("ttft = %s, want 5s with permission wait excluded", response.ttft)
}
if response.ttft > response.turnElapsed {
t.Fatalf("ttft = %s exceeds turn elapsed = %s", response.ttft, response.turnElapsed)
}
updated, _ = next.Update(finalMsg)
next = updated.(model)
answer, ok := findTranscriptRow(next.transcript, rowAssistant)
if !ok {
t.Fatalf("expected final assistant row, got %#v", next.transcript)
}
if answer.turnElapsed != 5*time.Second {
t.Fatalf("assistant turn elapsed = %s, want 5s", answer.turnElapsed)
}
}
func TestPermissionPromptAllowWritesFileAndRecordsDecision(t *testing.T) {
store := testSessionStore(t)
root := t.TempDir()
+93
View File
@@ -0,0 +1,93 @@
package tui
import (
"sync"
"time"
)
// activeTurnTimer measures run time while excluding intervals where execution
// is paused waiting for a user decision. It is shared by the Bubble Tea model
// and the agent command, so access must remain safe across their goroutines.
type activeTurnTimer struct {
mu sync.Mutex
startedAt time.Time
pausedAt time.Time
pausedFor time.Duration
}
func newActiveTurnTimer(startedAt time.Time) *activeTurnTimer {
return &activeTurnTimer{startedAt: startedAt}
}
func (timer *activeTurnTimer) start(startedAt time.Time) {
if timer == nil {
return
}
timer.mu.Lock()
defer timer.mu.Unlock()
timer.startedAt = startedAt
timer.pausedAt = time.Time{}
timer.pausedFor = 0
}
func (timer *activeTurnTimer) pause(at time.Time) {
if timer == nil {
return
}
timer.mu.Lock()
defer timer.mu.Unlock()
if timer.startedAt.IsZero() || !timer.pausedAt.IsZero() {
return
}
timer.pausedAt = at
}
func (timer *activeTurnTimer) resume(at time.Time) {
if timer == nil {
return
}
timer.mu.Lock()
defer timer.mu.Unlock()
if timer.pausedAt.IsZero() {
return
}
if at.After(timer.pausedAt) {
timer.pausedFor += at.Sub(timer.pausedAt)
}
timer.pausedAt = time.Time{}
}
func (timer *activeTurnTimer) elapsed(at time.Time) time.Duration {
if timer == nil {
return 0
}
timer.mu.Lock()
defer timer.mu.Unlock()
if timer.startedAt.IsZero() {
return 0
}
end := at
if !timer.pausedAt.IsZero() && timer.pausedAt.Before(end) {
end = timer.pausedAt
}
elapsed := end.Sub(timer.startedAt) - timer.pausedFor
if elapsed < 0 {
return 0
}
return elapsed
}
func (m model) activeTurnElapsed(fallbackStartedAt time.Time) time.Duration {
return m.activeTurnElapsedAt(fallbackStartedAt, m.now())
}
func (m model) activeTurnElapsedAt(fallbackStartedAt, at time.Time) time.Duration {
if m.turnTimer != nil {
return m.turnTimer.elapsed(at)
}
elapsed := at.Sub(fallbackStartedAt)
if elapsed < 0 {
return 0
}
return elapsed
}