detached 6324668

add auto compaction

commit
6324668df886bb2a23921cdb754c856650ebae05
author
patriceckhart · · 2026-04-18 08:34 UTC
parent
dbe6763
signature
unsigned
4 files · +105 -24
M internal/agent/modes/interactive.go +76 -22
@@ -100,6 +100,11 @@ type Interactive struct {
100100 // survive past the ctx of the key event that enqueued them.
101101 runCtx context.Context
102102
103+ // autoCompacting is true while a model-triggered compaction is in
104+ // flight. Surfaced in the status bar so the user can tell a
105+ // condense pass from a regular assistant turn.
106+ autoCompacting bool
107+
103108 dialog *loginDialog
104109 modelDialog *modelDialog
105110 sessionDialog *sessionDialog
@@ -387,18 +392,19 @@ func (i *Interactive) redraw() {
387392 ctxMax = m.ContextWindow
388393 }
389394 status := tui.StatusBar(tui.StatusBarParams{
390- Theme: i.cfg.Theme,
391- Provider: i.cfg.Provider,
392- Model: i.cfg.Model,
393- Busy: i.busy,
394- BusyPrefix: busyPrefix,
395- CWD: i.cfg.CWD,
396- Locked: i.cfg.Sandbox.Locked(),
397- Usage: i.cumUsage,
398- Subscription: i.cfg.AuthMethod == "oauth",
399- ContextUsed: i.lastCtxInput,
400- ContextMax: ctxMax,
401- Cols: cols,
395+ Theme: i.cfg.Theme,
396+ Provider: i.cfg.Provider,
397+ Model: i.cfg.Model,
398+ Busy: i.busy,
399+ BusyPrefix: busyPrefix,
400+ CWD: i.cfg.CWD,
401+ Locked: i.cfg.Sandbox.Locked(),
402+ Usage: i.cumUsage,
403+ Subscription: i.cfg.AuthMethod == "oauth",
404+ ContextUsed: i.lastCtxInput,
405+ ContextMax: ctxMax,
406+ AutoCompacting: i.autoCompacting,
407+ Cols: cols,
402408 })
403409 edLines, curR, curC := i.ed.Render(cols)
404410
@@ -728,7 +734,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
728734 case "/sessions":
729735 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
730736 case "/compact":
731- i.runCompact(ctx)
737+ i.runCompact(ctx, false)
732738 case "/lock":
733739 if i.cfg.Sandbox == nil {
734740 i.mu.Lock()
@@ -946,7 +952,11 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
946952 // runCompact invokes core.Agent.Compact and reflects the progress in
947953 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
948954 // cancel via the same cancelTurn channel used for normal turns.
949-func (i *Interactive) runCompact(parent context.Context) {
955+//
956+// When auto is true the spinner message is pinned to "condensing
957+// history" and the status bar surfaces "(auto)" next to the context
958+// percentage so it's obvious the system triggered this, not the user.
959+func (i *Interactive) runCompact(parent context.Context, auto bool) {
950960 if i.agent == nil {
951961 i.mu.Lock()
952962 i.statusErr = "not logged in. type /login first."
@@ -956,10 +966,16 @@ func (i *Interactive) runCompact(parent context.Context) {
956966 ctx, cancel := context.WithCancel(parent)
957967 i.mu.Lock()
958968 i.busy = true
959- i.spin.Start()
969+ if auto {
970+ i.spin.StartFixed("condensing history")
971+ i.autoCompacting = true
972+ i.statusOK = "condensing history… (esc to cancel)"
973+ } else {
974+ i.spin.Start()
975+ i.statusOK = "compacting..."
976+ }
960977 i.cancelTurn = cancel
961978 i.statusErr = ""
962- i.statusOK = "compacting..."
963979 i.streaming.Reset()
964980 i.streamOn = true
965981 i.scrollOffset = 0
@@ -980,10 +996,15 @@ func (i *Interactive) runCompact(parent context.Context) {
980996 i.streamOn = false
981997 i.streaming.Reset()
982998 i.cancelTurn = nil
999+ i.autoCompacting = false
9831000 switch {
9841001 case err != nil && ctx.Err() != nil:
9851002 i.statusErr = ""
986- i.statusOK = "compaction cancelled"
1003+ if auto {
1004+ i.statusOK = "auto-condense cancelled"
1005+ } else {
1006+ i.statusOK = "compaction cancelled"
1007+ }
9871008 case err != nil:
9881009 i.statusErr = "compaction failed: " + err.Error()
9891010 i.statusOK = ""
@@ -1045,18 +1066,51 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
10451066 if ctx.Err() != nil || err != nil {
10461067 i.queued = nil
10471068 }
1069+ // Decide whether the next thing to do is an auto-compaction.
1070+ // Only fires when the turn completed cleanly AND the queue is
1071+ // empty (otherwise a queued message would race the condense).
1072+ shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
10481073 i.mu.Unlock()
10491074 i.invalidate()
1050- if hasNext {
1051- parent := i.runCtx
1052- if parent == nil {
1053- parent = context.Background()
1054- }
1075+ parent := i.runCtx
1076+ if parent == nil {
1077+ parent = context.Background()
1078+ }
1079+ switch {
1080+ case hasNext:
10551081 i.startTurn(parent, next)
1082+ case shouldAutoCompact:
1083+ i.runCompact(parent, true)
10561084 }
10571085 }()
10581086 }
10591087
1088+// autoCompactThreshold is the context-window fraction at which the
1089+// agent will auto-compact after a turn ends. 0.85 leaves enough
1090+// headroom for one more user prompt + response before we bump the
1091+// hard limit.
1092+const autoCompactThreshold = 0.85
1093+
1094+// shouldAutoCompactLocked reports whether the last turn pushed context
1095+// usage past the auto-compact threshold. Must be called with i.mu
1096+// held; it reads lastCtxInput and the current model's context window.
1097+func (i *Interactive) shouldAutoCompactLocked() bool {
1098+ if i.agent == nil {
1099+ return false
1100+ }
1101+ if i.autoCompacting {
1102+ return false
1103+ }
1104+ m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1105+ if err != nil || m.ContextWindow <= 0 {
1106+ return false
1107+ }
1108+ if i.lastCtxInput <= 0 {
1109+ return false
1110+ }
1111+ return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1112+}
1113+
10601114 func (i *Interactive) handleEvent(ev core.AgentEvent) {
10611115 i.mu.Lock()
10621116 defer i.mu.Unlock()
@@ -100,6 +100,11 @@ type Interactive struct {
100 // survive past the ctx of the key event that enqueued them.100 // survive past the ctx of the key event that enqueued them.
101 runCtx context.Context101 runCtx context.Context
102 102
103+ // autoCompacting is true while a model-triggered compaction is in
104+ // flight. Surfaced in the status bar so the user can tell a
105+ // condense pass from a regular assistant turn.
106+ autoCompacting bool
107+
103 dialog *loginDialog108 dialog *loginDialog
104 modelDialog *modelDialog109 modelDialog *modelDialog
105 sessionDialog *sessionDialog110 sessionDialog *sessionDialog
@@ -387,18 +392,19 @@ func (i *Interactive) redraw() {
387 ctxMax = m.ContextWindow392 ctxMax = m.ContextWindow
388 }393 }
389 status := tui.StatusBar(tui.StatusBarParams{394 status := tui.StatusBar(tui.StatusBarParams{
390- Theme: i.cfg.Theme,395+ Theme: i.cfg.Theme,
391- Provider: i.cfg.Provider,396+ Provider: i.cfg.Provider,
392- Model: i.cfg.Model,397+ Model: i.cfg.Model,
393- Busy: i.busy,398+ Busy: i.busy,
394- BusyPrefix: busyPrefix,399+ BusyPrefix: busyPrefix,
395- CWD: i.cfg.CWD,400+ CWD: i.cfg.CWD,
396- Locked: i.cfg.Sandbox.Locked(),401+ Locked: i.cfg.Sandbox.Locked(),
397- Usage: i.cumUsage,402+ Usage: i.cumUsage,
398- Subscription: i.cfg.AuthMethod == "oauth",403+ Subscription: i.cfg.AuthMethod == "oauth",
399- ContextUsed: i.lastCtxInput,404+ ContextUsed: i.lastCtxInput,
400- ContextMax: ctxMax,405+ ContextMax: ctxMax,
401- Cols: cols,406+ AutoCompacting: i.autoCompacting,
407+ Cols: cols,
402 })408 })
403 edLines, curR, curC := i.ed.Render(cols)409 edLines, curR, curC := i.ed.Render(cols)
404 410
@@ -728,7 +734,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
728 case "/sessions":734 case "/sessions":
729 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)735 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
730 case "/compact":736 case "/compact":
731- i.runCompact(ctx)737+ i.runCompact(ctx, false)
732 case "/lock":738 case "/lock":
733 if i.cfg.Sandbox == nil {739 if i.cfg.Sandbox == nil {
734 i.mu.Lock()740 i.mu.Lock()
@@ -946,7 +952,11 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
946 // runCompact invokes core.Agent.Compact and reflects the progress in952 // runCompact invokes core.Agent.Compact and reflects the progress in
947 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c953 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
948 // cancel via the same cancelTurn channel used for normal turns.954 // cancel via the same cancelTurn channel used for normal turns.
949-func (i *Interactive) runCompact(parent context.Context) {955+//
956+// When auto is true the spinner message is pinned to "condensing
957+// history" and the status bar surfaces "(auto)" next to the context
958+// percentage so it's obvious the system triggered this, not the user.
959+func (i *Interactive) runCompact(parent context.Context, auto bool) {
950 if i.agent == nil {960 if i.agent == nil {
951 i.mu.Lock()961 i.mu.Lock()
952 i.statusErr = "not logged in. type /login first."962 i.statusErr = "not logged in. type /login first."
@@ -956,10 +966,16 @@ func (i *Interactive) runCompact(parent context.Context) {
956 ctx, cancel := context.WithCancel(parent)966 ctx, cancel := context.WithCancel(parent)
957 i.mu.Lock()967 i.mu.Lock()
958 i.busy = true968 i.busy = true
959- i.spin.Start()969+ if auto {
970+ i.spin.StartFixed("condensing history")
971+ i.autoCompacting = true
972+ i.statusOK = "condensing history… (esc to cancel)"
973+ } else {
974+ i.spin.Start()
975+ i.statusOK = "compacting..."
976+ }
960 i.cancelTurn = cancel977 i.cancelTurn = cancel
961 i.statusErr = ""978 i.statusErr = ""
962- i.statusOK = "compacting..."
963 i.streaming.Reset()979 i.streaming.Reset()
964 i.streamOn = true980 i.streamOn = true
965 i.scrollOffset = 0981 i.scrollOffset = 0
@@ -980,10 +996,15 @@ func (i *Interactive) runCompact(parent context.Context) {
980 i.streamOn = false996 i.streamOn = false
981 i.streaming.Reset()997 i.streaming.Reset()
982 i.cancelTurn = nil998 i.cancelTurn = nil
999+ i.autoCompacting = false
983 switch {1000 switch {
984 case err != nil && ctx.Err() != nil:1001 case err != nil && ctx.Err() != nil:
985 i.statusErr = ""1002 i.statusErr = ""
986- i.statusOK = "compaction cancelled"1003+ if auto {
1004+ i.statusOK = "auto-condense cancelled"
1005+ } else {
1006+ i.statusOK = "compaction cancelled"
1007+ }
987 case err != nil:1008 case err != nil:
988 i.statusErr = "compaction failed: " + err.Error()1009 i.statusErr = "compaction failed: " + err.Error()
989 i.statusOK = ""1010 i.statusOK = ""
@@ -1045,18 +1066,51 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
1045 if ctx.Err() != nil || err != nil {1066 if ctx.Err() != nil || err != nil {
1046 i.queued = nil1067 i.queued = nil
1047 }1068 }
1069+ // Decide whether the next thing to do is an auto-compaction.
1070+ // Only fires when the turn completed cleanly AND the queue is
1071+ // empty (otherwise a queued message would race the condense).
1072+ shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1048 i.mu.Unlock()1073 i.mu.Unlock()
1049 i.invalidate()1074 i.invalidate()
1050- if hasNext {1075+ parent := i.runCtx
1051- parent := i.runCtx1076+ if parent == nil {
1052- if parent == nil {1077+ parent = context.Background()
1053- parent = context.Background()1078+ }
1054- }1079+ switch {
1080+ case hasNext:
1055 i.startTurn(parent, next)1081 i.startTurn(parent, next)
1082+ case shouldAutoCompact:
1083+ i.runCompact(parent, true)
1056 }1084 }
1057 }()1085 }()
1058 }1086 }
1059 1087
1088+// autoCompactThreshold is the context-window fraction at which the
1089+// agent will auto-compact after a turn ends. 0.85 leaves enough
1090+// headroom for one more user prompt + response before we bump the
1091+// hard limit.
1092+const autoCompactThreshold = 0.85
1093+
1094+// shouldAutoCompactLocked reports whether the last turn pushed context
1095+// usage past the auto-compact threshold. Must be called with i.mu
1096+// held; it reads lastCtxInput and the current model's context window.
1097+func (i *Interactive) shouldAutoCompactLocked() bool {
1098+ if i.agent == nil {
1099+ return false
1100+ }
1101+ if i.autoCompacting {
1102+ return false
1103+ }
1104+ m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1105+ if err != nil || m.ContextWindow <= 0 {
1106+ return false
1107+ }
1108+ if i.lastCtxInput <= 0 {
1109+ return false
1110+ }
1111+ return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1112+}
1113+
1060 func (i *Interactive) handleEvent(ev core.AgentEvent) {1114 func (i *Interactive) handleEvent(ev core.AgentEvent) {
1061 i.mu.Lock()1115 i.mu.Lock()
1062 defer i.mu.Unlock()1116 defer i.mu.Unlock()
M internal/agent/modes/spinner.go +20 -1
@@ -14,6 +14,11 @@ type spinner struct {
1414 startedAt time.Time
1515 msgIdx int
1616 lastSwap time.Time
17+
18+ // fixedMsg overrides the rotating funnyWorkingLines message when
19+ // set. Used for auto-compaction so the spinner clearly says what's
20+ // happening instead of cycling jokes.
21+ fixedMsg string
1722 }
1823
1924 // funnyWorkingLines is the rotating text. Kept deliberately short so it
@@ -59,6 +64,15 @@ func (s *spinner) Start() {
5964 s.startedAt = time.Now()
6065 s.msgIdx = rand.Intn(len(s.messages))
6166 s.lastSwap = s.startedAt
67+ s.fixedMsg = ""
68+}
69+
70+// StartFixed is like Start but pins the status text to msg for the
71+// duration of this spinner run. Cleared by the next Start() call.
72+func (s *spinner) StartFixed(msg string) {
73+ s.startedAt = time.Now()
74+ s.lastSwap = s.startedAt
75+ s.fixedMsg = msg
6276 }
6377
6478 // Frame returns the current spinner glyph for the running animation.
@@ -73,8 +87,13 @@ func (s *spinner) Frame() string {
7387 }
7488
7589 // Message returns the current rotating status text. The text changes
76-// every ~2.5 seconds so the spinner doesn't look frozen.
90+// every ~2.5 seconds so the spinner doesn't look frozen. When the
91+// spinner was started via StartFixed, the pinned message is returned
92+// unchanged.
7793 func (s *spinner) Message() string {
94+ if s.fixedMsg != "" {
95+ return s.fixedMsg
96+ }
7897 if time.Since(s.lastSwap) > 2500*time.Millisecond {
7998 s.msgIdx = (s.msgIdx + 1) % len(s.messages)
8099 s.lastSwap = time.Now()
@@ -14,6 +14,11 @@ type spinner struct {
14 startedAt time.Time14 startedAt time.Time
15 msgIdx int15 msgIdx int
16 lastSwap time.Time16 lastSwap time.Time
17+
18+ // fixedMsg overrides the rotating funnyWorkingLines message when
19+ // set. Used for auto-compaction so the spinner clearly says what's
20+ // happening instead of cycling jokes.
21+ fixedMsg string
17 }22 }
18 23
19 // funnyWorkingLines is the rotating text. Kept deliberately short so it24 // funnyWorkingLines is the rotating text. Kept deliberately short so it
@@ -59,6 +64,15 @@ func (s *spinner) Start() {
59 s.startedAt = time.Now()64 s.startedAt = time.Now()
60 s.msgIdx = rand.Intn(len(s.messages))65 s.msgIdx = rand.Intn(len(s.messages))
61 s.lastSwap = s.startedAt66 s.lastSwap = s.startedAt
67+ s.fixedMsg = ""
68+}
69+
70+// StartFixed is like Start but pins the status text to msg for the
71+// duration of this spinner run. Cleared by the next Start() call.
72+func (s *spinner) StartFixed(msg string) {
73+ s.startedAt = time.Now()
74+ s.lastSwap = s.startedAt
75+ s.fixedMsg = msg
62 }76 }
63 77
64 // Frame returns the current spinner glyph for the running animation.78 // Frame returns the current spinner glyph for the running animation.
@@ -73,8 +87,13 @@ func (s *spinner) Frame() string {
73 }87 }
74 88
75 // Message returns the current rotating status text. The text changes89 // Message returns the current rotating status text. The text changes
76-// every ~2.5 seconds so the spinner doesn't look frozen.90+// every ~2.5 seconds so the spinner doesn't look frozen. When the
91+// spinner was started via StartFixed, the pinned message is returned
92+// unchanged.
77 func (s *spinner) Message() string {93 func (s *spinner) Message() string {
94+ if s.fixedMsg != "" {
95+ return s.fixedMsg
96+ }
78 if time.Since(s.lastSwap) > 2500*time.Millisecond {97 if time.Since(s.lastSwap) > 2500*time.Millisecond {
79 s.msgIdx = (s.msgIdx + 1) % len(s.messages)98 s.msgIdx = (s.msgIdx + 1) % len(s.messages)
80 s.lastSwap = time.Now()99 s.lastSwap = time.Now()
M internal/auth/callback.go +1 -1
@@ -185,7 +185,7 @@ func serveLogo(w http.ResponseWriter, r *http.Request) {
185185
186186 func oauthSuccessHTML(provider string) string {
187187 p := strings.ToLower(provider)
188- return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body>
188+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
189189 ` + logoTag + `
190190 <h1><span class="mark">✓</span> logged in to ` + p + `</h1>
191191 <hr class="rule">
@@ -185,7 +185,7 @@ func serveLogo(w http.ResponseWriter, r *http.Request) {
185 185
186 func oauthSuccessHTML(provider string) string {186 func oauthSuccessHTML(provider string) string {
187 p := strings.ToLower(provider)187 p := strings.ToLower(provider)
188- return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body>188+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
189 ` + logoTag + `189 ` + logoTag + `
190 <h1><span class="mark">✓</span> logged in to ` + p + `</h1>190 <h1><span class="mark">✓</span> logged in to ` + p + `</h1>
191 <hr class="rule">191 <hr class="rule">
M internal/tui/view.go +8 -0
@@ -610,6 +610,11 @@ type StatusBarParams struct {
610610 ContextUsed int
611611 ContextMax int // model's context window; 0 disables the percentage
612612
613+ // AutoCompacting is true when the agent is currently running a
614+ // model-triggered condense pass. Surfaces as "(auto)" after the
615+ // context percentage so it's clear where the spinner is coming from.
616+ AutoCompacting bool
617+
613618 Cols int // terminal width; drives right-alignment of cwd
614619 }
615620
@@ -648,6 +653,9 @@ func StatusBar(p StatusBarParams) string {
648653 // Context %. Color-coded: yellow >70, red >90.
649654 ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax)
650655 if ctx != "" {
656+ if p.AutoCompacting {
657+ ctx += " (auto)"
658+ }
651659 stats = append(stats, th.FG256(ctxColor, ctx))
652660 }
653661
@@ -610,6 +610,11 @@ type StatusBarParams struct {
610 ContextUsed int610 ContextUsed int
611 ContextMax int // model's context window; 0 disables the percentage611 ContextMax int // model's context window; 0 disables the percentage
612 612
613+ // AutoCompacting is true when the agent is currently running a
614+ // model-triggered condense pass. Surfaces as "(auto)" after the
615+ // context percentage so it's clear where the spinner is coming from.
616+ AutoCompacting bool
617+
613 Cols int // terminal width; drives right-alignment of cwd618 Cols int // terminal width; drives right-alignment of cwd
614 }619 }
615 620
@@ -648,6 +653,9 @@ func StatusBar(p StatusBarParams) string {
648 // Context %. Color-coded: yellow >70, red >90.653 // Context %. Color-coded: yellow >70, red >90.
649 ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax)654 ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax)
650 if ctx != "" {655 if ctx != "" {
656+ if p.AutoCompacting {
657+ ctx += " (auto)"
658+ }
651 stats = append(stats, th.FG256(ctxColor, ctx))659 stats = append(stats, th.FG256(ctxColor, ctx))
652 }660 }
653 661

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