v0.50.0 c4ec31f

interactive_compaction.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7
8	"github.com/bnema/zut/packages/core"
9	"github.com/bnema/zut/packages/provider"
10	"github.com/bnema/zut/packages/tui"
11)
12
13func (i *Interactive) clearPendingCompactTurnLocked() {
14	i.pendingCompactPrompt = ""
15	i.pendingCompactImages = nil
16	i.hasPendingCompactPrompt = false
17	i.continueAfterCompact = false
18	i.pendingPostCompactNote = ""
19}
20
21// estimateCompactedContextInput approximates the input tokens of the
22// just-compacted transcript for the status display. It uses the same rough
23// 1-token-per-4-chars rule as compaction's own estimate, summed over the
24// textual timeline content. Empty transcripts yield zero; image bytes are
25// not counted. This is display-only: the next provider usage replaces it,
26// and immediate continuations bypass the pre-turn guard until then.
27func estimateCompactedContextInput(msgs []provider.Message) int {
28	total := 0
29	for _, msg := range msgs {
30		for _, c := range msg.Content {
31			switch v := c.(type) {
32			case provider.TextBlock:
33				total += len(v.Text)
34			case provider.ToolCallBlock:
35				total += len(v.Name) + len(v.Arguments)
36			case provider.ToolResultBlock:
37				for _, inner := range v.Content {
38					if text, ok := inner.(provider.TextBlock); ok {
39						total += len(text.Text)
40					}
41				}
42			case provider.ReasoningBlock:
43				total += len(v.Summary)
44			}
45		}
46	}
47	return total / 4
48}
49func (i *Interactive) compactHandoffLocked() json.RawMessage {
50	return encodeCompactHandoff(i.compactContinuation)
51}
52func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
53	if i.compactContinuation == state {
54		return i.compactHandoffLocked(), false
55	}
56	i.compactContinuation = state
57	return i.compactHandoffLocked(), true
58}
59func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
60	return i.setCompactContinuationLocked(compactContinuationState{})
61}
62func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
63	if persist := i.cfg.PersistCompactHandoff; persist != nil {
64		if err := persist(state); err != nil {
65			i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
66		}
67	}
68}
69func (i *Interactive) resetCompactHandoff() {
70	i.mu.Lock()
71	state, persist := i.resetCompactContinuationLocked()
72	i.mu.Unlock()
73	if persist {
74		i.persistCompactHandoff(state)
75	}
76}
77func (i *Interactive) currentCompactHandoff() json.RawMessage {
78	if current := i.cfg.CurrentCompactHandoff; current != nil {
79		return current()
80	}
81	return nil
82}
83func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
84	i.mu.Lock()
85	i.compactContinuation = state
86	i.mu.Unlock()
87}
88func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
89	state := decodeCompactHandoff(i.currentCompactHandoff())
90	i.restoreCompactHandoff(state)
91	return state
92}
93func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
94	lastHandoff := -1
95	for idx := len(messages) - 1; idx >= 0; idx-- {
96		message := messages[idx]
97		if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
98			lastHandoff = idx
99			break
100		}
101	}
102	if lastHandoff < 0 {
103		return compactHandoffAppendPrompt
104	}
105	for _, message := range messages[lastHandoff+1:] {
106		switch message.Role {
107		case provider.RoleTool:
108			continue
109		case provider.RoleAssistant:
110			hasToolCall := false
111			for _, content := range message.Content {
112				if _, ok := content.(provider.ToolCallBlock); ok {
113					hasToolCall = true
114					break
115				}
116			}
117			if !hasToolCall {
118				return compactHandoffDiscard
119			}
120		default:
121			return compactHandoffDiscard
122		}
123	}
124	return compactHandoffContinueExisting
125}
126func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
127	if parent == nil {
128		parent = i.runCtx
129	}
130	if parent == nil {
131		parent = context.Background()
132	}
133	i.mu.Lock()
134	state := i.compactContinuation
135	ag := i.agent
136	busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
137	if state.reason == compactContinuationNone || ag == nil || busy {
138		i.mu.Unlock()
139		return
140	}
141
142	var next core.QueuedMessage
143	var hasNext bool
144	continueQueued := false
145	resumeGoalCompaction := false
146	resumeGoal := false
147	var resume compactHandoffResume
148	var handoff json.RawMessage
149	var persistHandoff bool
150	switch {
151	case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && len(i.queued) > 0:
152		next, i.queued = i.queued[0], i.queued[1:]
153		hasNext = true
154		handoff, persistHandoff = i.resetCompactContinuationLocked()
155	case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && ag.QueuedMessageCount() > 0:
156		continueQueued = true
157		handoff, persistHandoff = i.resetCompactContinuationLocked()
158	case state.reason == compactContinuationGoalCompactionPending:
159		resumeGoalCompaction = true
160	case state.reason == compactContinuationGoal:
161		resumeGoal = true
162	default:
163		resume = classifyCompactHandoffResume(ag.Messages())
164		if resume == compactHandoffDiscard {
165			handoff, persistHandoff = i.resetCompactContinuationLocked()
166		}
167	}
168	// Reserve the turn slot before persisting or dispatching so concurrent input
169	// can only queue behind the restored handoff, never start a competing turn.
170	starting := hasNext || continueQueued || resumeGoalCompaction || resumeGoal || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
171	i.busy = starting
172	i.mu.Unlock()
173	if persistHandoff {
174		i.persistCompactHandoff(handoff)
175	}
176	if !starting {
177		i.invalidate()
178		return
179	}
180	switch {
181	case hasNext:
182		i.startTurnWithImages(parent, next.Text, next.Images)
183	case continueQueued:
184		i.startTurnRequest(parent, "", nil, true, false)
185	case resumeGoalCompaction:
186		i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery, force: true})
187	case resumeGoal:
188		i.startReservedGoalContinuation(parent)
189	case resume == compactHandoffContinueExisting:
190		i.startTurnRequest(parent, "", nil, true, false)
191	case resume == compactHandoffAppendPrompt:
192		i.startAutoCompactContinuation(parent)
193	}
194}
195func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
196	i.mu.Lock()
197	ag := i.agent
198	if ag == nil {
199		i.busy = false
200		pendingIdleWork := i.takePendingIdleWorkLocked()
201		i.mu.Unlock()
202		runPendingIdleWork(pendingIdleWork)
203		i.invalidate()
204		return
205	}
206	// The caller selected the continuation while holding i.mu. Keep that
207	// ordering through the hidden append so an explicitly queued user prompt
208	// cannot be overtaken by an old-task handoff. Forced truncation is the
209	// existing exception: it keeps priority and the queued prompt runs after it.
210	if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
211		next := i.queued[0]
212		i.queued = i.queued[1:]
213		handoff, persistHandoff := i.resetCompactContinuationLocked()
214		i.mu.Unlock()
215		if persistHandoff {
216			i.persistCompactHandoff(handoff)
217		}
218		i.startTurnWithImages(parent, next.Text, next.Images)
219		return
220	}
221	if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
222		handoff, persistHandoff := i.resetCompactContinuationLocked()
223		i.mu.Unlock()
224		if persistHandoff {
225			i.persistCompactHandoff(handoff)
226		}
227		i.startTurnRequest(parent, "", nil, true, false)
228		return
229	}
230	ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
231		autoCompactContinueMetaKey: "true",
232	})
233	i.mu.Unlock()
234	i.startTurnRequest(parent, "", nil, true, false)
235}
236func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
237	auto := request.origin != compactOriginManual
238	if i.agent == nil {
239		i.mu.Lock()
240		i.statusErr = "not logged in. type /login first."
241		i.mu.Unlock()
242		return
243	}
244	ctx, cancel := context.WithCancel(parent)
245	i.mu.Lock()
246	i.busy = true
247	i.compacting = true
248	i.spin.Start()
249	i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
250	if auto {
251		i.activity.kind = activityCondensingHistory
252		i.autoCompacting = true
253	} else {
254		i.activity.kind = activityCompactingHistory
255	}
256	i.cancelTurn = cancel
257	var initialHandoff json.RawMessage
258	persistInitialHandoff := false
259	if request.origin == compactOriginManual || (request.origin == compactOriginRecovery && i.compactContinuation.reason != compactContinuationGoalCompactionPending) {
260		initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
261	}
262	i.statusErr = ""
263	i.statusOK = ""
264	// Do NOT start the stream presenter: the summary text should not be visible
265	// in the chat while compacting. The user just sees the spinner
266	// and can keep typing / queue prompts.
267	i.scrollOffset = 0
268	i.helpBlock = nil
269	i.mu.Unlock()
270	if persistInitialHandoff {
271		i.persistCompactHandoff(initialHandoff)
272	}
273	i.invalidate()
274
275	go func() {
276		// Sink discards deltas — we don't stream the summary to the UI.
277		sink := func(delta string) {}
278		msgsBefore := i.agent.Messages()
279		i.mu.Lock()
280		statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
281		i.mu.Unlock()
282		continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
283		if request.force {
284			continuationReason = compactContinuationForcedLength
285		}
286		// Keep the usual recent tail when possible, but never let it cover
287		// the whole transcript. A short session can still be at 70–90% of
288		// a model's window because one prompt or tool result is large; the
289		// automatic path must summarize that session instead of failing
290		// before it reaches the compaction provider.
291		keepTail := 4
292		if n := len(msgsBefore); n > 0 && keepTail >= n {
293			keepTail = n - 1
294		}
295		summary, err := i.agent.Compact(ctx, keepTail, sink)
296		_ = summary
297		_, goalActive := i.goalContinuationMessage()
298		i.mu.Lock()
299		// Keep busy/compacting asserted while cleanup and queue selection run.
300		// Completion updates can arrive in this window; clearing busy before
301		// inspecting the queues lets them race a new turn or be stranded.
302		i.stream.Reset()
303		i.cancelTurn = nil
304		i.autoCompacting = false
305		pendingIdleWork := i.takePendingIdleWorkLocked()
306
307		// Drain pending work after the transcript is clean. Overflow recovery
308		// continues the user message already in the transcript. Pre-turn
309		// compaction preserves its complete text-and-images request. Regular
310		// prompts typed during compaction remain in the host queue.
311		var next core.QueuedMessage
312		var hasNext bool
313		var continueExisting bool
314		var continueAutomatically bool
315		var continueGoal bool
316		var handoff json.RawMessage
317		var persistHandoff bool
318
319		switch {
320		case err != nil && ctx.Err() != nil:
321			i.statusErr = ""
322			if auto {
323				i.statusOK = "auto-condense cancelled"
324			} else {
325				i.statusOK = "compaction cancelled"
326			}
327			i.discardQueuedMessagesLocked(false)
328			i.skillPins.recall(i.pendingCompactPrompt)
329			i.clearPendingCompactTurnLocked()
330			handoff, persistHandoff = i.resetCompactContinuationLocked()
331		case err != nil:
332			i.statusErr = "compaction failed: " + err.Error()
333			i.statusOK = ""
334			i.discardQueuedMessagesLocked(true)
335			i.skillPins.recall(i.pendingCompactPrompt)
336			i.clearPendingCompactTurnLocked()
337			handoff, persistHandoff = i.resetCompactContinuationLocked()
338		default:
339			i.statusErr = ""
340			// Read token count from the compaction message meta.
341			tokens := ""
342			msgs := i.agent.Messages()
343			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
344				tokens = msgs[0].Meta["tokens_before"]
345			}
346			switch {
347			case i.pendingPostCompactNote != "":
348				i.statusOK = i.pendingPostCompactNote
349			case tokens != "":
350				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
351			default:
352				i.statusOK = "compacted (ctrl+o to expand)"
353			}
354			i.pendingPostCompactNote = ""
355			i.extNotes = stripAutoCompactNotes(i.extNotes)
356			i.lastCtxInput = estimateCompactedContextInput(msgs)
357			i.toolCalls = map[string]*tui.ToolCallView{}
358			i.toolOrder = nil
359			i.stream.Reset()
360			i.resetTranscriptRenderLocked()
361			switch {
362			case i.continueAfterCompact:
363				continueExisting = true
364				i.continueAfterCompact = false
365			case i.hasPendingCompactPrompt:
366				next = core.QueuedMessage{Text: i.pendingCompactPrompt, Images: i.pendingCompactImages}
367				i.pendingCompactPrompt = ""
368				i.pendingCompactImages = nil
369				i.hasPendingCompactPrompt = false
370				hasNext = true
371			case continuationReason == compactContinuationForcedLength && goalActive:
372				// Preserve the goal as the durable handoff owner so the fresh
373				// turn uses goal accounting and can be resumed after a restart.
374				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoal})
375				continueGoal = !i.coordinatorHasPendingWorkers()
376			case continuationReason == compactContinuationForcedLength:
377				// Forced truncated-output continuation keeps its existing
378				// priority over an explicitly queued prompt.
379				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
380				continueAutomatically = true
381			case len(i.queued) > 0:
382				next, i.queued = i.queued[0], i.queued[1:]
383				hasNext = true
384				handoff, persistHandoff = i.resetCompactContinuationLocked()
385			case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
386				if continuationReason == compactContinuationStatusRescue {
387					attempts := 1
388					if i.compactContinuation.reason == compactContinuationStatusRescue {
389						attempts = i.compactContinuation.rescueAttempts + 1
390					}
391					if attempts > maxStatusRescueContinuations {
392						handoff, persistHandoff = i.resetCompactContinuationLocked()
393						break
394					}
395					handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
396						reason:         continuationReason,
397						rescueAttempts: attempts,
398					})
399				} else {
400					handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
401				}
402				continueAutomatically = true
403			case auto && goalActive && !i.coordinatorHasPendingWorkers():
404				continueGoal = true
405				handoff, persistHandoff = i.resetCompactContinuationLocked()
406			default:
407				handoff, persistHandoff = i.resetCompactContinuationLocked()
408			}
409		}
410		// Keep the host busy until the hand-off decision is committed under
411		// the mutex. A completion update arriving after the compaction result
412		// but before this assignment is then queued for the selected follow-up
413		// instead of starting a competing turn.
414		if hasNext || continueExisting || continueAutomatically || continueGoal {
415			// The estimate above keeps the status display honest, but the
416			// follow-up turn must not trip the pre-turn compaction guard
417			// before fresh provider usage arrives: it runs against the
418			// just-compacted transcript by construction.
419			i.lastCtxInput = 0
420		}
421		i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
422		i.compacting = false
423		i.autoCompacting = false
424		i.mu.Unlock()
425		if persistHandoff {
426			i.persistCompactHandoff(handoff)
427		}
428		runPendingIdleWork(pendingIdleWork)
429		i.invalidate()
430
431		if hasNext || continueExisting || continueAutomatically || continueGoal {
432			p := i.runCtx
433			if p == nil {
434				p = context.Background()
435			}
436			switch {
437			case continueExisting:
438				i.startTurnRequest(p, "", nil, true, true)
439			case hasNext:
440				i.startQueuedTurn(p, next)
441			case continueAutomatically:
442				i.startAutoCompactContinuation(p)
443			case continueGoal:
444				i.startReservedGoalContinuation(p)
445			}
446		}
447	}()
448}

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