Skip to content

Commit eaeaf23

Browse files
committed
feat: comprehensive UI feedback for long operations
- Show stream idle status in status line instead of silencing it - Cap first idle warning at 30s (was half of timeout, e.g. 90s for GLM) - Add OnThinkingIdle callback — distinguishes "model is thinking" from "server is unresponsive" with separate UI treatment (info vs warning) - Extend stream idle timeout once for thinking models before first content, giving GLM up to 6 minutes for complex reasoning tasks - Fix lastWarningAt accumulation bug (was jumping by streamIdleWarning instead of actual 10s interval after first warning) - Add feedback for agent reflection (up to 30s LLM call), context compaction, force-compaction, and background optimization - Add OnOptimizeStart callback + StatusInfo toast for context optimization - Show "· running" hint for slow tool calls via previously unused slowWarningShown flag - Notify user when session is compacted after restore to fit model context - Translate all status_callback messages from Russian to English
1 parent e3a6106 commit eaeaf23

10 files changed

Lines changed: 134 additions & 13 deletions

File tree

internal/agent/agent.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,9 +2388,10 @@ func (a *Agent) executeLoop(ctx context.Context, prompt string, output *strings.
23882388
if a.treePlanner.ShouldReplan(planTree, firstFailure.result) && replanAttempts < 3 {
23892389
replanAttempts++
23902390

2391-
// Build replan context with reflection
2391+
// Build replan context with reflection — may invoke LLM (up to 30s)
23922392
var reflection *Reflection
23932393
if a.reflector != nil && firstFailure.action.ToolName != "" {
2394+
a.safeOnText(fmt.Sprintf("\n[Analyzing %s failure...]\n", firstFailure.action.ToolName))
23942395
reflection = a.reflector.Reflect(ctx, firstFailure.action.ToolName, firstFailure.action.ToolArgs, firstFailure.result.Error)
23952396
}
23962397

@@ -3154,6 +3155,7 @@ func (a *Agent) checkAndSummarize(ctx context.Context) error {
31543155
"agent_id", a.ID,
31553156
"usage", fmt.Sprintf("%.1f%%", percentUsed*100),
31563157
"tokens", tokenCount)
3158+
a.safeOnText(fmt.Sprintf("\n[Compacting context (%.0f%% used)...]\n", percentUsed*100))
31573159

31583160
// 3. Summarize on snapshot (potentially slow API call — no lock held)
31593161
if len(historySnapshot) <= a.summarizeMinMsgs {
@@ -3217,6 +3219,8 @@ func (a *Agent) forceCompactHistory(ctx context.Context) error {
32173219
return nil // Not enough to compact
32183220
}
32193221

3222+
a.safeOnText(fmt.Sprintf("\n[Force-compacting history (%d messages)...]\n", len(a.history)))
3223+
32203224
keepStart := 3 // system prompt + greeting + original task prompt
32213225
keepEnd := 6
32223226
keepMiddle := 4 // Top N by importance from middle section
@@ -3825,7 +3829,8 @@ func (a *Agent) executeToolWithReflection(ctx context.Context, call *genai.Funct
38253829
logging.Info("fix cache hit", "tool", call.Name, "category", category,
38263830
"hit_count", cacheHit.HitCount)
38273831
} else {
3828-
// Cache miss: full Reflect pipeline (unchanged)
3832+
// Cache miss: full Reflect pipeline — may invoke LLM (up to 30s)
3833+
a.safeOnText(fmt.Sprintf("\n[Analyzing %s error...]\n", call.Name))
38293834
reflection = a.reflector.Reflect(ctx, call.Name, call.Args, result.Content)
38303835
}
38313836

internal/app/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ func (a *App) Run() error {
354354
"messages_after", len(a.session.GetHistory()),
355355
"tokens_estimated", tokens,
356356
"model_limit", limits.MaxInputTokens)
357+
a.tui.AddSystemMessage(fmt.Sprintf("Compacted session to fit model context (removed %d messages)", truncated))
357358
}
358359
}
359360
}

internal/app/app_handlers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (a *App) promptPermission(ctx context.Context, req *permission.Request) (pe
5252
if a.program != nil {
5353
a.program.Send(ui.StatusUpdateMsg{
5454
Type: ui.StatusStreamIdle,
55-
Message: fmt.Sprintf("Ожидание разрешения для %s...", req.ToolName),
55+
Message: fmt.Sprintf("Waiting for permission: %s...", req.ToolName),
5656
})
5757
}
5858
// Reset timer for next reminder

internal/app/builder.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,14 @@ func (b *Builder) wireDependencies() error {
16291629
app.tui.AddSystemMessage(msg)
16301630
}
16311631
}
1632+
b.contextManager.OnOptimizeStart = func(reason string) {
1633+
if app.program != nil {
1634+
app.safeSendToProgram(ui.StatusUpdateMsg{
1635+
Type: ui.StatusInfo,
1636+
Message: fmt.Sprintf("Optimizing context (%s)...", reason),
1637+
})
1638+
}
1639+
}
16321640
}
16331641

16341642
// Set up background task tracking callbacks for UI

internal/app/status_callback.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func (c *appStatusCallback) OnRetry(attempt, maxAttempts int, delay time.Duratio
1919
return
2020
}
2121

22-
msg := fmt.Sprintf("Повторная попытка %d/%d через %s (%s)",
22+
msg := fmt.Sprintf("Retry %d/%d in %s (%s)",
2323
attempt, maxAttempts, delay.Round(time.Second), reason)
2424

2525
c.app.program.Send(ui.StatusUpdateMsg{
@@ -40,7 +40,7 @@ func (c *appStatusCallback) OnRateLimit(waitTime time.Duration) {
4040
return
4141
}
4242

43-
msg := fmt.Sprintf("Rate limit, ожидание %s...", waitTime.Round(time.Second))
43+
msg := fmt.Sprintf("Rate limit, waiting %s...", waitTime.Round(time.Second))
4444

4545
c.app.program.Send(ui.StatusUpdateMsg{
4646
Type: ui.StatusRateLimit,
@@ -57,9 +57,9 @@ func (c *appStatusCallback) OnStreamIdle(elapsed time.Duration) {
5757
return
5858
}
5959

60-
msg := fmt.Sprintf("Ожидание ответа %s...", elapsed.Round(time.Second))
60+
msg := fmt.Sprintf("Waiting for response %s...", elapsed.Round(time.Second))
6161
if elapsed >= 20*time.Second {
62-
msg = fmt.Sprintf("Ожидание ответа %s... (ESC для отмены)", elapsed.Round(time.Second))
62+
msg = fmt.Sprintf("Waiting for response %s... (ESC to cancel)", elapsed.Round(time.Second))
6363
}
6464

6565
c.app.program.Send(ui.StatusUpdateMsg{
@@ -71,6 +71,27 @@ func (c *appStatusCallback) OnStreamIdle(elapsed time.Duration) {
7171
})
7272
}
7373

74+
// OnThinkingIdle is called when a thinking-enabled model is in its silent reasoning phase.
75+
func (c *appStatusCallback) OnThinkingIdle(elapsed time.Duration, provider string) {
76+
if c.app == nil || c.app.program == nil {
77+
return
78+
}
79+
80+
msg := fmt.Sprintf("%s is thinking %s...", provider, elapsed.Round(time.Second))
81+
if elapsed >= 60*time.Second {
82+
msg = fmt.Sprintf("%s is thinking %s... (ESC to cancel)", provider, elapsed.Round(time.Second))
83+
}
84+
85+
c.app.program.Send(ui.StatusUpdateMsg{
86+
Type: ui.StatusThinkingIdle,
87+
Message: msg,
88+
Details: map[string]any{
89+
"elapsed": elapsed,
90+
"provider": provider,
91+
},
92+
})
93+
}
94+
7495
// OnStreamResume is called when the stream resumes after being idle.
7596
func (c *appStatusCallback) OnStreamResume() {
7697
if c.app == nil || c.app.program == nil {
@@ -92,7 +113,7 @@ func (c *appStatusCallback) OnError(err error, recoverable bool) {
92113

93114
msg := err.Error()
94115
if recoverable {
95-
msg = "Восстанавливаемая ошибка: " + msg
116+
msg = "Recoverable error: " + msg
96117
}
97118
ft := client.DetectFailureTelemetry(err)
98119

internal/client/anthropic.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -862,8 +862,11 @@ func (c *AnthropicClient) doStreamRequest(ctx context.Context, requestBody map[s
862862

863863
// Stream idle timeout (configurable, default 30s between chunks)
864864
streamIdleTimeout := c.config.StreamIdleTimeout
865-
// Stream idle warning - half of idle timeout
865+
// Stream idle warning - half of idle timeout, capped at 30s for faster feedback
866866
streamIdleWarning := streamIdleTimeout / 2
867+
if streamIdleWarning > 30*time.Second {
868+
streamIdleWarning = 30 * time.Second
869+
}
867870

868871
// Capture status callback for goroutine
869872
c.mu.RLock()
@@ -931,10 +934,11 @@ func (c *AnthropicClient) doStreamRequest(ctx context.Context, requestBody map[s
931934

932935
eventCount := 0
933936
contentReceived := false
937+
initialTimeoutExtended := false // Track whether we've already extended for thinking phase
934938
idleTimer := time.NewTimer(streamIdleTimeout)
935939
defer idleTimer.Stop()
936940

937-
// Warning timer for UI feedback (fires at 15s, then again at 25s)
941+
// Warning timer for UI feedback (fires at 30s max, then every 10s)
938942
warningTimer := time.NewTimer(streamIdleWarning)
939943
defer warningTimer.Stop()
940944
lastWarningAt := time.Duration(0)
@@ -956,16 +960,38 @@ func (c *AnthropicClient) doStreamRequest(ctx context.Context, requestBody map[s
956960

957961
case <-warningTimer.C:
958962
// Stream idle warning - notify UI
959-
lastWarningAt += streamIdleWarning
963+
if lastWarningAt == 0 {
964+
lastWarningAt = streamIdleWarning
965+
} else {
966+
lastWarningAt += 10 * time.Second // Match the actual reset interval
967+
}
960968
if statusCb != nil {
961-
statusCb.OnStreamIdle(lastWarningAt)
969+
// Distinguish thinking phase from generic idle
970+
if !contentReceived && c.config.EnableThinking {
971+
statusCb.OnThinkingIdle(lastWarningAt, c.config.Provider)
972+
} else {
973+
statusCb.OnStreamIdle(lastWarningAt)
974+
}
962975
}
963976
// Reset for next warning (every 10 seconds after first)
964977
warningTimer.Reset(10 * time.Second)
965978
// Continue waiting in the same select
966979
continue waitLoop
967980

968981
case <-idleTimer.C:
982+
// If thinking is enabled and no content received yet, the model
983+
// is likely in a silent reasoning phase. Extend the timeout once
984+
// to avoid killing the request prematurely.
985+
if !contentReceived && !initialTimeoutExtended && c.config.EnableThinking {
986+
initialTimeoutExtended = true
987+
logging.Info("extending idle timeout for thinking model — no content yet",
988+
"provider", c.config.Provider, "original_timeout", streamIdleTimeout)
989+
idleTimer.Reset(streamIdleTimeout)
990+
if statusCb != nil {
991+
statusCb.OnThinkingIdle(streamIdleTimeout, c.config.Provider)
992+
}
993+
continue waitLoop
994+
}
969995
logging.Warn("stream idle timeout exceeded", "timeout", streamIdleTimeout, "partial", contentReceived)
970996
chunks <- ResponseChunk{
971997
Error: &ErrStreamIdleTimeout{Timeout: streamIdleTimeout, Partial: contentReceived},

internal/client/status.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ type StatusCallback interface {
2020
// elapsed is the time since the last data was received.
2121
OnStreamIdle(elapsed time.Duration)
2222

23+
// OnThinkingIdle is called when a thinking-enabled model is in its silent
24+
// reasoning phase (no content received yet). Unlike OnStreamIdle, this tells
25+
// the UI that the delay is expected — the model is deliberately thinking.
26+
OnThinkingIdle(elapsed time.Duration, provider string)
27+
2328
// OnStreamResume is called when the stream resumes after being idle.
2429
OnStreamResume()
2530

@@ -42,6 +47,9 @@ func (d *DefaultStatusCallback) OnRateLimit(waitTime time.Duration) {}
4247
// OnStreamIdle does nothing.
4348
func (d *DefaultStatusCallback) OnStreamIdle(elapsed time.Duration) {}
4449

50+
// OnThinkingIdle does nothing.
51+
func (d *DefaultStatusCallback) OnThinkingIdle(elapsed time.Duration, provider string) {}
52+
4553
// OnStreamResume does nothing.
4654
func (d *DefaultStatusCallback) OnStreamResume() {}
4755

internal/context/manager.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ type ContextManager struct {
6565

6666
// Notification callback when context is compacted
6767
OnCompact func(oldTokens, newTokens, removedMessages int, reason string)
68+
69+
// Notification callback when background optimization starts (for UI feedback)
70+
OnOptimizeStart func(reason string)
6871
}
6972

7073
// NewContextManager creates a new context manager.
@@ -366,6 +369,7 @@ func (m *ContextManager) PrepareForRequest(ctx context.Context) error {
366369

367370
// Optimize if near limit — launch in background to avoid blocking
368371
if usage.NearLimit && m.summarizer != nil && m.config.EnableAutoSummary {
372+
m.notifyOptimizeStart("context near token limit")
369373
m.backgroundOptimize(ctx)
370374
}
371375

@@ -378,6 +382,7 @@ func (m *ContextManager) PrepareForRequest(ctx context.Context) error {
378382
"current_tokens", tokens,
379383
"predicted_tokens", predicted,
380384
"predicted_pct", predUsage.PercentUsed)
385+
m.notifyOptimizeStart("predictive — approaching token limit")
381386
m.backgroundOptimize(ctx)
382387
}
383388
}
@@ -390,13 +395,21 @@ func (m *ContextManager) PrepareForRequest(ctx context.Context) error {
390395
logging.Info("message count summarization triggered",
391396
"history_len", historyLen,
392397
"max_history", maxHistory)
398+
m.notifyOptimizeStart("conversation history growing large")
393399
m.backgroundOptimize(ctx)
394400
}
395401
}
396402

397403
return nil
398404
}
399405

406+
// notifyOptimizeStart fires the OnOptimizeStart callback if registered.
407+
func (m *ContextManager) notifyOptimizeStart(reason string) {
408+
if m.OnOptimizeStart != nil {
409+
m.OnOptimizeStart(reason)
410+
}
411+
}
412+
400413
// backgroundOptimize runs context optimization in a background goroutine.
401414
// Only one optimization runs at a time; concurrent calls are no-ops.
402415
func (m *ContextManager) backgroundOptimize(ctx context.Context) {

internal/ui/tui.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ type Model struct {
5050
lastActivityTime time.Time // Last time we received any activity (tool call, stream, etc.)
5151
slowWarningShown bool // Whether we've shown the slow warning for current operation
5252

53+
// Stream idle feedback (server slow to respond)
54+
streamIdleMsg string // Non-empty when stream is idle — shown in status line
55+
5356
// Rate limiting / debounce for message submission
5457
lastSubmitTime time.Time
5558
minSubmitDelay time.Duration // Minimum delay between submissions (default: 500ms)
@@ -1333,6 +1336,7 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
13331336
m.streamStartTime = time.Now() // Reset timeout on streaming activity
13341337
m.lastActivityTime = time.Now()
13351338
m.slowWarningShown = false
1339+
m.streamIdleMsg = "" // Server responded — clear idle warning
13361340
m.state = StateStreaming
13371341
m.processingLabel = "" // Text streaming is the feedback itself
13381342

@@ -1351,6 +1355,7 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
13511355
m.streamStartTime = time.Now() // Reset timeout on tool activity
13521356
m.lastActivityTime = time.Now()
13531357
m.slowWarningShown = false
1358+
m.streamIdleMsg = "" // Server responded — clear idle warning
13541359
m.processingLabel = "" // Tool name takes over in status bar
13551360

13561361
// Close thinking block when tool call starts
@@ -1495,6 +1500,7 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
14951500
m.currentTool = ""
14961501
m.currentToolInfo = ""
14971502
m.processingLabel = ""
1503+
m.streamIdleMsg = "" // Clear idle warning
14981504
m.loopIteration = 0
14991505
m.loopToolsUsed = 0
15001506
m.activeToolCalls = nil
@@ -1537,6 +1543,7 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
15371543
m.currentTool = ""
15381544
m.currentToolInfo = ""
15391545
m.processingLabel = ""
1546+
m.streamIdleMsg = "" // Clear idle warning
15401547
m.loopIteration = 0
15411548
m.loopToolsUsed = 0
15421549
m.activeToolCalls = nil
@@ -2026,8 +2033,15 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
20262033
m.rateLimitWaitUntil = time.Now().Add(wt)
20272034
}
20282035
case StatusStreamIdle:
2029-
// Silent
2036+
firstWarning := m.streamIdleMsg == ""
2037+
if msg.Message != "" {
2038+
m.streamIdleMsg = msg.Message
2039+
}
2040+
if firstWarning && m.toastManager != nil && msg.Message != "" {
2041+
m.toastManager.ShowWarning(msg.Message)
2042+
}
20302043
case StatusStreamResume:
2044+
m.streamIdleMsg = ""
20312045
m.retryAttempt = 0
20322046
m.retryMax = 0
20332047
m.rateLimitWaitUntil = time.Time{}
@@ -2039,6 +2053,19 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
20392053
if m.toastManager != nil {
20402054
m.toastManager.ShowWarning(msg.Message)
20412055
}
2056+
case StatusInfo:
2057+
if m.toastManager != nil && msg.Message != "" {
2058+
m.toastManager.Show(ToastInfo, "", msg.Message, 4*time.Second)
2059+
}
2060+
case StatusThinkingIdle:
2061+
// Model is deliberately thinking — show distinct from generic "idle"
2062+
firstWarning := m.streamIdleMsg == ""
2063+
if msg.Message != "" {
2064+
m.streamIdleMsg = msg.Message
2065+
}
2066+
if firstWarning && m.toastManager != nil && msg.Message != "" {
2067+
m.toastManager.Show(ToastInfo, "", msg.Message, 6*time.Second)
2068+
}
20422069
}
20432070
}
20442071

@@ -2382,6 +2409,16 @@ func (m Model) View() string {
23822409
status += " " + lipgloss.NewStyle().Foreground(durationColor).Render(format.Duration(elapsed))
23832410
}
23842411

2412+
// Show stream idle indicator when server is slow to respond
2413+
if m.streamIdleMsg != "" {
2414+
idleStyle := lipgloss.NewStyle().Foreground(ColorWarning)
2415+
status += " " + idleStyle.Render("· " + m.streamIdleMsg)
2416+
} else if m.slowWarningShown && m.currentTool != "" {
2417+
// Tool is running longer than expected — show brief hint
2418+
slowStyle := lipgloss.NewStyle().Foreground(ColorDim)
2419+
status += " " + slowStyle.Render("· running")
2420+
}
2421+
23852422
// Plan step context
23862423
if m.planProgress != nil && m.planProgressMode {
23872424
stepInfo := fmt.Sprintf(" [step %d/%d]",

internal/ui/tui_types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ const (
328328
StatusStreamResume
329329
StatusRecoverableError
330330
StatusCancelled
331+
StatusInfo
332+
StatusThinkingIdle
331333
)
332334

333335
// StatusUpdateMsg carries status updates from clients to the UI.

0 commit comments

Comments
 (0)