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 { | |||
| 100 | 100 | // survive past the ctx of the key event that enqueued them. | |
| 101 | 101 | 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 | 108 | dialog *loginDialog | |
| 104 | 109 | modelDialog *modelDialog | |
| 105 | 110 | sessionDialog *sessionDialog | |
| @@ -387,18 +392,19 @@ func (i *Interactive) redraw() { | |||
| 387 | 392 | ctxMax = m.ContextWindow | |
| 388 | 393 | } | |
| 389 | 394 | 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, | |
| 402 | 408 | }) | |
| 403 | 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 | 734 | case "/sessions": | |
| 729 | 735 | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | |
| 730 | 736 | case "/compact": | |
| 731 | - | i.runCompact(ctx) | |
| 737 | + | i.runCompact(ctx, false) | |
| 732 | 738 | case "/lock": | |
| 733 | 739 | if i.cfg.Sandbox == nil { | |
| 734 | 740 | i.mu.Lock() | |
| @@ -946,7 +952,11 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 946 | 952 | // runCompact invokes core.Agent.Compact and reflects the progress in | |
| 947 | 953 | // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c | |
| 948 | 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 | 960 | if i.agent == nil { | |
| 951 | 961 | i.mu.Lock() | |
| 952 | 962 | i.statusErr = "not logged in. type /login first." | |
| @@ -956,10 +966,16 @@ func (i *Interactive) runCompact(parent context.Context) { | |||
| 956 | 966 | ctx, cancel := context.WithCancel(parent) | |
| 957 | 967 | i.mu.Lock() | |
| 958 | 968 | 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 | 977 | i.cancelTurn = cancel | |
| 961 | 978 | i.statusErr = "" | |
| 962 | - | i.statusOK = "compacting..." | |
| 963 | 979 | i.streaming.Reset() | |
| 964 | 980 | i.streamOn = true | |
| 965 | 981 | i.scrollOffset = 0 | |
| @@ -980,10 +996,15 @@ func (i *Interactive) runCompact(parent context.Context) { | |||
| 980 | 996 | i.streamOn = false | |
| 981 | 997 | i.streaming.Reset() | |
| 982 | 998 | i.cancelTurn = nil | |
| 999 | + | i.autoCompacting = false | |
| 983 | 1000 | switch { | |
| 984 | 1001 | case err != nil && ctx.Err() != nil: | |
| 985 | 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 | 1008 | case err != nil: | |
| 988 | 1009 | i.statusErr = "compaction failed: " + err.Error() | |
| 989 | 1010 | i.statusOK = "" | |
| @@ -1045,18 +1066,51 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||
| 1045 | 1066 | if ctx.Err() != nil || err != nil { | |
| 1046 | 1067 | 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 | 1073 | i.mu.Unlock() | |
| 1049 | 1074 | 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: | |
| 1055 | 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 | 1114 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 1061 | 1115 | i.mu.Lock() | |
| 1062 | 1116 | 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.Context | 101 | 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 *loginDialog | 108 | dialog *loginDialog | ||
| 104 | modelDialog *modelDialog | 109 | modelDialog *modelDialog | ||
| 105 | sessionDialog *sessionDialog | 110 | sessionDialog *sessionDialog | ||
| @@ -387,18 +392,19 @@ func (i *Interactive) redraw() { | |||||
| 387 | ctxMax = m.ContextWindow | 392 | 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 in | 952 | // 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+c | 953 | // 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 = true | 968 | 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 = cancel | 977 | i.cancelTurn = cancel | ||
| 961 | i.statusErr = "" | 978 | i.statusErr = "" | ||
| 962 | - | i.statusOK = "compacting..." | |||
| 963 | i.streaming.Reset() | 979 | i.streaming.Reset() | ||
| 964 | i.streamOn = true | 980 | i.streamOn = true | ||
| 965 | i.scrollOffset = 0 | 981 | i.scrollOffset = 0 | ||
| @@ -980,10 +996,15 @@ func (i *Interactive) runCompact(parent context.Context) { | |||||
| 980 | i.streamOn = false | 996 | i.streamOn = false | ||
| 981 | i.streaming.Reset() | 997 | i.streaming.Reset() | ||
| 982 | i.cancelTurn = nil | 998 | 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 = nil | 1067 | 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.runCtx | 1076 | + | 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 { | |||
| 14 | 14 | startedAt time.Time | |
| 15 | 15 | msgIdx int | |
| 16 | 16 | 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 | 24 | // funnyWorkingLines is the rotating text. Kept deliberately short so it | |
| @@ -59,6 +64,15 @@ func (s *spinner) Start() { | |||
| 59 | 64 | s.startedAt = time.Now() | |
| 60 | 65 | s.msgIdx = rand.Intn(len(s.messages)) | |
| 61 | 66 | 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 | 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 | 89 | // 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 | 93 | func (s *spinner) Message() string { | |
| 94 | + | if s.fixedMsg != "" { | |
| 95 | + | return s.fixedMsg | |
| 96 | + | } | |
| 78 | 97 | if time.Since(s.lastSwap) > 2500*time.Millisecond { | |
| 79 | 98 | s.msgIdx = (s.msgIdx + 1) % len(s.messages) | |
| 80 | 99 | s.lastSwap = time.Now() | |
| @@ -14,6 +14,11 @@ type spinner struct { | |||||
| 14 | startedAt time.Time | 14 | startedAt time.Time | ||
| 15 | msgIdx int | 15 | msgIdx int | ||
| 16 | lastSwap time.Time | 16 | 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 it | 24 | // 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.startedAt | 66 | 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 changes | 89 | // 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) { | |||
| 185 | 185 | ||
| 186 | 186 | func oauthSuccessHTML(provider string) string { | |
| 187 | 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 | 189 | ` + logoTag + ` | |
| 190 | 190 | <h1><span class="mark">✓</span> logged in to ` + p + `</h1> | |
| 191 | 191 | <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 { | |||
| 610 | 610 | ContextUsed int | |
| 611 | 611 | 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 | 618 | Cols int // terminal width; drives right-alignment of cwd | |
| 614 | 619 | } | |
| 615 | 620 | ||
| @@ -648,6 +653,9 @@ func StatusBar(p StatusBarParams) string { | |||
| 648 | 653 | // Context %. Color-coded: yellow >70, red >90. | |
| 649 | 654 | ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax) | |
| 650 | 655 | if ctx != "" { | |
| 656 | + | if p.AutoCompacting { | |
| 657 | + | ctx += " (auto)" | |
| 658 | + | } | |
| 651 | 659 | stats = append(stats, th.FG256(ctxColor, ctx)) | |
| 652 | 660 | } | |
| 653 | 661 | ||
| @@ -610,6 +610,11 @@ type StatusBarParams struct { | |||||
| 610 | ContextUsed int | 610 | ContextUsed int | ||
| 611 | ContextMax int // model's context window; 0 disables the percentage | 611 | 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 cwd | 618 | 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 | ||||