Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions CODE_AUDIT_REPORT.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"log"
"math/rand"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"

"golang.org/x/term"
Expand Down Expand Up @@ -648,6 +650,18 @@ func runChat() error {
EnableTabProgress()
ref.Set(p)

// Forward SIGHUP (terminal close, ssh drop, window manager exit) into the
// TUI as a tea.QuitMsg so the session is saved and cleaned up instead of
// dying silently mid-run. Bubble Tea only handles SIGINT and SIGTERM.
{
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP)
go func() {
<-sigCh
ref.Send(tea.QuitMsg{})
}()
}

go func() {
if extra := strings.TrimSpace(buildDeferredWorkspacePromptContext()); extra != "" {
ref.Send(systemPromptContextReadyMsg{context: extra})
Expand Down
51 changes: 51 additions & 0 deletions cmd/chat_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,36 @@ const maxDisplayMessages = 500
// We trim in batches to avoid frequent reallocations.
const messageTrimThreshold = 450

// maxPromptHistory bounds the in-memory prompt history ring (M18: history
// grew without bound across a long session).
const maxPromptHistory = 200

// pushHistory records a submitted prompt, capped to the most recent
// maxPromptHistory entries.
func (m *chatModel) pushHistory(text string) {
m.history = append(m.history, text)
if len(m.history) > maxPromptHistory {
keep := len(m.history) - maxPromptHistory
m.history = append(m.history[:0], m.history[keep:]...)
}
m.historyIdx = len(m.history)
m.historyDraft = ""
}

// maxQueuedMessages bounds the queue of prompts entered while the agent is
// working (M18: it grew without bound during long turns). The oldest queued
// prompts are dropped first so the most recent intent is preserved.
const maxQueuedMessages = 100

// enqueueMessage queues a prompt entered while the agent is working,
// dropping the oldest entries past the cap.
func (m *chatModel) enqueueMessage(text string) {
if len(m.messageQueue) >= maxQueuedMessages {
m.messageQueue = append(m.messageQueue[:0], m.messageQueue[1:]...)
}
m.messageQueue = append(m.messageQueue, text)
}

// trimOldMessages removes old messages when the count exceeds the threshold.
// Keeps the most recent messages and shows a hint about trimmed history.
func (m *chatModel) trimOldMessages() {
Expand Down Expand Up @@ -412,9 +442,30 @@ func (m *chatModel) trimOldMessages() {
kept = append(kept, trimmedHint)
kept = append(kept, m.messages[startIdx+trimCount:]...)
m.messages = kept
// Expansion state is keyed by message index; reindex the survivors so
// Enter-to-expand keeps targeting the right messages and stale keys for
// trimmed messages are pruned (M18: the map grew without bound).
m.toolResultExpanded = reindexExpandedMap(m.toolResultExpanded, startIdx, trimCount)
m.invalidateViewportCache()
}

// reindexExpandedMap maps tool-result expansion state across
// trimOldMessages' reindex: indices below startIdx are untouched, trimmed
// indices are pruned, and survivors above the trim shift down by
// trimCount-1 because the trim hint takes one slot.
func reindexExpandedMap(expanded map[int]bool, startIdx, trimCount int) map[int]bool {
reindexed := make(map[int]bool, len(expanded))
for idx, expandedState := range expanded {
switch {
case idx < startIdx:
reindexed[idx] = expandedState
case idx >= startIdx+trimCount:
reindexed[idx-trimCount+1] = expandedState
}
}
return reindexed
}

func (m *chatModel) markPartialDirty() tea.Cmd {
m.partialDirty = true
if time.Since(m.lastPartialRender) >= streamRenderInterval {
Expand Down
45 changes: 45 additions & 0 deletions cmd/chat_model_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -378,3 +379,47 @@ func TestChatModel_StreamingCommands(t *testing.T) {
})
}
}

func TestChatModel_PushHistoryCapsAtMax(t *testing.T) {
m := newTestChatModel()
for i := 0; i < maxPromptHistory+50; i++ {
m.pushHistory(fmt.Sprintf("prompt-%d", i))
}
if len(m.history) != maxPromptHistory {
t.Fatalf("history len = %d, want %d", len(m.history), maxPromptHistory)
}
if m.history[0] != "prompt-50" || m.history[len(m.history)-1] != fmt.Sprintf("prompt-%d", maxPromptHistory+49) {
t.Fatalf("history did not keep the most recent prompts: first=%q last=%q", m.history[0], m.history[len(m.history)-1])
}
if m.historyIdx != len(m.history) {
t.Fatalf("historyIdx = %d, want %d", m.historyIdx, len(m.history))
}
}

func TestChatModel_EnqueueMessageCapsAtMax(t *testing.T) {
m := newTestChatModel()
for i := 0; i < maxQueuedMessages+25; i++ {
m.enqueueMessage(fmt.Sprintf("queued-%d", i))
}
if len(m.messageQueue) != maxQueuedMessages {
t.Fatalf("queue len = %d, want %d", len(m.messageQueue), maxQueuedMessages)
}
if m.messageQueue[0] != "queued-25" {
t.Fatalf("oldest queued prompt not dropped: first=%q", m.messageQueue[0])
}
}

func TestChatModel_ReindexExpandedMap(t *testing.T) {
// startIdx=1 (welcome preserved), trimCount=3: old idx 4 → 2, old idx 8 → 6.
expanded := map[int]bool{0: true, 1: true, 3: false, 4: true, 8: true}
got := reindexExpandedMap(expanded, 1, 3)
want := map[int]bool{0: true, 2: true, 6: true}
if len(got) != len(want) {
t.Fatalf("reindexed map len = %d, want %d: %v", len(got), len(want), got)
}
for idx, state := range want {
if got[idx] != state {
t.Fatalf("reindexed[%d] = %v, want %v", idx, got[idx], state)
}
}
}
20 changes: 20 additions & 0 deletions cmd/chat_print.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ func runPrint(text string) error {

// Wire timeout if --timeout flag is set
ctx := context.Background()
var countdown bool
if timeout > 0 {
cfg := lifecycle.TimeoutConfig{Total: timeout, Countdown: true}
var cancel context.CancelFunc
ctx, cancel = lifecycle.WithTimeout(ctx, cfg)
defer cancel()
countdown = cfg.Countdown
}

ch, err := sess.Stream(ctx)
Expand All @@ -80,6 +82,7 @@ func runPrint(text string) error {
}

var printed strings.Builder
var countdownShown bool
for ev := range ch {
switch ev.Type {
case "content":
Expand All @@ -89,6 +92,14 @@ func runPrint(text string) error {
writePrintEvent(sessionID, "content", ev.Content, "")
}
printed.WriteString(ev.Content)
// Honour the Countdown flag (was previously set but unread):
// surface the remaining time budget once, on the first content.
if countdown && !countdownShown {
if rem := lifecycle.RemainingTime(ctx); rem != "" {
fmt.Fprintf(os.Stderr, "[time remaining] %s\n", rem)
countdownShown = true
}
}
case "tool_use":
if outputFormat == "stream-json" {
writePrintEvent(sessionID, "tool_use", "", ev.ToolName)
Expand Down Expand Up @@ -297,11 +308,13 @@ func runRepl() error {
}

ctx := context.Background()
var countdown bool
if timeout > 0 {
cfg := lifecycle.TimeoutConfig{Total: timeout, Countdown: true}
var cancel context.CancelFunc
ctx, cancel = lifecycle.WithTimeout(ctx, cfg)
defer cancel()
countdown = cfg.Countdown
}

for {
Expand Down Expand Up @@ -352,6 +365,7 @@ func runRepl() error {
}

var printed strings.Builder
var countdownShown bool
for ev := range ch {
switch ev.Type {
case "content":
Expand All @@ -361,6 +375,12 @@ func runRepl() error {
writePrintEvent(sessionID, "content", ev.Content, "")
}
printed.WriteString(ev.Content)
if countdown && !countdownShown {
if rem := lifecycle.RemainingTime(ctx); rem != "" {
fmt.Fprintf(os.Stderr, "[time remaining] %s\n", rem)
countdownShown = true
}
}
case "tool_use":
if outputFormat == "stream-json" {
writePrintEvent(sessionID, "tool_use", "", ev.ToolName)
Expand Down
4 changes: 1 addition & 3 deletions cmd/chat_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ func (m chatModel) submitUserMessage() (chatModel, tea.Cmd) {
m.input.CursorEnd()
return m, nil
}
m.history = append(m.history, text)
m.historyIdx = len(m.history)
m.historyDraft = ""
m.pushHistory(text)
m.input.Reset()
if strings.HasPrefix(text, "/") {
result, cmd := m.handleCommand(text)
Expand Down
57 changes: 40 additions & 17 deletions cmd/chat_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,32 @@ func shouldReturnToPromptOnType(msg tea.KeyMsg) bool {
return true
}

// quitModel performs the shared graceful-quit sequence used by every exit
// path (Ctrl+C twice, /quit, SIGINT as tea.InterruptMsg, SIGTERM/SIGHUP as
// tea.QuitMsg): cancel any in-flight stream, persist the session, stop
// background workers (watcher, parallel agents, background tasks), stop the
// sandbox container, and mark the model as quitting so the final view can
// show the resume hint.
func (m *chatModel) quitModel() (tea.Model, tea.Cmd) {
if m.cancel != nil {
m.cancel()
m.cancel = nil
}
m.saveSession()
if m.watcherStop != nil {
m.watcherStop()
}
if m.parallelCancel != nil {
m.parallelCancel()
}
if m.bgCancel != nil {
m.bgCancel()
}
m.stopContainer()
m.quitting = true
return m, tea.Quit
}

func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if _, isMouse := msg.(tea.MouseMsg); !isMouse {
Expand Down Expand Up @@ -128,6 +154,17 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateViewportContent()
return m, nil

case tea.InterruptMsg:
// External SIGINT delivered while the terminal is not in raw mode
// (e.g. `kill -INT`, tmux/screen `prefix` + ctrl+c). Bubble Tea would
// otherwise exit without saving the session.
return m.quitModel()

case tea.QuitMsg:
// SIGTERM (e.g. `kill <pid>`, terminal close on some platforms).
// Exit through the same save-and-cleanup path as Ctrl+C.
return m.quitModel()

case promptKeepAliveMsg:
if m.uiFocus == focusPrompt && !m.configOpen && !m.useConfigInput {
if !m.input.Focused() {
Expand Down Expand Up @@ -723,19 +760,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateViewportContent()
return m, nil
}
m.saveSession()
if m.watcherStop != nil {
m.watcherStop()
}
if m.parallelCancel != nil {
m.parallelCancel()
}
if m.bgCancel != nil {
m.bgCancel()
}
m.stopContainer()
m.quitting = true
return m, tea.Quit
return m.quitModel()
}
if msg.String() == "escape" {
if m.cancel != nil {
Expand All @@ -758,10 +783,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.String() == "enter" {
text := strings.TrimSpace(m.input.Value())
if text != "" {
m.history = append(m.history, text)
m.historyIdx = len(m.history)
m.historyDraft = ""
m.messageQueue = append(m.messageQueue, text)
m.pushHistory(text)
m.enqueueMessage(text)
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s Queued: %s", icons.Mail(), text)})
m.input.Reset()
m.viewDirty = true
Expand Down
Loading
Loading