v0.17.0 949637b

interactive_compaction.go RawHistoryFolder

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

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