v0.27.1 131edba

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}
133func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
134	if i.agent == nil {
135		// Text startup pre cannot run without credentials; continue so
136		// deferred InitialInput (pre-fill or auto-submit) still applies.
137		i.mu.Lock()
138		awaitingPre := i.awaitingStartupPre
139		i.mu.Unlock()
140		if awaitingPre {
141			i.completeStartupPre()
142		}
143		return
144	}
145	// Pre-turn safety: if the most recent context measurement is
146	// already past the auto-compact threshold, condense before
147	// sending so the next outbound request stays under the limit.
148	// The condense flow re-fires the user's queued prompt for us, so
149	// we just hand it off and exit.
150	i.mu.Lock()
151	var resetHandoff json.RawMessage
152	var persistResetHandoff bool
153	if !continueExisting {
154		resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
155	}
156	needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
157	if needsPreCompact {
158		// Reserve the compaction hand-off before releasing i.mu. A completion
159		// update arriving in the small gap before runCompact acquires the
160		// mutex must join the host queue rather than starting a competing turn.
161		i.busy = true
162		i.compacting = true
163		i.autoCompacting = true
164		if continueExisting {
165			i.continueAfterCompact = true
166		} else {
167			i.pendingCompactPrompt = prompt
168			i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
169			i.hasPendingCompactPrompt = true
170		}
171		i.statusErr = ""
172		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
173		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
174		i.mu.Unlock()
175		if persistResetHandoff {
176			i.persistCompactHandoff(resetHandoff)
177		}
178		i.invalidate()
179		i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
180		return
181	}
182	i.mu.Unlock()
183	if persistResetHandoff {
184		i.persistCompactHandoff(resetHandoff)
185	}
186
187	ctx, cancel := context.WithCancel(parent)
188	i.mu.Lock()
189	i.busy = true
190	i.spin.Start()
191	i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
192	i.cancelTurn = cancel
193	i.statusErr = ""
194	i.statusOK = ""
195	i.streaming.Reset()
196	i.streamOn = true
197	i.pendingAlert = nil
198	i.toolCalls = map[string]*tui.ToolCallView{}
199	i.toolOrder = nil
200	i.toolGate = map[string]int{}
201	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
202	i.scrollOffset = 0 // jump back to the bottom on new turn
203	// Lift the resume tail cap once the user starts interacting. The
204	// cap is purely a first-paint optimization (don't markdown the
205	// whole history before showing anything). Keeping it active during
206	// a turn makes the rendered chat a sliding window: appended
207	// messages push older ones off the TOP of the buffer, which the
208	// renderer must treat as a change above the viewport and repaint
209	// fully, snapping the terminal's native scrollback to the bottom on
210	// every streamed chunk. A fresh session has no cap (append-only),
211	// which is why the jump only shows up in resumed sessions. Dropping
212	// the cap here makes resumed turns append-only too.
213	i.view.TailLimit = 0
214	// Reset the auto-follow baseline so the very next render at
215	// interactive.go:1053 doesn't see a synthetic shrink between
216	// "last frame had the previous turn's tool overlay" and
217	// "this frame had it cleared above". Without this, the guard
218	// reads delta = -(rows in cleared overlay) and decrements
219	// scrollOffset, which on terminals that mirror zut's pane
220	// scroll into the host scrollbar visibly yanks the viewport.
221	// See autofollow_shrink_test.go for the exact arithmetic.
222	i.prevChatLen = 0
223	i.prevChatCols = 0
224	i.parkedTurn = 0 // starting a turn clears the /jump parked state
225	i.parkedTotal = 0
226	i.helpBlock = nil // hide the help block once the user asks something
227	i.mu.Unlock()
228	i.invalidate()
229
230	var (
231		lastStop    provider.StopReason
232		lastTurnErr error
233	)
234	sink := func(ev core.AgentEvent) {
235		if e, ok := ev.(core.EvTurnEnd); ok {
236			lastStop = e.Stop
237			lastTurnErr = e.Err
238		}
239		i.observeGoalRun(ev)
240		i.handleEventForPresentation(ev)
241	}
242
243	releaseCompletionHold := i.beginCompletionDeliveryHold()
244	go func() {
245		defer releaseCompletionHold()
246		var err error
247		if continueExisting {
248			err = i.agent.Continue(ctx, sink)
249		} else {
250			err = i.agent.Prompt(ctx, prompt, images, sink)
251		}
252		i.mu.Lock()
253		// Keep busy asserted through final cleanup and queue selection. A
254		// watcher may enqueue a completion summary concurrently with the
255		// provider's final event; publishing idle before inspecting queues can
256		// strand that summary in the core agent queue or start an overlapping
257		// turn.
258		// Don't touch streamPending / streamFlushPending here — the
259		// pacer may still be draining the final deltas and needs to
260		// paint them even though Prompt has returned. It will reset
261		// streamOn on its own once the buffer empties.
262		if len(i.streamPending) == 0 {
263			i.streamOn = false
264		}
265		i.cancelTurn = nil
266		pendingIdleWork := i.takePendingIdleWorkLocked()
267		if err != nil && ctx.Err() == nil {
268			i.statusErr = err.Error()
269		}
270		// Decide whether to offer a model rescue picker for recoverable
271		// provider failures (auth/rate/temporary). The picker opens after
272		// the mutex is released so it can take its own locks freely.
273		var (
274			offer       bool
275			rescueWhy   string
276			rescueImgs  []provider.ImageBlock
277			rescueModel string
278			rescueProv  string
279			rescueFprov string
280		)
281		if err != nil && ctx.Err() == nil {
282			if ok, reason := classifyRescueError(err); ok {
283				offer = true
284				rescueWhy = reason
285				rescueImgs = images
286				rescueModel = i.cfg.Model
287				rescueProv = i.cfg.Provider
288				rescueFprov = extractFailedProvider(err)
289				if rescueFprov == "" {
290					rescueFprov = i.cfg.Provider
291				}
292				// Suppress the red banner — the rescue dialog already
293				// surfaces the failure.
294				i.statusErr = ""
295			}
296		}
297		// Detect responses that reject the current context, either as an
298		// HTTP 413 payload limit or a model context-window error. Token-
299		// based auto-compact can miss both when metadata is stale or the
300		// limit is measured in raw bytes. Compact once, then continue the
301		// user message already present in the transcript.
302		contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
303		recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
304		if recoverContextOverflow {
305			i.statusErr = ""
306			i.continueAfterCompact = true
307			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
308			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
309		}
310		// Persist the assistant's reply (and every tool row before
311		// it) to the session file while the turn memory is hot.
312		// Without this, WriteNewTranscript only fires at zut exit,
313		// meaning a crash or ungraceful kill drops the whole
314		// conversation. FlushSession is idempotent (it advances the
315		// baseline so subsequent flushes only write new rows).
316		flush := i.cfg.FlushSession
317		i.mu.Unlock()
318		runPendingIdleWork(pendingIdleWork)
319		if flush != nil {
320			flush()
321		}
322		terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
323		if terminalGoalError {
324			i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
325		}
326		continueGoal := i.finishGoalRun()
327		i.mu.Lock()
328		awaitingPre := i.awaitingStartupPre
329		// A newer explicit prompt may have cleared the handoff while the
330		// completed turn was being flushed. Re-read it under the mutex before
331		// deciding whether this continuation may spend another rescue attempt.
332		statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
333		// Pop the next queued message, if any, and relaunch.
334		var next core.QueuedMessage
335		var hasNext bool
336		if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
337			next, i.queued = i.queued[0], i.queued[1:]
338			hasNext = true
339		}
340		// If the turn was cancelled or errored, drop ordinary input so the
341		// user isn't bombarded with stale messages after an interrupt.
342		// Scheduler follow-ups deliberately remain: they are distinct turns
343		// whose due time must not let them steer this failed turn.
344		if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
345			i.queued = nil
346			if i.agent != nil {
347				i.agent.DrainQueuedMessages()
348			}
349		}
350		var scheduled scheduledFollowUp
351		var hasScheduled bool
352		if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
353			scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
354			hasScheduled = true
355		}
356		// Decide whether the next thing to do is an auto-compaction.
357		// Only fires when the turn completed cleanly AND no host-side
358		// or agent-side queued messages are waiting (otherwise a queued
359		// message would race the condense).
360		agentQueued := 0
361		if i.agent != nil {
362			agentQueued = i.agent.QueuedMessageCount()
363		}
364		continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
365		shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
366		continueStatusRescue := false
367		var handoff json.RawMessage
368		var persistHandoff bool
369		if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
370			followUpMessages := i.agent.Messages()
371			reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
372			if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
373				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
374					reason:         compactContinuationStatusRescue,
375					rescueAttempts: i.compactContinuation.rescueAttempts + 1,
376				})
377				continueStatusRescue = true
378			} else {
379				handoff, persistHandoff = i.resetCompactContinuationLocked()
380			}
381		}
382		if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
383			handoff, persistHandoff = i.resetCompactContinuationLocked()
384		}
385		// The agent run can finish before the paced final text reaches the
386		// transcript. A compaction replaces that transcript, so it must never
387		// race the still-live stream frame; otherwise stale deltas can repaint
388		// after the replacement and corrupt the scrollback renderer's model.
389		if recoverContextOverflow || shouldAutoCompact {
390			i.resetStreamingStateLocked()
391		}
392		alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
393		i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
394		i.mu.Unlock()
395		if persistHandoff {
396			i.persistCompactHandoff(handoff)
397		}
398		if alertReason != "" {
399			i.scheduleMainAlert(alertReason)
400		}
401		i.invalidate()
402		parent := i.runCtx
403		if parent == nil {
404			parent = context.Background()
405		}
406		if awaitingPre {
407			i.completeStartupPre()
408			return
409		}
410		switch {
411		case hasNext:
412			i.startTurnWithImages(parent, next.Text, next.Images)
413		case hasScheduled:
414			// The scheduler holds the session transition read lock until this
415			// acknowledgement, so accepting here binds the prompt to the agent
416			// that just completed rather than a subsequently loaded session.
417			scheduled.accepted <- nil
418			i.startTurn(parent, scheduled.text)
419		case continueQueued:
420			i.startTurnRequest(parent, "", nil, true, false)
421		case continueStatusRescue:
422			i.startAutoCompactContinuation(parent)
423		case offer:
424			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
425		case recoverContextOverflow:
426			i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
427		case shouldAutoCompact:
428			i.runCompact(parent, compactContinuationRequest{
429				origin:    compactOriginAfterTurnThreshold,
430				force:     lastStop == provider.StopLength,
431				lastStop:  lastStop,
432				turnError: lastTurnErr,
433			})
434		case continueGoal:
435			i.requestGoalContinuationIfIdle(parent)
436		}
437	}()
438}
439func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
440	if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
441		return ""
442	}
443	if rescue {
444		return "rescue_required"
445	}
446	if stop == provider.StopAborted {
447		return ""
448	}
449	if err != nil || turnErr != nil || stop == provider.StopError {
450		return "agent_error"
451	}
452	if stop == provider.StopLength {
453		return "response_truncated"
454	}
455	return "agent_done"
456}
457func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
458	if i.rescueDialog == nil {
459		return
460	}
461	loggedIn := []string{}
462	if i.cfg.LoggedInProviders != nil {
463		loggedIn = i.cfg.LoggedInProviders()
464	}
465	fprov := failedProvider
466	if fprov == "" {
467		fprov = activeProvider
468	}
469	i.mu.Lock()
470	i.pendingRescuePrompt = prompt
471	i.pendingRescueImages = images
472	i.mu.Unlock()
473	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
474	i.invalidate()
475}
476func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
477	if model == "" {
478		return
479	}
480	i.applyRescueModelSelection(prov, model)
481	i.mu.Lock()
482	images := i.pendingRescueImages
483	if prompt == "" {
484		prompt = i.pendingRescuePrompt
485	}
486	i.pendingRescuePrompt = ""
487	i.pendingRescueImages = nil
488	i.mu.Unlock()
489	parent := i.runCtx
490	if parent == nil {
491		parent = context.Background()
492	}
493	i.startTurnWithImages(parent, prompt, images)
494}
495func stripAutoCompactNotes(notes []string) []string {
496	if len(notes) == 0 {
497		return notes
498	}
499	out := notes[:0]
500	for _, n := range notes {
501		if strings.Contains(n, "condensing history") {
502			continue
503		}
504		out = append(out, n)
505	}
506	return out
507}
508func autoCompactNoteLine(th tui.Theme, msg string) string {
509	return "  " + th.FGColor(th.Warning, "⚠ "+msg)
510}
511func NormalizeAutoCompactThreshold(threshold *int) int {
512	const defaultAutoCompactThreshold = 85
513	if threshold == nil {
514		return defaultAutoCompactThreshold
515	}
516	switch *threshold {
517	case 0, 70, 80, 85, 90, 95:
518		return *threshold
519	default:
520		return defaultAutoCompactThreshold
521	}
522}
523func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
524	if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
525		return false
526	}
527	return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
528}
529func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
530	if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
531		return compactContinuationStructuralTail
532	}
533	if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
534		return compactContinuationNone
535	}
536	if likelyForwardWorkStatus(msgs) {
537		return compactContinuationStatusRescue
538	}
539	return compactContinuationNone
540}
541func structuralCompactionContinuation(msgs []provider.Message) bool {
542	if len(msgs) == 0 {
543		return false
544	}
545	last := msgs[len(msgs)-1]
546	if last.Role != provider.RoleAssistant {
547		return true
548	}
549	hasText := false
550	for _, content := range last.Content {
551		switch block := content.(type) {
552		case provider.ToolCallBlock:
553			return true
554		case provider.TextBlock:
555			if strings.TrimSpace(block.Text) != "" {
556				hasText = true
557			}
558		case provider.ReasoningBlock:
559			// Reasoning without a visible answer is not terminal.
560		default:
561			return true
562		}
563	}
564	return !hasText
565}
566func likelyForwardWorkStatus(msgs []provider.Message) bool {
567	if len(msgs) == 0 {
568		return false
569	}
570	last := msgs[len(msgs)-1]
571	if last.Role != provider.RoleAssistant {
572		return false
573	}
574	var text strings.Builder
575	for _, content := range last.Content {
576		block, ok := content.(provider.TextBlock)
577		if !ok {
578			if _, hasTool := content.(provider.ToolCallBlock); hasTool {
579				return false
580			}
581			continue
582		}
583		if strings.TrimSpace(block.Text) == "" {
584			continue
585		}
586		if text.Len() > 0 {
587			text.WriteByte(' ')
588		}
589		text.WriteString(block.Text)
590	}
591	if text.Len() == 0 {
592		return false
593	}
594	normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
595	normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
596	for _, phrase := range []string{
597		"next i will",
598		"next, i will",
599		"i'll now",
600		"i will now",
601		"i am going to",
602		"i need to continue",
603		"then i will",
604		"then, i will",
605		"i will proceed",
606	} {
607		if containsFutureWorkPhrase(normalized, phrase) {
608			return true
609		}
610	}
611	return false
612}
613func containsFutureWorkPhrase(text, phrase string) bool {
614	for offset := 0; offset < len(text); {
615		index := strings.Index(text[offset:], phrase)
616		if index < 0 {
617			return false
618		}
619		index += offset
620		end := index + len(phrase)
621		if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
622			return true
623		}
624		offset = end
625	}
626	return false
627}
628func wordRuneBefore(text string, index int) bool {
629	if index == 0 {
630		return false
631	}
632	rune_, _ := utf8.DecodeLastRuneInString(text[:index])
633	return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
634}
635func wordRuneAfter(text string, index int) bool {
636	if index == len(text) {
637		return false
638	}
639	rune_, _ := utf8.DecodeRuneInString(text[index:])
640	return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
641}
642func (i *Interactive) shouldAutoCompactLocked() bool {
643	if i.agent == nil {
644		return false
645	}
646	if i.autoCompacting {
647		return false
648	}
649	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
650	if err != nil || m.ContextWindow <= 0 {
651		return false
652	}
653	threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
654	return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
655}

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