v0.38.0 58133ea

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 set streamOn: 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.resetStreamingStateLocked()
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.clearPendingCompactTurnLocked()
329			handoff, persistHandoff = i.resetCompactContinuationLocked()
330		case err != nil:
331			i.statusErr = "compaction failed: " + err.Error()
332			i.statusOK = ""
333			i.discardQueuedMessagesLocked(true)
334			i.clearPendingCompactTurnLocked()
335			handoff, persistHandoff = i.resetCompactContinuationLocked()
336		default:
337			i.statusErr = ""
338			// Read token count from the compaction message meta.
339			tokens := ""
340			msgs := i.agent.Messages()
341			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
342				tokens = msgs[0].Meta["tokens_before"]
343			}
344			switch {
345			case i.pendingPostCompactNote != "":
346				i.statusOK = i.pendingPostCompactNote
347			case tokens != "":
348				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
349			default:
350				i.statusOK = "compacted (ctrl+o to expand)"
351			}
352			i.pendingPostCompactNote = ""
353			i.extNotes = stripAutoCompactNotes(i.extNotes)
354			i.lastCtxInput = estimateCompactedContextInput(msgs)
355			i.toolCalls = map[string]*tui.ToolCallView{}
356			i.toolOrder = nil
357			i.toolGate = map[string]int{}
358			i.resetTranscriptRenderLocked()
359			switch {
360			case i.continueAfterCompact:
361				continueExisting = true
362				i.continueAfterCompact = false
363			case i.hasPendingCompactPrompt:
364				next = core.QueuedMessage{Text: i.pendingCompactPrompt, Images: i.pendingCompactImages}
365				i.pendingCompactPrompt = ""
366				i.pendingCompactImages = nil
367				i.hasPendingCompactPrompt = false
368				hasNext = true
369			case continuationReason == compactContinuationForcedLength && goalActive:
370				// Preserve the goal as the durable handoff owner so the fresh
371				// turn uses goal accounting and can be resumed after a restart.
372				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoal})
373				continueGoal = !i.coordinatorHasPendingWorkers()
374			case continuationReason == compactContinuationForcedLength:
375				// Forced truncated-output continuation keeps its existing
376				// priority over an explicitly queued prompt.
377				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
378				continueAutomatically = true
379			case len(i.queued) > 0:
380				next, i.queued = i.queued[0], i.queued[1:]
381				hasNext = true
382				handoff, persistHandoff = i.resetCompactContinuationLocked()
383			case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
384				if continuationReason == compactContinuationStatusRescue {
385					attempts := 1
386					if i.compactContinuation.reason == compactContinuationStatusRescue {
387						attempts = i.compactContinuation.rescueAttempts + 1
388					}
389					if attempts > maxStatusRescueContinuations {
390						handoff, persistHandoff = i.resetCompactContinuationLocked()
391						break
392					}
393					handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
394						reason:         continuationReason,
395						rescueAttempts: attempts,
396					})
397				} else {
398					handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
399				}
400				continueAutomatically = true
401			case auto && goalActive && !i.coordinatorHasPendingWorkers():
402				continueGoal = true
403				handoff, persistHandoff = i.resetCompactContinuationLocked()
404			default:
405				handoff, persistHandoff = i.resetCompactContinuationLocked()
406			}
407		}
408		// Keep the host busy until the hand-off decision is committed under
409		// the mutex. A completion update arriving after the compaction result
410		// but before this assignment is then queued for the selected follow-up
411		// instead of starting a competing turn.
412		if hasNext || continueExisting || continueAutomatically || continueGoal {
413			// The estimate above keeps the status display honest, but the
414			// follow-up turn must not trip the pre-turn compaction guard
415			// before fresh provider usage arrives: it runs against the
416			// just-compacted transcript by construction.
417			i.lastCtxInput = 0
418		}
419		i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
420		i.compacting = false
421		i.autoCompacting = false
422		i.mu.Unlock()
423		if persistHandoff {
424			i.persistCompactHandoff(handoff)
425		}
426		runPendingIdleWork(pendingIdleWork)
427		i.invalidate()
428
429		if hasNext || continueExisting || continueAutomatically || continueGoal {
430			p := i.runCtx
431			if p == nil {
432				p = context.Background()
433			}
434			switch {
435			case continueExisting:
436				i.startTurnRequest(p, "", nil, true, true)
437			case hasNext:
438				i.startQueuedTurn(p, next)
439			case continueAutomatically:
440				i.startAutoCompactContinuation(p)
441			case continueGoal:
442				i.startReservedGoalContinuation(p)
443			}
444		}
445	}()
446}

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