v0.25.1 025a25b

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	var resume compactHandoffResume
117	var handoff json.RawMessage
118	var persistHandoff bool
119	switch {
120	case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
121		next, i.queued = i.queued[0], i.queued[1:]
122		hasNext = true
123		handoff, persistHandoff = i.resetCompactContinuationLocked()
124	case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
125		continueQueued = true
126		handoff, persistHandoff = i.resetCompactContinuationLocked()
127	default:
128		resume = classifyCompactHandoffResume(ag.Messages())
129		if resume == compactHandoffDiscard {
130			handoff, persistHandoff = i.resetCompactContinuationLocked()
131		}
132	}
133	// Reserve the turn slot before persisting or dispatching so concurrent input
134	// can only queue behind the restored handoff, never start a competing turn.
135	starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
136	i.busy = starting
137	i.mu.Unlock()
138	if persistHandoff {
139		i.persistCompactHandoff(handoff)
140	}
141	if !starting {
142		i.invalidate()
143		return
144	}
145	switch {
146	case hasNext:
147		i.startTurnWithImages(parent, next.Text, next.Images)
148	case continueQueued:
149		i.startTurnRequest(parent, "", nil, true, false)
150	case resume == compactHandoffContinueExisting:
151		i.startTurnRequest(parent, "", nil, true, false)
152	case resume == compactHandoffAppendPrompt:
153		i.startAutoCompactContinuation(parent)
154	}
155}
156func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
157	i.mu.Lock()
158	ag := i.agent
159	if ag == nil {
160		i.busy = false
161		pendingIdleWork := i.takePendingIdleWorkLocked()
162		i.mu.Unlock()
163		runPendingIdleWork(pendingIdleWork)
164		i.invalidate()
165		return
166	}
167	// The caller selected the continuation while holding i.mu. Keep that
168	// ordering through the hidden append so an explicitly queued user prompt
169	// cannot be overtaken by an old-task handoff. Forced truncation is the
170	// existing exception: it keeps priority and the queued prompt runs after it.
171	if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
172		next := i.queued[0]
173		i.queued = i.queued[1:]
174		handoff, persistHandoff := i.resetCompactContinuationLocked()
175		i.mu.Unlock()
176		if persistHandoff {
177			i.persistCompactHandoff(handoff)
178		}
179		i.startTurnWithImages(parent, next.Text, next.Images)
180		return
181	}
182	if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
183		handoff, persistHandoff := i.resetCompactContinuationLocked()
184		i.mu.Unlock()
185		if persistHandoff {
186			i.persistCompactHandoff(handoff)
187		}
188		i.startTurnRequest(parent, "", nil, true, false)
189		return
190	}
191	ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
192		autoCompactContinueMetaKey: "true",
193	})
194	i.mu.Unlock()
195	i.startTurnRequest(parent, "", nil, true, false)
196}
197func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
198	auto := request.origin != compactOriginManual
199	if i.agent == nil {
200		i.mu.Lock()
201		i.statusErr = "not logged in. type /login first."
202		i.mu.Unlock()
203		return
204	}
205	ctx, cancel := context.WithCancel(parent)
206	i.mu.Lock()
207	i.busy = true
208	i.compacting = true
209	i.spin.Start()
210	i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
211	if auto {
212		i.activity.kind = activityCondensingHistory
213		i.autoCompacting = true
214	} else {
215		i.activity.kind = activityCompactingHistory
216	}
217	i.cancelTurn = cancel
218	var initialHandoff json.RawMessage
219	persistInitialHandoff := false
220	if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
221		initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
222	}
223	i.statusErr = ""
224	i.statusOK = ""
225	// Do NOT set streamOn: the summary text should not be visible
226	// in the chat while compacting. The user just sees the spinner
227	// and can keep typing / queue prompts.
228	i.scrollOffset = 0
229	i.helpBlock = nil
230	i.mu.Unlock()
231	if persistInitialHandoff {
232		i.persistCompactHandoff(initialHandoff)
233	}
234	i.invalidate()
235
236	go func() {
237		// Sink discards deltas — we don't stream the summary to the UI.
238		sink := func(delta string) {}
239		msgsBefore := i.agent.Messages()
240		i.mu.Lock()
241		statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
242		i.mu.Unlock()
243		continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
244		if request.force {
245			continuationReason = compactContinuationForcedLength
246		}
247		// Keep the usual recent tail when possible, but never let it cover
248		// the whole transcript. A short session can still be at 70–90% of
249		// a model's window because one prompt or tool result is large; the
250		// automatic path must summarize that session instead of failing
251		// before it reaches the compaction provider.
252		keepTail := 4
253		if n := len(msgsBefore); n > 0 && keepTail >= n {
254			keepTail = n - 1
255		}
256		summary, err := i.agent.Compact(ctx, keepTail, sink)
257		_ = summary
258		goalMessage, goalActive := i.goalContinuationMessage()
259		i.mu.Lock()
260		// Keep busy/compacting asserted while cleanup and queue selection run.
261		// Completion updates can arrive in this window; clearing busy before
262		// inspecting the queues lets them race a new turn or be stranded.
263		i.resetStreamingStateLocked()
264		i.cancelTurn = nil
265		i.autoCompacting = false
266		pendingIdleWork := i.takePendingIdleWorkLocked()
267
268		// Drain pending work after the transcript is clean. Overflow recovery
269		// continues the user message already in the transcript. Pre-turn
270		// compaction preserves its complete text-and-images request. Regular
271		// prompts typed during compaction remain in the host queue.
272		var next string
273		var nextImages []provider.ImageBlock
274		var hasNext bool
275		var continueExisting bool
276		var continueAutomatically bool
277		var continueGoal bool
278		var handoff json.RawMessage
279		var persistHandoff bool
280
281		switch {
282		case err != nil && ctx.Err() != nil:
283			i.statusErr = ""
284			if auto {
285				i.statusOK = "auto-condense cancelled"
286			} else {
287				i.statusOK = "compaction cancelled"
288			}
289			i.queued = nil // drop queue on cancel
290			i.clearPendingCompactTurnLocked()
291			handoff, persistHandoff = i.resetCompactContinuationLocked()
292			if i.agent != nil {
293				i.agent.DrainQueuedMessages()
294			}
295		case err != nil:
296			i.statusErr = "compaction failed: " + err.Error()
297			i.statusOK = ""
298			i.queued = nil // drop queue on error
299			i.clearPendingCompactTurnLocked()
300			handoff, persistHandoff = i.resetCompactContinuationLocked()
301			if i.agent != nil {
302				i.agent.DrainQueuedMessages()
303			}
304		default:
305			i.statusErr = ""
306			// Read token count from the compaction message meta.
307			tokens := ""
308			msgs := i.agent.Messages()
309			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
310				tokens = msgs[0].Meta["tokens_before"]
311			}
312			switch {
313			case i.pendingPostCompactNote != "":
314				i.statusOK = i.pendingPostCompactNote
315			case tokens != "":
316				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
317			default:
318				i.statusOK = "compacted (ctrl+o to expand)"
319			}
320			i.pendingPostCompactNote = ""
321			i.extNotes = stripAutoCompactNotes(i.extNotes)
322			i.lastCtxInput = 0
323			i.toolCalls = map[string]*tui.ToolCallView{}
324			i.toolOrder = nil
325			i.toolGate = map[string]int{}
326			i.resetTranscriptRenderLocked()
327			switch {
328			case i.continueAfterCompact:
329				continueExisting = true
330				i.continueAfterCompact = false
331			case i.hasPendingCompactPrompt:
332				next = i.pendingCompactPrompt
333				nextImages = i.pendingCompactImages
334				i.pendingCompactPrompt = ""
335				i.pendingCompactImages = nil
336				i.hasPendingCompactPrompt = false
337				hasNext = true
338			case continuationReason == compactContinuationForcedLength:
339				// Forced truncated-output continuation keeps its existing
340				// priority over an explicitly queued prompt.
341				handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
342				continueAutomatically = true
343			case len(i.queued) > 0:
344				queued := i.queued[0]
345				i.queued = i.queued[1:]
346				next = queued.Text
347				nextImages = queued.Images
348				hasNext = true
349				handoff, persistHandoff = i.resetCompactContinuationLocked()
350			case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
351				if continuationReason == compactContinuationStatusRescue {
352					attempts := 1
353					if i.compactContinuation.reason == compactContinuationStatusRescue {
354						attempts = i.compactContinuation.rescueAttempts + 1
355					}
356					if attempts > maxStatusRescueContinuations {
357						handoff, persistHandoff = i.resetCompactContinuationLocked()
358						break
359					}
360					handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
361						reason:         continuationReason,
362						rescueAttempts: attempts,
363					})
364				} else {
365					handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
366				}
367				continueAutomatically = true
368			case auto && goalActive && !i.coordinatorHasPendingWorkers():
369				continueGoal = true
370				handoff, persistHandoff = i.resetCompactContinuationLocked()
371			default:
372				handoff, persistHandoff = i.resetCompactContinuationLocked()
373			}
374		}
375		// Keep the host busy until the hand-off decision is committed under
376		// the mutex. A completion update arriving after the compaction result
377		// but before this assignment is then queued for the selected follow-up
378		// instead of starting a competing turn.
379		i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
380		i.compacting = false
381		i.autoCompacting = false
382		i.mu.Unlock()
383		if persistHandoff {
384			i.persistCompactHandoff(handoff)
385		}
386		runPendingIdleWork(pendingIdleWork)
387		i.invalidate()
388
389		if hasNext || continueExisting || continueAutomatically || continueGoal {
390			p := i.runCtx
391			if p == nil {
392				p = context.Background()
393			}
394			switch {
395			case continueExisting:
396				i.startTurnRequest(p, "", nil, true, true)
397			case hasNext:
398				i.startTurnWithImages(p, next, nextImages)
399			case continueAutomatically:
400				i.startAutoCompactContinuation(p)
401			case continueGoal:
402				var run *goalContinuationRun
403				if i.cfg.CurrentGoal != nil {
404					goal := copySessionGoal(i.cfg.CurrentGoal())
405					if !i.limitGoalBeforeRun(goal) {
406						run, err = i.startGoalRun(goal)
407					}
408				}
409				if err != nil || run == nil {
410					i.mu.Lock()
411					i.busy = false
412					i.mu.Unlock()
413					if err != nil {
414						i.ReportError(err)
415					}
416					return
417				}
418				i.startGoalContinuation(p, goalMessage, run)
419			}
420		}
421	}()
422}

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