v0.31.0 9467429

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

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