diff --git a/CODEBASE.md b/CODEBASE.md index 08c61d2..fb288f9 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -1,6 +1,6 @@ # TinyCode — CODEBASE Map -> AI coding agent in pure Go. Single binary, Bubble Tea TUI, ReAct agent loop, 25 built-in tools + MCP, LSP diagnostics, session persistence. 1237 test functions + 10 fuzz targets, race-detector clean. +> AI coding agent in pure Go. Single binary, Bubble Tea TUI, ReAct agent loop, 25 built-in tools + MCP, LSP diagnostics, session persistence. 1249 test functions + 10 fuzz targets, race-detector clean. ## Quick Reference @@ -249,13 +249,24 @@ provider is implemented — reaching IMDS from an agent process is a separate se 2. Need ≥4 user messages 3. Head: first 2 user-message boundaries (+3 context after) 4. Tail: last 2 user-message boundaries - 5. Middle → LLM summarization under the caller's context → `[COMPRESSED HISTORY]` system message + 5. Middle → LLM summarization under the caller's context → `[COMPRESSED HISTORY]` system message; + a **pinned** group is never fed to the summarizer and is re-inserted verbatim after the summary + (`groupPinned`), and a middle that is entirely pinned costs no provider call (roadmap B5) 6. Tool output in middle truncated to 200 chars during serialization 7. Active TODO items re-injected after compression A summarizer failure (including `context.Canceled`) returns `nil, err` rather than a no-op history, so a caller can tell a cancelled summary from a healthy "nothing to compress". The agent loop ignores the returned history in that case, exactly as it did when the error was swallowed, but `CompressHistory` surfaces it to the TUI. - **`(*Agent) HandleContextError(err error) bool`** — detects `context_length_exceeded`, lowers `discoveredCtxLen` (5 regex patterns, valid range 1024–10M, only lowers never raises) +### `context.go` — Turn-level Context Control + +- **`(*Agent) PinLast(n)` / `UnpinLast(n)` / `DropLast(n)` / `PinnedTurns()`** — a *turn* is a user + message plus everything up to the next one, the grouping `compressHistory` works in. Pins ride on + `types.Message.Pinned` and are honoured by the compressor; `/drop` removes turns from the live history + only — the session file is what `--resume` reads and is left alone. The TUI commands are `/pin [n]`, + `/unpin [n]` and `/drop [n]`, refused while a run is active because they replace `Agent.History` while + the run goroutine may append to it. + ### `ruleset.go` — Permission Rules - **`Effect`** (string): `EffectAllow = "allow"`, `EffectDeny = "deny"` @@ -858,7 +869,7 @@ User Input (textarea / CLI arg) ## Testing -- **1237 test functions + 10 fuzz targets** in the main module — what `go test ./... -count=1` runs, since `tuiprobe/` is a nested module; each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks +- **1249 test functions + 10 fuzz targets** in the main module — what `go test ./... -count=1` runs, since `tuiprobe/` is a nested module; each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks - `make fuzz` (`FUZZTIME=30s`) explores every fuzz target; `go test` already runs their seed corpora, so CI exercises them on every push - `make test-browser` (`BROWSER_TEST=1`, `-run TestBrowserSmoke`) renders a JavaScript page through both browser paths against a loopback server and asserts the request carried the proxy's `Via` header, which proves the filtering proxy was used; `TestBrowserSmokeRefusesABlockedSubresource` then serves a page whose own authority is exempted from the loopback rule while the policy stays enforced, and asserts a subresource pointing at a *second, live* loopback service is refused (the script's `onerror` marker in the DOM) and that the service was never reached — a refusal is proven, not only that the proxy was used (issue #8). Both skip without a browser, so `make test` never launches one - `make test-tui-visual` (`TUI_SHOT=1`) is the only place the TUI is looked at rather than asserted on: it renders the committed frame scenarios to PNGs through headless Chromium, starts `bin/tinycode` on a real 80x24 PTY (asserting the stream carried SGR styling and that the binary quits on the documented double Ctrl+C), runs it once more on a size-less PTY, and replays the live stream into a screen buffer that is screenshotted as `tinycode-pty-welcome-80x24.png`. Both tests skip without the variable, so `make test` needs neither a browser, nor a binary, nor a terminal device; PNGs land in `TUI_SHOT_DIR` (default `/tmp`) @@ -904,7 +915,7 @@ Counts in this document are produced by these commands at the commit they descri ```bash find . -name '*_test.go' -not -path './tuiprobe/*' -print0 \ - | xargs -0 grep -h '^func Test' | wc -l # 1237 test functions (main module) + | xargs -0 grep -h '^func Test' | wc -l # 1249 test functions (main module) grep -rn '^func Fuzz' --include=*_test.go . | wc -l # 10 fuzz targets ls tui/testdata/golden/frames/*.txt | wc -l # 29 plain-text frames ls tui/testdata/golden/ansi/*.ansi | wc -l # 1 raw ANSI frame diff --git a/README.md b/README.md index ac36290..e21f3b3 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Custom **CellGrid** frame-buffer renders markdown directly in the terminal — n - **GitHub Actions**: Two workflows — main.yml (build + lint + test on push/PR) and release.yml (cross-compile + GitHub Releases on tags v*) - **main.yml jobs**: `ci` (build, gofmt gate, vet, tests, `-race`, repeated run), `lsp` (real gopls), `browser` (real Chromium), `tui-visual` (frame PNGs + the built binary on a PTY), `cross` (linux/amd64, linux/arm64, darwin/arm64 build + vet) and `staticcheck` - **Makefile improvements**: test target preserves exit code with pass/fail message; releases target cross-compiles all platforms + .tar.gz archives -- **1237 test functions + 10 fuzz targets** in the main module — the one `go test ./... -count=1` runs, since `tuiprobe/` is a nested module — counted with `find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l` and `grep -rn '^func Fuzz' --include=*_test.go . | wc -l`; the command is part of the record, and `CODEBASE.md` → Testing lists every count with its measurement +- **1249 test functions + 10 fuzz targets** in the main module — the one `go test ./... -count=1` runs, since `tuiprobe/` is a nested module — counted with `find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l` and `grep -rn '^func Fuzz' --include=*_test.go . | wc -l`; the command is part of the record, and `CODEBASE.md` → Testing lists every count with its measurement - **Annotations**: the jobs carry one `ubuntu-latest` migration notice each, plus the `setup-chrome@v1` Node 20 warning on `browser` and `tui-visual`; the gate is no *new* annotations, not zero ### Running the checks locally diff --git a/agent/compression.go b/agent/compression.go index b3f9886..2011ebb 100644 --- a/agent/compression.go +++ b/agent/compression.go @@ -37,6 +37,19 @@ func EstimateMessagesTokens(msgs []types.Message) int { return total } +// groupPinned reports whether any message in a group carries a pin. One pinned +// message pins the group: a user instruction whose answer was summarized away +// is not useful on its own, and a turn split in half is not something the +// compressor can reason about. +func groupPinned(msgs []types.Message) bool { + for _, m := range msgs { + if m.Pinned { + return true + } + } + return false +} + // groupEnd returns the index just past the message group containing idx. A // group starts at a user message and runs until the next user message, so an // assistant message with tool_calls always stays together with its tool @@ -98,29 +111,51 @@ func (a *Agent) compressHistory(ctx context.Context, messages []types.Message) ( return messages, nil } - var middleText strings.Builder - for _, m := range middle { - switch m.Role { - case types.RoleUser: - middleText.WriteString(fmt.Sprintf("User: %s\n", m.Content)) - case types.RoleAssistant: - if m.Content != "" { - middleText.WriteString(fmt.Sprintf("Assistant: %s\n", m.Content)) - } - if m.ReasoningContent != "" { - middleText.WriteString(fmt.Sprintf("Reasoning: %s\n", m.ReasoningContent)) - } - case types.RoleTool: - content := m.Content - if len(content) > 200 { - content = content[:200] + "..." + // A pinned group is context the user asked to keep verbatim: it is never + // fed to the summarizer, and it is put back after the summary and before the + // tail. The order is therefore head, summary, pinned, recent tail — a + // summary is already a lossy stand-in, and keeping the pinned text at all + // matters more than where in the stand-in it sits (roadmap B5). + var ( + middleText strings.Builder + pinned []types.Message + ) + for i := 0; i < len(middle); { + end := groupEnd(middle, i) + if groupPinned(middle[i:end]) { + pinned = append(pinned, middle[i:end]...) + i = end + continue + } + for _, m := range middle[i:end] { + switch m.Role { + case types.RoleUser: + middleText.WriteString(fmt.Sprintf("User: %s\n", m.Content)) + case types.RoleAssistant: + if m.Content != "" { + middleText.WriteString(fmt.Sprintf("Assistant: %s\n", m.Content)) + } + if m.ReasoningContent != "" { + middleText.WriteString(fmt.Sprintf("Reasoning: %s\n", m.ReasoningContent)) + } + case types.RoleTool: + content := m.Content + if len(content) > 200 { + content = content[:200] + "..." + } + middleText.WriteString(fmt.Sprintf("Tool (%s): %s\n", m.Name, content)) + case types.RoleSystem: + // Preserve earlier summaries so compressing twice does not discard + // the context that was already distilled. + middleText.WriteString(fmt.Sprintf("Context: %s\n", m.Content)) } - middleText.WriteString(fmt.Sprintf("Tool (%s): %s\n", m.Name, content)) - case types.RoleSystem: - // Preserve earlier summaries so compressing twice does not discard - // the context that was already distilled. - middleText.WriteString(fmt.Sprintf("Context: %s\n", m.Content)) } + i = end + } + if strings.TrimSpace(middleText.String()) == "" { + // Everything worth compressing is pinned. Compressing it would mean + // paying for a summary of nothing, so leave the history alone. + return messages, nil } summaryPrompt := fmt.Sprintf(`Summarize the following conversation history. Focus on: @@ -174,9 +209,10 @@ Provide a concise summary in 3-5 sentences.`, middleText.String()) Content: fmt.Sprintf("[COMPRESSED HISTORY — REFERENCE ONLY]\n%s\n[END COMPRESSED HISTORY]", summary), } - compressed := make([]types.Message, 0, len(head)+1+len(tail)) + compressed := make([]types.Message, 0, len(head)+1+len(pinned)+len(tail)) compressed = append(compressed, head...) compressed = append(compressed, summaryMsg) + compressed = append(compressed, pinned...) compressed = append(compressed, tail...) // Inject active todo items after compression so LLM doesn't redo completed work diff --git a/agent/context.go b/agent/context.go new file mode 100644 index 0000000..53510e1 --- /dev/null +++ b/agent/context.go @@ -0,0 +1,113 @@ +package agent + +import "github.com/yusiwen/tinycode/types" + +// Turn-level control over the live history (roadmap B5). +// +// A *turn* is a user message plus everything up to the next user message — the +// same grouping compressHistory works in, so "pin the last turn" and "protect +// this group from the summarizer" mean the same thing. +// +// None of these touch the session store: what is on disk is the record +// `--resume` reads, and a command that silently rewrote it would be a +// destructive edit rather than context control. + +// turnStarts returns the index of every user message in history. +func turnStarts(history []types.Message) []int { + var starts []int + for i, msg := range history { + if msg.Role == types.RoleUser { + starts = append(starts, i) + } + } + return starts +} + +// turns returns the [start, end) range of every turn, oldest first. +func turns(history []types.Message) [][2]int { + starts := turnStarts(history) + ranges := make([][2]int, 0, len(starts)) + for i, start := range starts { + end := len(history) + if i+1 < len(starts) { + end = starts[i+1] + } + ranges = append(ranges, [2]int{start, end}) + } + return ranges +} + +// lastTurns returns the ranges of the last n turns, oldest first. Asking for +// more turns than exist returns every turn rather than failing: "pin the last 5" +// on a three-turn history should do what it can and report the count. +func lastTurns(history []types.Message, n int) [][2]int { + ranges := turns(history) + if n <= 0 || len(ranges) == 0 { + return nil + } + if n > len(ranges) { + n = len(ranges) + } + return ranges[len(ranges)-n:] +} + +// PinLast marks the last n turns as pinned and returns how many turns it pinned +// and how many are pinned in total. +func (a *Agent) PinLast(n int) (pinned, total int) { + for _, r := range lastTurns(a.History, n) { + for i := r[0]; i < r[1]; i++ { + a.History[i].Pinned = true + } + pinned++ + } + return pinned, a.PinnedTurns() +} + +// UnpinLast clears the pin on the last n pinned turns, newest first, and returns +// how many it cleared and how many remain. +func (a *Agent) UnpinLast(n int) (unpinned, remaining int) { + if n <= 0 { + n = 1 + } + ranges := turns(a.History) + for i := len(ranges) - 1; i >= 0 && unpinned < n; i-- { + cleared := false + for j := ranges[i][0]; j < ranges[i][1]; j++ { + if a.History[j].Pinned { + a.History[j].Pinned = false + cleared = true + } + } + if cleared { + unpinned++ + } + } + return unpinned, a.PinnedTurns() +} + +// DropLast removes the last n turns from the live history and returns how many +// turns went. The session file is untouched. +func (a *Agent) DropLast(n int) (dropped int) { + ranges := lastTurns(a.History, n) + if len(ranges) == 0 { + return 0 + } + // The copy matters: history is shared with any goroutine holding a slice of + // it, and truncating in place would overwrite what they are reading. + a.History = append([]types.Message(nil), a.History[:ranges[0][0]]...) + return len(ranges) +} + +// PinnedTurns reports how many turns carry a pin. +func (a *Agent) PinnedTurns() int { + count := 0 + for _, r := range turns(a.History) { + for i := r[0]; i < r[1]; i++ { + if a.History[i].Pinned { + count++ + break + } + } + } + return count +} diff --git a/agent/context_test.go b/agent/context_test.go new file mode 100644 index 0000000..7bc1b87 --- /dev/null +++ b/agent/context_test.go @@ -0,0 +1,177 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/yusiwen/tinycode/types" +) + +// turnHistory builds n turns of two messages each: "question i" then "answer i". +func turnHistory(n int) []types.Message { + history := make([]types.Message, 0, 2*n) + for i := 0; i < n; i++ { + history = append(history, + types.Message{Role: types.RoleUser, Content: "question " + string(rune('0'+i))}, + types.Message{Role: types.RoleAssistant, Content: "answer " + string(rune('0'+i))}, + ) + } + return history +} + +// TestPinLastMarksExactlyTheLastTurns pins the unit these commands work in: a +// turn is a user message and everything up to the next one. +func TestPinLastMarksExactlyTheLastTurns(t *testing.T) { + ag := New(nil) + ag.History = turnHistory(4) + + pinned, total := ag.PinLast(2) + if pinned != 2 || total != 2 { + t.Fatalf("pinned=%d total=%d, want 2/2", pinned, total) + } + for i, msg := range ag.History { + wantPinned := i >= 4 // the last two turns are indices 4..7 + if msg.Pinned != wantPinned { + t.Fatalf("message %d (%s) pinned=%v, want %v", i, msg.Role, msg.Pinned, wantPinned) + } + } + + // Asking for more turns than exist pins what there is and says so. + pinned, total = ag.PinLast(10) + if pinned != 4 || total != 4 { + t.Fatalf("pinning 10 turns pinned=%d total=%d, want the 4 that exist", pinned, total) + } +} + +// TestUnpinLastClearsNewestFirst pins the order: the turn a user just pinned is +// the one they mean when they say "unpin". +func TestUnpinLastClearsNewestFirst(t *testing.T) { + ag := New(nil) + ag.History = turnHistory(3) + ag.PinLast(3) + + cleared, remaining := ag.UnpinLast(1) + if cleared != 1 || remaining != 2 { + t.Fatalf("cleared=%d remaining=%d, want 1/2", cleared, remaining) + } + // The newest turn (indices 4,5) lost its pin; the older ones kept theirs. + if ag.History[4].Pinned || ag.History[5].Pinned { + t.Fatal("the newest turn is still pinned") + } + if !ag.History[0].Pinned || !ag.History[2].Pinned { + t.Fatal("unpinning one turn cleared an older pin") + } + + if cleared, remaining := ag.UnpinLast(5); cleared != 2 || remaining != 0 { + t.Fatalf("cleared=%d remaining=%d, want 2/0", cleared, remaining) + } + if cleared, _ := ag.UnpinLast(1); cleared != 0 { + t.Fatalf("unpinning with no pins cleared %d", cleared) + } +} + +// TestDropLastRemovesExactlyTheLastTurns also pins that the original history is +// not written through: a goroutine holding the old slice must not see its +// contents replaced. +func TestDropLastRemovesExactlyTheLastTurns(t *testing.T) { + ag := New(nil) + original := turnHistory(4) + ag.History = append([]types.Message(nil), original...) + + dropped := ag.DropLast(2) + if dropped != 2 { + t.Fatalf("dropped=%d, want 2", dropped) + } + if len(ag.History) != 4 { + t.Fatalf("history has %d messages, want 4", len(ag.History)) + } + if ag.History[3].Content != "answer 1" { + t.Fatalf("the wrong turns were removed: %+v", ag.History) + } + if original[6].Content != "question 3" { + t.Fatal("dropping rewrote the slice it was given") + } + + if dropped := ag.DropLast(5); dropped != 2 { + t.Fatalf("dropping more turns than exist removed %d, want the 2 that exist", dropped) + } + if len(ag.History) != 0 { + t.Fatalf("history is %d messages, want empty", len(ag.History)) + } + if dropped := ag.DropLast(1); dropped != 0 { + t.Fatalf("dropping from an empty history removed %d", dropped) + } +} + +// TestCompressionKeepsPinnedGroupsVerbatim is the point of the whole change: a +// pinned turn is neither summarized nor summarized-away, and the summarizer +// never sees it. +func TestCompressionKeepsPinnedGroupsVerbatim(t *testing.T) { + provider := &recordingProvider{content: "SUMMARY"} + ag := New(provider) + ag.ContextLength = 1000 + ag.CompressionThreshold = 1 // compress whatever it is given + // Eight turns: the head keeps turns 1-3, the tail turns 7-8, so the middle + // is turns 4-5 (indices 6-11). Pinning a head turn would prove nothing — the + // head is kept whatever the pins say. + ag.History = turnHistory(8) + ag.History[6].Pinned = true // turn 4 + ag.History[7].Pinned = true + + compressed, err := ag.compressHistory(context.Background(), ag.History) + if err != nil { + t.Fatalf("compress: %v", err) + } + if len(provider.requests) != 1 { + t.Fatalf("the summarizer was called %d times, want 1", len(provider.requests)) + } + if prompt := provider.requests[0].Messages[1].Content; strings.Contains(prompt, "answer 3") { + t.Fatalf("the pinned turn was fed to the summarizer:\n%s", prompt) + } + if !containsMessage(compressed, "answer 3") { + t.Fatal("the pinned turn is missing from the compressed history") + } + if !containsMessage(compressed, "SUMMARY") { + t.Fatal("the summary is missing from the compressed history") + } + if containsMessage(compressed, "answer 4") { + t.Fatal("the unpinned middle was not summarized away") + } +} + +// TestCompressionDoesNothingWhenEverythingIsPinned pins the cheap path: a middle +// that is all pinned must not cost a provider call. +func TestCompressionDoesNothingWhenEverythingIsPinned(t *testing.T) { + provider := &recordingProvider{content: "SUMMARY"} + ag := New(provider) + ag.ContextLength = 1000 + ag.CompressionThreshold = 1 + ag.History = turnHistory(8) + // The middle is turns 4 and 5 (indices 6-11); pin all of it. PinLast would + // pin the tail, which is not the region the summarizer touches. + for i := 6; i < 12; i++ { + ag.History[i].Pinned = true + } + + compressed, err := ag.compressHistory(context.Background(), ag.History) + if err != nil { + t.Fatalf("compress: %v", err) + } + if len(provider.requests) != 0 { + t.Fatalf("the summarizer was called %d times for a fully pinned middle", len(provider.requests)) + } + if len(compressed) != len(ag.History) { + t.Fatalf("history changed: %d messages, want %d", len(compressed), len(ag.History)) + } +} + +// containsMessage reports whether any message in the history carries the text. +func containsMessage(history []types.Message, text string) bool { + for _, msg := range history { + if strings.Contains(msg.Content, text) { + return true + } + } + return false +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 50b4dc5..f07da71 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,8 +18,8 @@ after it (see *Provenance and method*). | :--- | ---: | :--- | | Delivered | 16 | the entry's **Picked up** line names the issue that landed it | | Refuted | 1 | E5 — the capability exists, recorded so it is not re-filed | -| In progress | 1 | F1 — three increments landed, the next one is not opened | -| Not started | 22 | verified gaps, no issue opened | +| In progress | 2 | F1 — three increments landed, the next one is not opened; B5 — S1 landed, S2 and S3 open | +| Not started | 21 | verified gaps, no issue opened | | **Total** | **40** | `grep -Ec '^\| [A-H][0-9] \| .* \| (Delivered —\|Refuted\|In progress\|Not started)' docs/roadmap.md` | **Open work**: F1, whose next increment (a task set with headroom) is not opened, and @@ -43,7 +43,7 @@ B4 (repository index), F1, then the larger D and E items. | B2 | No local memory backend | Delivered — #293 | | B3 | No embeddings, vector search or rerank | Delivered — #297 | | B4 | No repository index or repo map | Not started | -| B5 | Context management has a single strategy | Not started | +| B5 | Context management has a single strategy | In progress — #306 | | B6 | Truncation is fixed, not budget-aware | Delivered — #304 | | C1 | No checkpoint and no undo | Delivered — #234 | | C2 | No automatic change preview and commit loop, and no remote operations | Not started | @@ -381,6 +381,10 @@ current tree. D7 is the exception: it was recorded later, so its references are ### B5 — Context management has a single strategy +- **Picked up**: [#306](https://github.com/yusiwen/TinyCode/issues/306) — S1 (turn-level `/pin`, + `/unpin` and `/drop`, and a compressor that keeps pinned groups verbatim), S2 (compaction of the run's + own messages, where tool results — and so file contents — live; this is the part D7 depends on), S3 (a + compression preview that calls no model). **S1 landed 2026-10-28**; S2 and S3 are open. - **Gap**: compression is automatic and all-or-nothing. No `/drop`, no `/pin`, no unloading by message or by file, and no preview of what a compression would discard. - **Evidence**: one strategy, `compressHistory` (`agent/compression.go:59`) — head, LLM-summarized diff --git a/tui/context_command.go b/tui/context_command.go new file mode 100644 index 0000000..5fd6697 --- /dev/null +++ b/tui/context_command.go @@ -0,0 +1,77 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// contextTurns reads the optional turn count: "/pin 2" acts on two turns, "/pin" +// on one. +func contextTurns(cmd, name string) (int, error) { + rest := strings.TrimSpace(strings.TrimPrefix(cmd, name)) + if rest == "" { + return 1, nil + } + n, err := strconv.Atoi(rest) + if err != nil || n < 1 { + return 0, fmt.Errorf("usage: %s [turns] — turns is a positive number", name) + } + return n, nil +} + +// handleContextCommand implements /pin, /unpin and /drop. +// +// All three replace Agent.History while the run goroutine may be appending to +// it, so each is refused while a run is active — the same rule /compress +// follows, and for the same reason. +// +// None of them writes the session file: what is on disk is the record --resume +// reads, and a command meant to shape the live context should not quietly delete +// from that record. +func (m *TuiModel) handleContextCommand(cmd, name string) (tea.Model, tea.Cmd) { + if m.agent == nil { + m.ShowStatus("No agent available") + return m, nil + } + if m.runIsActive() { + m.ShowStatus("Cannot " + strings.TrimPrefix(name, "/") + ": a run is in progress") + return m, nil + } + n, err := contextTurns(cmd, name) + if err != nil { + m.appendSystem(err.Error()) + return m, nil + } + + switch name { + case "/pin": + pinned, total := m.agent.PinLast(n) + if pinned == 0 { + m.appendSystem("nothing to pin: the history is empty") + return m, nil + } + m.appendSystem(fmt.Sprintf( + "pinned %d turn(s); %d pinned in total. A pinned turn is kept verbatim — neither /compress nor the automatic compression summarizes it.", + pinned, total)) + case "/unpin": + cleared, remaining := m.agent.UnpinLast(n) + if cleared == 0 { + m.appendSystem("nothing to unpin: no turn carries a pin") + return m, nil + } + m.appendSystem(fmt.Sprintf("unpinned %d turn(s); %d pinned in total.", cleared, remaining)) + case "/drop": + dropped := m.agent.DropLast(n) + if dropped == 0 { + m.appendSystem("nothing to drop: the history is empty") + return m, nil + } + m.appendSystem(fmt.Sprintf( + "dropped %d turn(s) from this context. The session file still holds them, so --resume brings them back.", + dropped)) + } + return m, nil +} diff --git a/tui/context_command_test.go b/tui/context_command_test.go new file mode 100644 index 0000000..ce5171a --- /dev/null +++ b/tui/context_command_test.go @@ -0,0 +1,144 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/yusiwen/tinycode/types" +) + +// seedHistory gives the model n turns of two messages each, the shape a real +// transcript has. +func seedHistory(m *TuiModel, n int) { + for i := 0; i < n; i++ { + m.agent.History = append(m.agent.History, + types.Message{Role: types.RoleUser, Content: "question"}, + types.Message{Role: types.RoleAssistant, Content: "answer"}, + ) + } +} + +// TestPinCommand pins the reply and the effect: the newest turn, marked. +func TestPinCommand(t *testing.T) { + m := newTestTUI() + seedHistory(m, 2) + + m.handleCommand("/pin") + + if got := lastMessage(m); !strings.Contains(got, "pinned 1 turn(s); 1 pinned in total") { + t.Fatalf("reply = %q", got) + } + if !m.agent.History[2].Pinned || !m.agent.History[3].Pinned { + t.Fatal("the newest turn is not pinned") + } + if m.agent.History[0].Pinned { + t.Fatal("an older turn was pinned too") + } +} + +// TestPinCommandWithACount reads the optional number, and rejects a bad one with +// the usage line rather than a silent no-op. +func TestPinCommandWithACount(t *testing.T) { + m := newTestTUI() + seedHistory(m, 3) + + m.handleCommand("/pin 2") + if got := lastMessage(m); !strings.Contains(got, "pinned 2 turn(s); 2 pinned in total") { + t.Fatalf("reply = %q", got) + } + + m.handleCommand("/pin zero") + if got := lastMessage(m); !strings.Contains(got, "usage: /pin [turns]") { + t.Fatalf("reply = %q", got) + } +} + +// TestUnpinCommand clears the newest pin first. +func TestUnpinCommand(t *testing.T) { + m := newTestTUI() + seedHistory(m, 2) + m.handleCommand("/pin 2") + + m.handleCommand("/unpin") + if got := lastMessage(m); !strings.Contains(got, "unpinned 1 turn(s); 1 pinned in total") { + t.Fatalf("reply = %q", got) + } + if m.agent.History[2].Pinned { + t.Fatal("the newest turn is still pinned") + } + if !m.agent.History[0].Pinned { + t.Fatal("the older pin went with it") + } +} + +// TestDropCommand removes the turn from the live context and says where the +// record still is. +func TestDropCommand(t *testing.T) { + m := newTestTUI() + seedHistory(m, 3) + + m.handleCommand("/drop 2") + + if got := len(m.agent.History); got != 2 { + t.Fatalf("history has %d messages, want the first turn's 2", got) + } + reply := lastMessage(m) + if !strings.Contains(reply, "dropped 2 turn(s)") || !strings.Contains(reply, "session file still holds them") { + t.Fatalf("reply = %q", reply) + } +} + +// TestContextCommandsOnAnEmptyHistory pins the message a user gets before there +// is anything to act on. +func TestContextCommandsOnAnEmptyHistory(t *testing.T) { + for _, tc := range []struct{ cmd, want string }{ + {"/pin", "nothing to pin"}, + {"/unpin", "nothing to unpin"}, + {"/drop", "nothing to drop"}, + } { + m := newTestTUI() + m.handleCommand(tc.cmd) + if got := lastMessage(m); !strings.Contains(got, tc.want) { + t.Errorf("%s reply = %q, want %q", tc.cmd, got, tc.want) + } + } +} + +// TestContextCommandsRefusedDuringARun pins the guard: these commands replace +// Agent.History while the run goroutine appends to it. +func TestContextCommandsRefusedDuringARun(t *testing.T) { + for _, cmd := range []string{"/pin", "/unpin", "/drop"} { + m := newTestTUI() + seedHistory(m, 2) + m.runMu.Lock() + m.runActive = true + m.runMu.Unlock() + + m.handleCommand(cmd) + + if got := m.statusMsg; !strings.Contains(got, "a run is in progress") { + t.Errorf("%s status = %q", cmd, got) + } + if len(m.agent.History) != 4 { + t.Errorf("%s changed the history during a run", cmd) + } + m.runMu.Lock() + m.runActive = false + m.runMu.Unlock() + } +} + +// TestContextPaletteListsTheCommands pins discoverability. +func TestContextPaletteListsTheCommands(t *testing.T) { + want := map[string]bool{"/pin": false, "/unpin": false, "/drop": false} + for _, entry := range commandList() { + if _, ok := want[entry.Name]; ok { + want[entry.Name] = true + } + } + for name, found := range want { + if !found { + t.Errorf("%s is missing from the command palette", name) + } + } +} diff --git a/tui/model.go b/tui/model.go index 852f040..0ae0fd8 100644 --- a/tui/model.go +++ b/tui/model.go @@ -246,6 +246,9 @@ func commandList() []cmdEntry { {"/diagnostics", "LSP diagnostics"}, {"/undo", "Restore the last tool call's files"}, {"/goal", "View, create, pause or resume the goal"}, + {"/pin", "Keep the last turn out of compression"}, + {"/unpin", "Clear a pin on the last turn"}, + {"/drop", "Drop the last turn from this context"}, {"/remember", "Store a fact for later sessions"}, {"/memory", "List or forget stored facts"}, {"/sandbox", "Sandbox status"}, diff --git a/tui/testdata/golden/frames/palette_120x40.txt b/tui/testdata/golden/frames/palette_120x40.txt index e7048ee..5ce0d08 100644 --- a/tui/testdata/golden/frames/palette_120x40.txt +++ b/tui/testdata/golden/frames/palette_120x40.txt @@ -47,6 +47,9 @@ /diagnostics LSP diagnostics /undo Restore the last tool call's files /goal View, create, pause or resume the goal + /pin Keep the last turn out of compression + /unpin Clear a pin on the last turn + /drop Drop the last turn from this context /remember Store a fact for later sessions /memory List or forget stored facts /sandbox Sandbox status diff --git a/tui/testdata/golden/frames/palette_30x12.txt b/tui/testdata/golden/frames/palette_30x12.txt index a54a36c..92f8b11 100644 --- a/tui/testdata/golden/frames/palette_30x12.txt +++ b/tui/testdata/golden/frames/palette_30x12.txt @@ -26,6 +26,15 @@ /goal View, create, pause or resume the goal + /pin + Keep the last turn out of + compression + /unpin + Clear a pin on the last + turn + /drop + Drop the last turn from + this context /remember Store a fact for later sessions diff --git a/tui/testdata/golden/frames/palette_40x12.txt b/tui/testdata/golden/frames/palette_40x12.txt index f16e901..a1ce46d 100644 --- a/tui/testdata/golden/frames/palette_40x12.txt +++ b/tui/testdata/golden/frames/palette_40x12.txt @@ -22,6 +22,12 @@ /goal View, create, pause or resume the goal + /pin + Keep the last turn out of + compression + /unpin Clear a pin on the last turn + /drop + Drop the last turn from this context /remember Store a fact for later sessions /memory List or forget stored facts diff --git a/tui/testdata/golden/frames/palette_80x24.txt b/tui/testdata/golden/frames/palette_80x24.txt index f70c073..06f3442 100644 --- a/tui/testdata/golden/frames/palette_80x24.txt +++ b/tui/testdata/golden/frames/palette_80x24.txt @@ -31,6 +31,9 @@ /diagnostics LSP diagnostics /undo Restore the last tool call's files /goal View, create, pause or resume the goal + /pin Keep the last turn out of compression + /unpin Clear a pin on the last turn + /drop Drop the last turn from this context /remember Store a fact for later sessions /memory List or forget stored facts /sandbox Sandbox status diff --git a/tui/update.go b/tui/update.go index 04a1c8a..cb5462e 100644 --- a/tui/update.go +++ b/tui/update.go @@ -1225,6 +1225,9 @@ Commands: /goal View the goal; /goal , edit, pause, resume, clear /remember Store a fact for later sessions (/remember