Skip to content

Commit eedbd9f

Browse files
committed
Context governor UX: compaction events, checkpoints, API usage footer
Emit compact stream events to the TUI with spinner status; save pre-compaction checkpoints under ~/.hawk/sessions/<id>/checkpoints; prefer API prompt tokens in the footer when usage is available; add Anthropic provider-native compaction (compact_20260112) when a direct API key and supported Claude model are present.
1 parent 5dc7707 commit eedbd9f

19 files changed

Lines changed: 571 additions & 16 deletions

cmd/chat.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
241241
if err != nil {
242242
return chatModel{}, err
243243
}
244+
bindChatSession(sess, sid)
244245

245246
// Initialize conversation DAG for branching support
246247
if home, err := os.UserHomeDir(); err == nil {
@@ -926,6 +927,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
926927
return m, nil
927928

928929
case streamChunkMsg:
930+
m.compacting = false
931+
m.compactStatus = ""
929932
m.partial.WriteString(string(msg))
930933
m.markPartialDirty()
931934
return m, nil
@@ -977,10 +980,26 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
977980
if msg.usage != nil {
978981
m.turnInputTokens += msg.usage.PromptTokens
979982
m.turnOutputTokens += msg.usage.CompletionTokens
983+
m.invalidateConnStatus()
980984
m.viewDirty = true
981985
}
982986

987+
case compactMsg:
988+
m.compacting = false
989+
m.compactStatus = ""
990+
line := fmt.Sprintf("Context compacted (%s): ~%s → ~%s tokens",
991+
msg.strategy,
992+
formatHawkTokenCount(msg.tokensBefore),
993+
formatHawkTokenCount(msg.tokensAfter),
994+
)
995+
m.messages = append(m.messages, displayMsg{role: "system", content: line})
996+
m.invalidateConnStatus()
997+
m.viewDirty = true
998+
return m, nil
999+
9831000
case streamDoneMsg:
1001+
m.compacting = false
1002+
m.compactStatus = ""
9841003
m.flushPartialDirty()
9851004
if m.partial.Len() > 0 {
9861005
content := sanitizeIdentity(m.partial.String())
@@ -1216,9 +1235,13 @@ func runChat() error {
12161235
p.Send(toolUseMsg{name: ev.ToolName, id: ev.ToolID})
12171236
case "tool_result":
12181237
p.Send(toolResultMsg{name: ev.ToolName, content: ev.Content})
1238+
case "compact":
1239+
p.Send(compactMsg{
1240+
strategy: ev.Content,
1241+
tokensBefore: ev.TokensBefore,
1242+
tokensAfter: ev.TokensAfter,
1243+
})
12191244
case "usage":
1220-
// Forward usage events to TUI so the spinner line can show
1221-
// running ↑/↓ token counts for the current turn.
12221245
if ev.Usage != nil {
12231246
p.Send(usageUpdateMsg{usage: ev.Usage})
12241247
}

cmd/chat_commands_session.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string
7373
msg = fmt.Sprintf("Compacted with fallback: %d → %d messages", before, after)
7474
}
7575
m.messages = append(m.messages, displayMsg{role: "system", content: msg})
76+
m.compacting = false
77+
m.compactStatus = ""
7678
m.invalidateConnStatus()
7779
return m, nil
7880

cmd/chat_model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ type (
8484
blinkTickMsg struct{}
8585
spinnerVerbTickMsg struct{}
8686
usageUpdateMsg struct{ usage *engine.StreamUsage }
87+
compactMsg struct {
88+
strategy string
89+
tokensBefore, tokensAfter int
90+
}
8791
)
8892

8993
type (
@@ -174,6 +178,8 @@ type chatModel struct {
174178
// Reset each time the user submits a message; updated by usageUpdateMsg.
175179
turnInputTokens int
176180
turnOutputTokens int
181+
compacting bool // true while context governor runs
182+
compactStatus string // short label on spinner line
177183
// Display values lerped toward the turn targets each render frame
178184
// (factor 0.10). Smooths the counter animation.
179185
displayInTok float64

cmd/chat_status.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ func (m *chatModel) invalidateConnStatus() {
5252
func (m chatModel) connStatusFingerprint() string {
5353
gw, model := m.sessionGatewayModel()
5454
creds := strings.Join(hawkconfig.ConfiguredCredentialProviders(), ",")
55+
api := 0
56+
if m.session != nil {
57+
api = m.session.LastPromptTokens()
58+
}
5559
used := sessionContextUsedTokens(m.session)
56-
return gw + "\x00" + model + "\x00" + creds + "\x00" + fmt.Sprintf("%d", used)
60+
return gw + "\x00" + model + "\x00" + creds + "\x00" + fmt.Sprintf("%d", used) + "\x00" + fmt.Sprintf("%d", api)
5761
}
5862

5963
func (m chatModel) sessionGatewayModel() (gateway, model string) {
@@ -245,7 +249,7 @@ func sessionContextUsedTokens(sess *engine.Session) int {
245249
if sess == nil {
246250
return 0
247251
}
248-
return engine.EstimateTokens(sess.RawMessages())
252+
return sess.ContextUsedTokens()
249253
}
250254

251255
func contextUsagePercent(m chatModel, windowLabel string) int {

cmd/chat_status_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ func TestContextPercentColor(t *testing.T) {
6666
pct int
6767
want lipgloss.Color
6868
}{
69-
{0, successTeal},
70-
{50, successTeal},
71-
{79, successTeal},
69+
{0, doneGreen},
70+
{50, doneGreen},
71+
{79, doneGreen},
7272
{80, warnAmber},
7373
{94, warnAmber},
7474
{95, errorCoral},

cmd/chat_stream.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ func (m *chatModel) startPromptCommand(display, prompt string) (tea.Model, tea.C
2121

2222
func (m *chatModel) startStream() {
2323
m.syncSessionSelection()
24+
m.compacting = true
25+
m.compactStatus = "Compacting context"
26+
m.viewDirty = true
2427
sess := m.session
2528
ref := m.ref
2629
ctx, cancel := context.WithCancel(context.Background())
@@ -43,9 +46,16 @@ func (m *chatModel) startStream() {
4346
ref.Send(toolResultMsg{name: ev.ToolName, content: ev.Content})
4447
case "blast_radius":
4548
ref.Send(blastRadiusMsg{message: ev.Content})
49+
case "compact":
50+
ref.Send(compactMsg{
51+
strategy: ev.Content,
52+
tokensBefore: ev.TokensBefore,
53+
tokensAfter: ev.TokensAfter,
54+
})
4655
case "usage":
47-
// Usage events are only emitted in stream-json print mode
48-
// TUI mode ignores them since cost is tracked separately
56+
if ev.Usage != nil {
57+
ref.Send(usageUpdateMsg{usage: ev.Usage})
58+
}
4959
case "error":
5060
ref.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)})
5161
return

cmd/chat_view.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,10 @@ func (m chatModel) renderWaitingSpinnerLine() string {
604604
sep := ansiDim + " " + iconSpinnerSep + " " + ansiReset
605605

606606
var b strings.Builder
607+
if m.compacting && m.compactStatus != "" {
608+
b.WriteString(ansiYellow + m.compactStatus + ansiReset)
609+
b.WriteString(sep)
610+
}
607611
b.WriteString(m.brailleSpinner.Frame())
608612
b.WriteString(sep)
609613
b.WriteString(ansiTeal + fmt.Sprintf("%.1fs", m.spinnerElapsed().Seconds()) + ansiReset)

cmd/options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,16 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTur
293293
return nil
294294
}
295295

296+
// bindChatSession wires TUI-only session metadata (persist id, compaction callbacks).
297+
func bindChatSession(sess *engine.Session, sessionID string) {
298+
if sess == nil {
299+
return
300+
}
301+
if id := strings.TrimSpace(sessionID); id != "" {
302+
sess.SetPersistID(id)
303+
}
304+
}
305+
296306
func validateRootFlags() error {
297307
if outputFormat != "text" && outputFormat != "json" && outputFormat != "stream-json" {
298308
return fmt.Errorf("--output-format must be one of: text, json, stream-json")

internal/engine/compact_auto.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session)
6767
return "", false
6868
}
6969

70+
tokensBefore := EstimateTokens(sess.messages)
7071
strategy, err := ac.RunCompaction(ctx, sess)
7172
if err != nil {
7273
ac.mu.Lock()
@@ -77,19 +78,23 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session)
7778
"failures": ac.consecutiveFailures,
7879
})
7980
sess.compact()
81+
tokensAfter := EstimateTokens(sess.messages)
82+
sess.recordCompaction("truncate_fallback", tokensBefore, tokensAfter, false)
8083
return "truncate_fallback", true
8184
}
8285

8386
ac.mu.Lock()
8487
ac.consecutiveFailures = 0
8588
ac.mu.Unlock()
89+
tokensAfter := EstimateTokens(sess.messages)
90+
sess.recordCompaction(strategy, tokensBefore, tokensAfter, false)
8691
return strategy, true
8792
}
8893

8994
// RunCompaction selects and executes the best compaction strategy.
9095
func (ac *AutoCompactor) RunCompaction(ctx context.Context, sess *Session) (string, error) {
9196
tokenCount := EstimateTokens(sess.messages)
92-
strategy := ac.registry.SelectStrategy(sess.messages, tokenCount)
97+
strategy := ac.registry.SelectStrategy(sess, sess.messages, tokenCount)
9398

9499
sess.log.Info("running compaction", map[string]interface{}{
95100
"strategy": strategy.Name(),
@@ -112,6 +117,7 @@ func (ac *AutoCompactor) RunCompaction(ctx context.Context, sess *Session) (stri
112117
"tokens_after": result.TokensAfter,
113118
"reduction": result.TokensBefore - result.TokensAfter,
114119
})
120+
// recordCompaction is called by AutoCompactIfNeeded / CompactConversation callers
115121

116122
return result.Strategy, nil
117123
}

0 commit comments

Comments
 (0)