v0.51.0 a9d674e

interactive_turn.go RawHistoryFolder

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

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 476

↑↓ moveenter openesc close