v0.38.0 58133ea

interactive_turn.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"strings"
7	"unicode"
8	"unicode/utf8"
9
10	"github.com/bnema/zut/packages/agent/tools"
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13	"github.com/bnema/zut/packages/tui"
14)
15
16func shellEscapeCommand(text string) (string, bool) {
17	return ShellEscapeCommand(text)
18}
19func ShellEscapeCommand(text string) (string, bool) {
20	trimmed := strings.TrimLeft(text, " \t")
21	if !strings.HasPrefix(trimmed, "!") {
22		return "", false
23	}
24	cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
25	if cmd == "" {
26		return "", false
27	}
28	return cmd, true
29}
30func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
31	i.mu.Lock()
32	if i.busy || i.shellRunning {
33		i.statusErr = "busy — wait for the current turn to finish before running a shell command"
34		i.statusOK = ""
35		i.mu.Unlock()
36		i.invalidate()
37		return
38	}
39	if parent == nil {
40		parent = i.runCtx
41	}
42	if parent == nil {
43		parent = context.Background()
44	}
45	ctx, cancel := context.WithCancel(parent)
46	i.busy = true
47	i.shellRunning = true
48	i.shellLive = "$ " + cmd + "\n\n"
49	i.cancelTurn = cancel
50	i.statusErr = ""
51	i.statusOK = ""
52	i.spin.Start()
53	i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
54	// Clear stale extension notes the same way a new turn would so the
55	// screen doesn't accumulate transient state.
56	i.extNotes = nil
57	i.scrollOffset = 0
58	i.parkedTurn = 0
59	i.parkedTotal = 0
60	i.helpBlock = nil
61	sandbox := i.cfg.Sandbox
62	cwd := i.cfg.CWD
63	i.mu.Unlock()
64	i.invalidate()
65
66	go func() {
67		defer cancel()
68		raw, _ := json.Marshal(map[string]any{
69			"command": cmd,
70			"timeout": ShellEscapeTimeoutSeconds,
71		})
72		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
73		progress := func(chunk string) {
74			i.mu.Lock()
75			i.shellLive += chunk
76			i.mu.Unlock()
77			i.invalidate()
78		}
79		res, err := bash.Execute(ctx, raw, progress)
80
81		var out string
82		if err != nil {
83			out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
84		} else {
85			for _, c := range res.Content {
86				if tb, ok := c.(provider.TextBlock); ok {
87					out += tb.Text
88				}
89			}
90		}
91		cancelled := ctx.Err() != nil
92		failed := err != nil || res.IsError || cancelled
93		if cancelled {
94			out += "\n\n[cancelled]"
95		}
96
97		if i.agent != nil {
98			i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
99		}
100
101		i.mu.Lock()
102		i.shellRunning = false
103		i.shellLive = ""
104		i.busy = false
105		i.cancelTurn = nil
106		pendingIdleWork := i.takePendingIdleWorkLocked()
107		awaitingPre := i.awaitingStartupPre
108		if failed {
109			if cancelled {
110				i.statusErr = "shell command cancelled"
111			} else {
112				i.statusErr = "shell command failed"
113			}
114			i.statusOK = ""
115		} else {
116			i.statusOK = "shell command finished"
117			i.statusErr = ""
118		}
119		i.mu.Unlock()
120		runPendingIdleWork(pendingIdleWork)
121		i.invalidate()
122		if awaitingPre {
123			i.completeStartupPre()
124		}
125	}()
126}
127func (i *Interactive) startTurn(parent context.Context, prompt string) {
128	i.startTurnWithImages(parent, prompt, nil)
129}
130func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
131	i.startTurnRequest(parent, prompt, images, false, false)
132}
133
134// startQueuedTurn keeps host provenance in the agent queue until injection,
135// including when the next turn first needs pre-turn compaction.
136func (i *Interactive) startQueuedTurn(parent context.Context, message core.QueuedMessage) {
137	if message.HostEvent && i.agent != nil {
138		i.agent.QueuePrompt(message)
139		i.startTurnRequest(parent, "", nil, true, false)
140		return
141	}
142	i.startTurnWithImages(parent, message.Text, message.Images)
143}
144func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
145	if i.agent == nil {
146		// Text startup pre cannot run without credentials; continue so
147		// deferred InitialInput (pre-fill or auto-submit) still applies.
148		i.mu.Lock()
149		awaitingPre := i.awaitingStartupPre
150		i.mu.Unlock()
151		if awaitingPre {
152			i.completeStartupPre()
153		}
154		return
155	}
156	// Pre-turn safety: if the most recent context measurement is
157	// already past the auto-compact threshold, condense before
158	// sending so the next outbound request stays under the limit.
159	// The condense flow re-fires the user's queued prompt for us, so
160	// we just hand it off and exit.
161	i.mu.Lock()
162	var resetHandoff json.RawMessage
163	var persistResetHandoff bool
164	if !continueExisting {
165		resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
166	}
167	needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
168	if needsPreCompact {
169		// Reserve the compaction hand-off before releasing i.mu. A completion
170		// update arriving in the small gap before runCompact acquires the
171		// mutex must join the host queue rather than starting a competing turn.
172		i.busy = true
173		i.compacting = true
174		i.autoCompacting = true
175		if continueExisting {
176			i.continueAfterCompact = true
177		} else {
178			i.pendingCompactPrompt = prompt
179			i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
180			i.hasPendingCompactPrompt = true
181		}
182		i.statusErr = ""
183		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
184		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
185		i.mu.Unlock()
186		if persistResetHandoff {
187			i.persistCompactHandoff(resetHandoff)
188		}
189		i.invalidate()
190		i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
191		return
192	}
193	i.mu.Unlock()
194	if persistResetHandoff {
195		i.persistCompactHandoff(resetHandoff)
196	}
197	// Bind a pending Esc interruption to the ordinary user turn that is
198	// actually starting. Queued-while-busy prompts do not consume it; only
199	// this start path does, so the hidden instruction precedes the prompt
200	// that owns the single automatic goal return.
201	if !continueExisting {
202		i.consumeInterruptedGoalPrompt()
203	}
204
205	ctx, cancel := context.WithCancel(parent)
206	i.mu.Lock()
207	i.busy = true
208	i.spin.Start()
209	i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
210	i.cancelTurn = cancel
211	i.statusErr = ""
212	i.statusOK = ""
213	i.streaming.Reset()
214	i.streamOn = true
215	i.pendingAlert = nil
216	i.toolCalls = map[string]*tui.ToolCallView{}
217	i.toolOrder = nil
218	i.toolGate = map[string]int{}
219	i.planCurrent = 0
220	i.planTotal = 0
221	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
222	i.scrollOffset = 0 // jump back to the bottom on new turn
223	// Lift the resume tail cap once the user starts interacting. The
224	// cap is purely a first-paint optimization (don't markdown the
225	// whole history before showing anything). Keeping it active during
226	// a turn makes the rendered chat a sliding window: appended
227	// messages push older ones off the TOP of the buffer, which the
228	// renderer must treat as a change above the viewport and repaint
229	// fully, snapping the terminal's native scrollback to the bottom on
230	// every streamed chunk. A fresh session has no cap (append-only),
231	// which is why the jump only shows up in resumed sessions. Dropping
232	// the cap here makes resumed turns append-only too.
233	i.view.TailLimit = 0
234	// Reset the auto-follow baseline so the very next render at
235	// interactive.go:1053 doesn't see a synthetic shrink between
236	// "last frame had the previous turn's tool overlay" and
237	// "this frame had it cleared above". Without this, the guard
238	// reads delta = -(rows in cleared overlay) and decrements
239	// scrollOffset, which on terminals that mirror zut's pane
240	// scroll into the host scrollbar visibly yanks the viewport.
241	// See autofollow_shrink_test.go for the exact arithmetic.
242	i.prevChatLen = 0
243	i.prevChatCols = 0
244	i.parkedTurn = 0 // starting a turn clears the /jump parked state
245	i.parkedTotal = 0
246	i.helpBlock = nil // hide the help block once the user asks something
247	i.mu.Unlock()
248	i.invalidate()
249
250	var (
251		lastStop    provider.StopReason
252		lastTurnErr error
253	)
254	sink := func(ev core.AgentEvent) {
255		if e, ok := ev.(core.EvTurnEnd); ok {
256			lastStop = e.Stop
257			lastTurnErr = e.Err
258		}
259		i.observeGoalRun(ev)
260		i.handleEventForPresentation(ev)
261	}
262
263	releaseCompletionHold := i.beginCompletionDeliveryHold()
264	go func() {
265		defer releaseCompletionHold()
266		var err error
267		if continueExisting {
268			err = i.agent.Continue(ctx, sink)
269		} else {
270			err = i.agent.Prompt(ctx, prompt, images, sink)
271		}
272		i.mu.Lock()
273		// Keep busy asserted through final cleanup and queue selection. A
274		// watcher may enqueue a completion summary concurrently with the
275		// provider's final event; publishing idle before inspecting queues can
276		// strand that summary in the core agent queue or start an overlapping
277		// turn.
278		// Don't touch streamPending / streamFlushPending here — the
279		// pacer may still be draining the final deltas and needs to
280		// paint them even though Prompt has returned. It will reset
281		// streamOn on its own once the buffer empties.
282		if len(i.streamPending) == 0 {
283			i.streamOn = false
284		}
285		i.cancelTurn = nil
286		pendingIdleWork := i.takePendingIdleWorkLocked()
287		if err != nil && ctx.Err() == nil {
288			i.statusErr = err.Error()
289		}
290		// Decide whether to offer a model rescue picker for recoverable
291		// provider failures (auth/rate/temporary). The picker opens after
292		// the mutex is released so it can take its own locks freely.
293		var (
294			offer       bool
295			rescueWhy   string
296			rescueImgs  []provider.ImageBlock
297			rescueModel string
298			rescueProv  string
299			rescueFprov string
300		)
301		if err != nil && ctx.Err() == nil {
302			if ok, reason := classifyRescueError(err); ok {
303				offer = true
304				rescueWhy = reason
305				rescueImgs = images
306				rescueModel = i.cfg.Model
307				rescueProv = i.cfg.Provider
308				rescueFprov = extractFailedProvider(err)
309				if rescueFprov == "" {
310					rescueFprov = i.cfg.Provider
311				}
312				// Suppress the red banner — the rescue dialog already
313				// surfaces the failure.
314				i.statusErr = ""
315			}
316		}
317		// Detect responses that reject the current context, either as an
318		// HTTP 413 payload limit or a model context-window error. Token-
319		// based auto-compact can miss both when metadata is stale or the
320		// limit is measured in raw bytes. Compact once, then continue the
321		// user message already present in the transcript.
322		contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
323		recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
324		if recoverContextOverflow {
325			i.statusErr = ""
326			i.continueAfterCompact = true
327			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
328			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
329		}
330		// Persist the assistant's reply (and every tool row before
331		// it) to the session file while the turn memory is hot.
332		// Without this, WriteNewTranscript only fires at zut exit,
333		// meaning a crash or ungraceful kill drops the whole
334		// conversation. FlushSession is idempotent (it advances the
335		// baseline so subsequent flushes only write new rows).
336		flush := i.cfg.FlushSession
337		i.mu.Unlock()
338		runPendingIdleWork(pendingIdleWork)
339		if flush != nil {
340			flush()
341		}
342		terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
343		if terminalGoalError {
344			i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
345		}
346		// A cancelled or failed turn unwinds without scheduling autonomous
347		// work below. Preserve a freshly armed Esc return for the next
348		// ordinary prompt; drop only a return already bound to this turn.
349		if ctx.Err() != nil || err != nil {
350			i.mu.Lock()
351			if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
352				i.interruptedGoalReturn = nil
353			}
354			i.mu.Unlock()
355		}
356		goalContextLimited := lastStop == provider.StopLength
357		// Cancellation is user action, not model inaction: unwind the lease
358		// without consuming the no-progress allowance or stalling the goal.
359		continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil)
360		i.mu.Lock()
361		awaitingPre := i.awaitingStartupPre
362		// A newer explicit prompt may have cleared the handoff while the
363		// completed turn was being flushed. Re-read it under the mutex before
364		// deciding whether this continuation may spend another rescue attempt.
365		statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
366		// Pop the next queued message, if any, and relaunch.
367		var next core.QueuedMessage
368		var hasNext bool
369		if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
370			next, i.queued = i.queued[0], i.queued[1:]
371			hasNext = true
372		}
373		// If the turn was cancelled or errored, drop ordinary input so the
374		// user isn't bombarded with stale messages after an interrupt.
375		// Scheduler follow-ups deliberately remain: they are distinct turns
376		// whose due time must not let them steer this failed turn.
377		if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
378			i.discardQueuedMessagesLocked(ctx.Err() == nil)
379		}
380		var scheduled scheduledFollowUp
381		var hasScheduled bool
382		if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
383			scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
384			hasScheduled = true
385		}
386		// Decide whether the next thing to do is an auto-compaction.
387		// Only fires when the turn completed cleanly AND no host-side
388		// or agent-side queued messages are waiting (otherwise a queued
389		// message would race the condense).
390		agentQueued := 0
391		if i.agent != nil {
392			agentQueued = i.agent.QueuedMessageCount()
393		}
394		continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
395		// A truncated autonomous goal needs fresh execution capacity even when
396		// context-usage metadata is absent or below the configured threshold.
397		// Route it through compaction rather than immediately reusing the same
398		// exhausted transcript.
399		goalNeedsFreshContext := continueGoal && goalContextLimited
400		shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && (goalNeedsFreshContext || i.shouldAutoCompactLocked())
401		continueStatusRescue := false
402		var handoff json.RawMessage
403		var persistHandoff bool
404		if goalNeedsFreshContext {
405			// Persist ownership before compaction starts. If the process exits
406			// while condensing, session resume repeats compaction before it
407			// provisions a fresh leased goal turn.
408			handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoalCompactionPending})
409		}
410		if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
411			followUpMessages := i.agent.Messages()
412			reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
413			if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
414				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
415					reason:         compactContinuationStatusRescue,
416					rescueAttempts: i.compactContinuation.rescueAttempts + 1,
417				})
418				continueStatusRescue = true
419			} else {
420				handoff, persistHandoff = i.resetCompactContinuationLocked()
421			}
422		}
423		if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
424			handoff, persistHandoff = i.resetCompactContinuationLocked()
425		}
426		// The agent run can finish before the paced final text reaches the
427		// transcript. A compaction replaces that transcript, so it must never
428		// race the still-live stream frame; otherwise stale deltas can repaint
429		// after the replacement and corrupt the scrollback renderer's model.
430		if recoverContextOverflow || shouldAutoCompact {
431			i.resetStreamingStateLocked()
432		}
433		// A clean ordinary turn that owns a pending Esc return resumes the
434		// same still-active goal exactly once through the existing scheduler.
435		// It yields to every existing priority above; terminal, paused, or
436		// replaced goals never resume because the match check fails.
437		interruptedReturn := false
438		if err == nil && ctx.Err() == nil && !awaitingPre && !hasNext && !hasScheduled && !continueQueued && !offer && !recoverContextOverflow && !shouldAutoCompact && !continueStatusRescue && !continueGoal {
439			state := i.interruptedGoalReturn
440			if state != nil && state.returnAfterTurn && i.interruptedGoalMatchesLocked() {
441				// Consume only after matching: the match helper reads the
442				// state being consumed.
443				i.interruptedGoalReturn = nil
444				interruptedReturn = true
445			}
446		} else if ctx.Err() == nil && err == nil {
447			// Another follow-up won this turn: keep an armed-but-unbound
448			// interruption pending without consuming the one-shot return.
449			// A bound return that lost priority is inert until replaced.
450			if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
451				i.interruptedGoalReturn = nil
452			}
453		}
454		alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact)
455		i.planCurrent = 0
456		i.planTotal = 0
457		// interruptedReturn already reserves the turn slot (like the
458		// compact handoff path below): the reserved continuation owns
459		// busy and must not re-enter the idle gate.
460		i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact || interruptedReturn
461		i.mu.Unlock()
462		if persistHandoff {
463			i.persistCompactHandoff(handoff)
464		}
465		if alertReason != "" {
466			i.scheduleMainAlert(alertReason)
467		}
468		i.invalidate()
469		parent := i.runCtx
470		if parent == nil {
471			parent = context.Background()
472		}
473		if awaitingPre {
474			i.completeStartupPre()
475			return
476		}
477		switch {
478		case hasNext:
479			i.startQueuedTurn(parent, next)
480		case hasScheduled:
481			// The scheduler holds the session transition read lock until this
482			// acknowledgement, so accepting here binds the prompt to the agent
483			// that just completed rather than a subsequently loaded session.
484			scheduled.accepted <- nil
485			i.startTurn(parent, scheduled.text)
486		case continueQueued:
487			i.startTurnRequest(parent, "", nil, true, false)
488		case continueStatusRescue:
489			i.startAutoCompactContinuation(parent)
490		case offer:
491			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
492		case recoverContextOverflow:
493			i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
494		case shouldAutoCompact:
495			i.runCompact(parent, compactContinuationRequest{
496				origin:    compactOriginAfterTurnThreshold,
497				force:     lastStop == provider.StopLength,
498				lastStop:  lastStop,
499				turnError: lastTurnErr,
500			})
501		case continueGoal:
502			i.requestGoalContinuationIfIdle(parent)
503		case interruptedReturn:
504			// busy is already reserved above; use the reserved entry point
505			// so the one-shot return cannot fail the idle gate and leak
506			// the reservation.
507			i.startReservedGoalContinuation(parent)
508		}
509	}()
510}
511func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
512	if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
513		return ""
514	}
515	if rescue {
516		return "rescue_required"
517	}
518	if stop == provider.StopAborted {
519		return ""
520	}
521	if err != nil || turnErr != nil || stop == provider.StopError {
522		return "agent_error"
523	}
524	if stop == provider.StopLength {
525		return "response_truncated"
526	}
527	return "agent_done"
528}
529func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
530	if i.rescueDialog == nil {
531		return
532	}
533	loggedIn := []string{}
534	if i.cfg.LoggedInProviders != nil {
535		loggedIn = i.cfg.LoggedInProviders()
536	}
537	fprov := failedProvider
538	if fprov == "" {
539		fprov = activeProvider
540	}
541	i.mu.Lock()
542	i.pendingRescuePrompt = prompt
543	i.pendingRescueImages = images
544	i.mu.Unlock()
545	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
546	i.invalidate()
547}
548func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
549	if model == "" {
550		return
551	}
552	i.applyRescueModelSelection(prov, model)
553	i.mu.Lock()
554	images := i.pendingRescueImages
555	if prompt == "" {
556		prompt = i.pendingRescuePrompt
557	}
558	i.pendingRescuePrompt = ""
559	i.pendingRescueImages = nil
560	i.mu.Unlock()
561	parent := i.runCtx
562	if parent == nil {
563		parent = context.Background()
564	}
565	i.startTurnWithImages(parent, prompt, images)
566}
567func stripAutoCompactNotes(notes []string) []string {
568	if len(notes) == 0 {
569		return notes
570	}
571	out := notes[:0]
572	for _, n := range notes {
573		if strings.Contains(n, "condensing history") {
574			continue
575		}
576		out = append(out, n)
577	}
578	return out
579}
580func autoCompactNoteLine(th tui.Theme, msg string) string {
581	return "  " + th.FGColor(th.Warning, "⚠ "+msg)
582}
583func NormalizeAutoCompactThreshold(threshold *int) int {
584	const defaultAutoCompactThreshold = 85
585	if threshold == nil {
586		return defaultAutoCompactThreshold
587	}
588	switch *threshold {
589	case 0, 70, 80, 85, 90, 95:
590		return *threshold
591	default:
592		return defaultAutoCompactThreshold
593	}
594}
595func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
596	if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
597		return false
598	}
599	return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
600}
601func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
602	if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
603		return compactContinuationStructuralTail
604	}
605	if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
606		return compactContinuationNone
607	}
608	if likelyForwardWorkStatus(msgs) {
609		return compactContinuationStatusRescue
610	}
611	return compactContinuationNone
612}
613func structuralCompactionContinuation(msgs []provider.Message) bool {
614	if len(msgs) == 0 {
615		return false
616	}
617	last := msgs[len(msgs)-1]
618	if last.Role != provider.RoleAssistant {
619		return true
620	}
621	hasText := false
622	for _, content := range last.Content {
623		switch block := content.(type) {
624		case provider.ToolCallBlock:
625			return true
626		case provider.TextBlock:
627			if strings.TrimSpace(block.Text) != "" {
628				hasText = true
629			}
630		case provider.ReasoningBlock:
631			// Reasoning without a visible answer is not terminal.
632		default:
633			return true
634		}
635	}
636	return !hasText
637}
638func likelyForwardWorkStatus(msgs []provider.Message) bool {
639	if len(msgs) == 0 {
640		return false
641	}
642	last := msgs[len(msgs)-1]
643	if last.Role != provider.RoleAssistant {
644		return false
645	}
646	var text strings.Builder
647	for _, content := range last.Content {
648		block, ok := content.(provider.TextBlock)
649		if !ok {
650			if _, hasTool := content.(provider.ToolCallBlock); hasTool {
651				return false
652			}
653			continue
654		}
655		if strings.TrimSpace(block.Text) == "" {
656			continue
657		}
658		if text.Len() > 0 {
659			text.WriteByte(' ')
660		}
661		text.WriteString(block.Text)
662	}
663	if text.Len() == 0 {
664		return false
665	}
666	normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
667	normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
668	for _, phrase := range []string{
669		"next i will",
670		"next, i will",
671		"i'll now",
672		"i will now",
673		"i am going to",
674		"i need to continue",
675		"then i will",
676		"then, i will",
677		"i will proceed",
678	} {
679		if containsFutureWorkPhrase(normalized, phrase) {
680			return true
681		}
682	}
683	return false
684}
685func containsFutureWorkPhrase(text, phrase string) bool {
686	for offset := 0; offset < len(text); {
687		index := strings.Index(text[offset:], phrase)
688		if index < 0 {
689			return false
690		}
691		index += offset
692		end := index + len(phrase)
693		if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
694			return true
695		}
696		offset = end
697	}
698	return false
699}
700func wordRuneBefore(text string, index int) bool {
701	if index == 0 {
702		return false
703	}
704	rune_, _ := utf8.DecodeLastRuneInString(text[:index])
705	return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
706}
707func wordRuneAfter(text string, index int) bool {
708	if index == len(text) {
709		return false
710	}
711	rune_, _ := utf8.DecodeRuneInString(text[index:])
712	return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
713}
714func (i *Interactive) shouldAutoCompactLocked() bool {
715	if i.agent == nil {
716		return false
717	}
718	if i.autoCompacting {
719		return false
720	}
721	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
722	if err != nil || m.ContextWindow <= 0 {
723		return false
724	}
725	threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
726	return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
727}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close