diff --git a/CODEBASE.md b/CODEBASE.md index 3b64f7f..37c43db 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. 1285 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. 1291 test functions + 10 fuzz targets, race-detector clean. ## Quick Reference @@ -264,6 +264,7 @@ provider is implemented — reaching IMDS from an agent process is a separate se ### `context.go` — Turn-level Context Control +- **`(*Agent) KeepPath(p)` / `UnkeepPath(p)` / `keepsPath(args)`** — a kept path is one whose tool reads must survive the compaction of a run: `compactRunMessages` keeps any round whose `tool_calls` arguments name it and summarizes the rest. Set before a run; the list travels with the session (`session.Session.KeptPaths`) so a resume keeps the rule. - **`(*Agent) PinLast(n)` / `UnpinLast(n)` / `DropLast(n)` / `PinnedTurnTexts()` / `PinnedTurnCount()`** — 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 @@ -873,7 +874,7 @@ User Input (textarea / CLI arg) ## Testing -- **1285 test functions + 10 fuzz targets** across the repository — what `go test ./... -count=1` runs for the main module, plus the evaluation corpora under `eval/` (nested modules the command still walks); each count below is listed with the command that produces it, and no count is carried in a copy that nothing checks +- **1291 test functions + 10 fuzz targets** across the repository — what `go test ./... -count=1` runs for the main module, plus the evaluation corpora under `eval/` (nested modules the command still walks); 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`) @@ -919,7 +920,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 # 1285 test functions (main module) + | xargs -0 grep -h '^func Test' | wc -l # 1291 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 f18b9cc..4200021 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 -- **1285 test functions + 10 fuzz targets** across the repository — the main module plus the evaluation corpora, with `tuiprobe/` a nested module the command below excludes — 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 +- **1291 test functions + 10 fuzz targets** across the repository — the main module plus the evaluation corpora, with `tuiprobe/` a nested module the command below excludes — 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/agent.go b/agent/agent.go index a66f6b6..5d1185f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -29,6 +29,10 @@ type Agent struct { // MemoryRecallLimit is how many facts the recall step injects; zero means // DefaultMemoryRecallLimit. It comes from memory.recall_limit. MemoryRecallLimit int + // KeptPaths name files whose tool reads survive the compaction of a run + // (roadmap B5, S4). Set before a run; the TUI persists the list with the + // session so a resume keeps the rule. + KeptPaths []string // Session persistence SessionStore interface { Append(msg types.Message) error diff --git a/agent/compaction.go b/agent/compaction.go index fafffcf..177c2a5 100644 --- a/agent/compaction.go +++ b/agent/compaction.go @@ -9,6 +9,18 @@ import ( "github.com/yusiwen/tinycode/types" ) +// roundKeepsPath reports whether any tool call in a round names a kept path. +func (a *Agent) roundKeepsPath(round []types.Message) bool { + for _, msg := range round { + for _, call := range msg.ToolCalls { + if a.keepsPath(call.Arguments) { + return true + } + } + } + return false +} + // compactRunMessages compresses the *run's own* traffic — the assistant and tool // messages between the prompt and now — when it crosses the compression // threshold (roadmap B5, S2). @@ -53,13 +65,41 @@ func (a *Agent) compactRunMessages(ctx context.Context, messages []types.Message return messages, false } + // A round whose tool call names a kept path is context the user asked to + // keep: it is not summarized, and it is put back after the marker and before + // the recent tail (roadmap B5, S4). + var ( + text strings.Builder + kept []types.Message + ) + for i := 0; i < cut; { + end := i + 1 + if rest[i].Role == types.RoleAssistant { + for end < cut && rest[end].Role == types.RoleTool { + end++ + } + } + round := rest[i:end] + if round[0].Role == types.RoleAssistant && a.roundKeepsPath(round) { + kept = append(kept, round...) + } else { + text.WriteString(describeMessages(round)) + } + i = end + } + if strings.TrimSpace(text.String()) == "" { + // Everything in the compactable part is kept: a summary would be of + // nothing, so leave the run alone. + return messages, false + } + const instruction = `Summarize what this run has done so far. Focus on: - The tools that were called and what they established - Files read or modified, and why - What is still open Run so far:` - summary, err := a.summarizeText(ctx, instruction, describeMessages(rest[:cut]), 1024, step) + summary, err := a.summarizeText(ctx, instruction, text.String(), 1024, step) if err != nil { // A failed compaction costs the run one step of context, not the run: // the messages stay as they were and the loop continues. @@ -70,12 +110,13 @@ Run so far:` return messages, false } - out := make([]types.Message, 0, len(messages)-cut+1) + out := make([]types.Message, 0, len(messages)-cut+1+len(kept)) out = append(out, messages[:prompt+1]...) out = append(out, types.Message{ Role: types.RoleSystem, Content: fmt.Sprintf("[COMPACTED RUN — REFERENCE ONLY]\n%s\n[END COMPACTED RUN]", summary), }) + out = append(out, kept...) out = append(out, rest[cut:]...) tlog.Info("agent.loop", "compacted run messages", "summarized", cut, "kept", len(rest)-cut, "step", step) return out, true diff --git a/agent/compaction_test.go b/agent/compaction_test.go index 08a121c..64aa616 100644 --- a/agent/compaction_test.go +++ b/agent/compaction_test.go @@ -190,3 +190,77 @@ func TestRunCompactsItsOwnMessages(t *testing.T) { t.Fatalf("no provider request carried a compacted run across %d calls", len(provider.requests)) } } + +// keptRunMessages builds a run whose second round reads a file the test keeps, +// followed by filler rounds and a recent tail. +func keptRunMessages() []types.Message { + messages := []types.Message{ + {Role: types.RoleSystem, Content: "system prompt"}, + {Role: types.RoleUser, Content: "do the thing"}, + } + round := func(id, path, output string) []types.Message { + return []types.Message{ + {Role: types.RoleAssistant, ToolCalls: []types.ToolCall{{ + ID: id, Name: "read_file", Arguments: `{"path":"` + path + `"}`, + }}}, + {Role: types.RoleTool, ToolCallID: id, Name: "read_file", Content: output}, + } + } + messages = append(messages, round("1", "internal/filler.go", "filler one")...) + messages = append(messages, round("2", "internal/kept.go", "KEPT CONTENT")...) + for i := 0; i < 4; i++ { + messages = append(messages, round(string(rune('3'+i)), "internal/filler.go", "filler round")...) + } + return messages +} + +// TestCompactRunMessagesKeepsPathsTheUserKept is S4's acceptance: a read of a +// kept path survives compaction verbatim and the summary does not absorb it, +// while a read of any other path is summarized away as before. +func TestCompactRunMessagesKeepsPathsTheUserKept(t *testing.T) { + provider := &recordingProvider{content: "RUN SUMMARY"} + ag := New(provider) + ag.ContextLength = 1000 + ag.CompressionThreshold = 1 + if !ag.KeepPath("internal/kept.go") { + t.Fatal("the path was not kept") + } + + in := keptRunMessages() + out, changed := ag.compactRunMessages(context.Background(), in, 2) + if !changed { + t.Fatal("the run was not compacted") + } + if !containsMessage(out, "KEPT CONTENT") { + t.Fatal("the kept read was summarized away") + } + if containsMessage(out, "filler one") { + t.Fatal("an unkept read survived the summary") + } + if !containsMessage(out, "RUN SUMMARY") { + t.Fatal("the summary is missing") + } + if prompt := provider.requests[0].Messages[1].Content; strings.Contains(prompt, "KEPT CONTENT") { + t.Fatalf("the summarizer was given the kept read:\n%s", prompt) + } +} + +// TestCompactRunMessagesDoesNothingWhenEveryRoundIsKept: if every read is kept, +// a summary would be of nothing, so the run is left alone and no call is paid. +func TestCompactRunMessagesDoesNothingWhenEveryRoundIsKept(t *testing.T) { + provider := &recordingProvider{content: "RUN SUMMARY"} + ag := New(provider) + ag.ContextLength = 1000 + ag.CompressionThreshold = 1 + ag.KeepPath("internal/filler.go") + ag.KeepPath("internal/kept.go") + + in := keptRunMessages() + out, changed := ag.compactRunMessages(context.Background(), in, 2) + if changed { + t.Fatalf("a fully kept run was compacted: %d -> %d messages", len(in), len(out)) + } + if len(provider.requests) != 0 { + t.Fatalf("the summarizer was called %d times for nothing", len(provider.requests)) + } +} diff --git a/agent/context.go b/agent/context.go index 4137c2c..6b38110 100644 --- a/agent/context.go +++ b/agent/context.go @@ -1,6 +1,62 @@ package agent -import "github.com/yusiwen/tinycode/types" +import ( + "path/filepath" + "strings" + + "github.com/yusiwen/tinycode/types" +) + +// Path-level control over compaction (roadmap B5, S4). +// +// A run's messages hold the tool results, so "unload this file" is a decision +// about those. `/drop ` cannot make it — the messages live inside Run and +// the TUI refuses commands while a run is active — so the decision is made +// before the run instead: a kept path's reads survive compaction verbatim, and +// every other read is summarized away as usual. + +// KeepPath adds a path whose tool reads must survive compaction. The path is +// cleaned and stored once; an empty path is ignored. +func (a *Agent) KeepPath(path string) (added bool) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return false + } + for _, kept := range a.KeptPaths { + if kept == path { + return false + } + } + a.KeptPaths = append(a.KeptPaths, path) + return true +} + +// UnkeepPath removes a path, reporting whether it was there. +func (a *Agent) UnkeepPath(path string) (removed bool) { + path = filepath.Clean(strings.TrimSpace(path)) + for i, kept := range a.KeptPaths { + if kept != path { + continue + } + a.KeptPaths = append(a.KeptPaths[:i], a.KeptPaths[i+1:]...) + return true + } + return false +} + +// keepsPath reports whether a tool call's arguments name a kept path. Arguments +// are the signal because that is where a read names the file it reads. +func (a *Agent) keepsPath(arguments string) bool { + if arguments == "" { + return false + } + for _, kept := range a.KeptPaths { + if kept != "" && strings.Contains(arguments, kept) { + return true + } + } + return false +} // Turn-level control over the live history (roadmap B5). // diff --git a/agent/context_test.go b/agent/context_test.go index d93508b..071aaa9 100644 --- a/agent/context_test.go +++ b/agent/context_test.go @@ -194,3 +194,27 @@ func TestPinnedTurnTexts(t *testing.T) { t.Fatalf("PinnedTurnCount = %d, want 1", got) } } + +// TestKeepPathCleansAndDeduplicates pins the list's shape: cleaned, stored once, +// and empty values ignored. +func TestKeepPathCleansAndDeduplicates(t *testing.T) { + ag := New(nil) + if !ag.KeepPath(" internal/store.go ") { + t.Fatal("a path was not kept") + } + if ag.KeepPath("internal/store.go") { + t.Fatal("the same path was kept twice") + } + if ag.KeepPath(" ") { + t.Fatal("an empty path was kept") + } + if len(ag.KeptPaths) != 1 || ag.KeptPaths[0] != "internal/store.go" { + t.Fatalf("kept paths = %v", ag.KeptPaths) + } + if !ag.UnkeepPath("internal/store.go") { + t.Fatal("the path was not dropped") + } + if ag.UnkeepPath("internal/store.go") { + t.Fatal("dropping a path twice reported success") + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index c58a295..4a80c2d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,9 +16,9 @@ after it (see *Provenance and method*). | State | Entries | Note | | :--- | ---: | :--- | -| Delivered | 16 | the entry's **Picked up** line names the issue that landed it | +| Delivered | 17 | 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 | 2 | F1 — three increments landed, the next one is not opened; B5 — S1 landed, S2 and S3 open | +| In progress | 1 | F1 — three increments landed, the next one is not opened | | 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` | @@ -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 — spiked 2026-10-28, no-go (#308) | -| B5 | Context management has a single strategy | In progress — #306 | +| B5 | Context management has a single strategy | Delivered — #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 | @@ -395,7 +395,14 @@ current tree. D7 is the exception: it was recorded later, so its references are own messages — the part D7 depends on — the summarizer's spend now counted, and pins carried through the session file), S3 (a compression preview that calls no model — it reads the same partition `compressHistory` executes), S4 (a path-targeted unload, split out of S2 because the command channel it - assumed does not exist). **S1–S3 landed** (2026-10-28, 2026-10-31); S4 is open. + assumed does not exist). **S1–S4 landed** (2026-10-28 … 2026-10-31); the entry is delivered. +- **Delivered (2026-10-31)**: the single strategy is now four surfaces — turn-level `/pin`, `/unpin` and + `/drop` (pins survive compression and a resume), compaction of the run's own messages at a step + boundary, `/compress preview` (it reads the same plan the compression executes, and makes no model + call), and `/keep `, which keeps a file's tool reads verbatim through a compaction. The + summarizer's spend, which used to be invisible to the budgets, is counted. The one thing not built is a + *mid-run* command to unload a file: the run owns its messages and the TUI refuses commands while it + runs, so the rule is set before the run instead — recorded in #306's S4. - **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/session/session.go b/session/session.go index 8fdf04d..d78525d 100644 --- a/session/session.go +++ b/session/session.go @@ -38,6 +38,11 @@ type Session struct { // Permission paths allowed for this session (Allow session) AllowedPaths []string `json:"allowed_paths,omitempty"` + // KeptPaths are files whose tool reads must survive compaction of a run + // (roadmap B5, S4). They live with the session because the rule is the + // user's, and a resume has to keep it. + KeptPaths []string `json:"kept_paths,omitempty"` + // Containment records what enforced file boundaries when this session ran, // as reported by the tool package at creation ("kernel (…)", // "userspace (…)"). It is recorded once because it is a property of the diff --git a/tui/context_command.go b/tui/context_command.go index 6031964..bec24ae 100644 --- a/tui/context_command.go +++ b/tui/context_command.go @@ -40,6 +40,11 @@ func (m *TuiModel) handleContextCommand(cmd, name string) (tea.Model, tea.Cmd) { m.ShowStatus("Cannot " + strings.TrimPrefix(name, "/") + ": a run is in progress") return m, nil } + if name == "/keep" || name == "/unkeep" { + m.handleKeep(cmd, name) + return m, nil + } + n, err := contextTurns(cmd, name) if err != nil { m.appendSystem(err.Error()) @@ -106,3 +111,37 @@ func (m *TuiModel) syncPinsToTranscript() { } } } + +// handleKeep manages the paths whose tool reads survive compaction (S4 of #306). +// The rule is set before a run — that is why it is a command and not something a +// mid-run model call decides — and it is persisted with the session. +func (m *TuiModel) handleKeep(cmd, name string) { + arg := strings.TrimSpace(strings.TrimPrefix(cmd, name)) + if name == "/keep" && arg == "" { + if len(m.agent.KeptPaths) == 0 { + m.appendSystem("no kept paths; /keep keeps a file's reads in context across a compaction") + return + } + m.appendSystem("kept paths (their reads survive compaction):\n " + strings.Join(m.agent.KeptPaths, "\n ")) + return + } + if arg == "" { + m.appendSystem("usage: /unkeep ") + return + } + if name == "/keep" { + if !m.agent.KeepPath(arg) { + m.appendSystem("already kept: " + arg) + return + } + m.persistSession() + m.appendSystem("kept " + arg + ": a tool call that names it is kept verbatim when the run is compacted") + return + } + if !m.agent.UnkeepPath(arg) { + m.appendSystem("not kept: " + arg) + return + } + m.persistSession() + m.appendSystem("dropped " + arg + " from the kept paths") +} diff --git a/tui/context_command_test.go b/tui/context_command_test.go index 159f1c9..1044b24 100644 --- a/tui/context_command_test.go +++ b/tui/context_command_test.go @@ -229,3 +229,88 @@ func TestCompressPreviewRefusedDuringARun(t *testing.T) { t.Fatalf("status = %q", m.statusMsg) } } + +// TestKeepCommands pins the path rule's commands: add, list, drop, and the +// message a user gets for each. +func TestKeepCommands(t *testing.T) { + m := newTestTUI() + + m.handleCommand("/keep internal/store.go") + if !strings.Contains(lastMessage(m), "kept internal/store.go") { + t.Fatalf("reply = %q", lastMessage(m)) + } + if len(m.agent.KeptPaths) != 1 || m.agent.KeptPaths[0] != "internal/store.go" { + t.Fatalf("kept paths = %v", m.agent.KeptPaths) + } + + m.handleCommand("/keep") + if !strings.Contains(lastMessage(m), "internal/store.go") { + t.Fatalf("listing = %q", lastMessage(m)) + } + + m.handleCommand("/keep internal/store.go") + if !strings.Contains(lastMessage(m), "already kept") { + t.Fatalf("duplicate reply = %q", lastMessage(m)) + } + + m.handleCommand("/unkeep internal/store.go") + if !strings.Contains(lastMessage(m), "dropped internal/store.go") { + t.Fatalf("unkeep reply = %q", lastMessage(m)) + } + if len(m.agent.KeptPaths) != 0 { + t.Fatalf("kept paths after unkeep = %v", m.agent.KeptPaths) + } + m.handleCommand("/unkeep internal/store.go") + if !strings.Contains(lastMessage(m), "not kept") { + t.Fatalf("unkeep twice reply = %q", lastMessage(m)) + } +} + +// TestKeepPathSurvivesTheSessionFile pins S4's persistence acceptance: the rule +// is the user's, so it travels with the session and comes back on resume. +func TestKeepPathSurvivesTheSessionFile(t *testing.T) { + m := newTestTUI() + m.SessionDir = t.TempDir() + m.messages = append(m.messages, chatMessage{Role: "user", Content: "hello"}) + m.agent.History = append(m.agent.History, types.Message{Role: types.RoleUser, Content: "hello"}) + + m.handleCommand("/keep internal/store.go") + + saved, err := session.Load(m.currentBranch, m.SessionDir) + if err != nil { + t.Fatalf("load session: %v", err) + } + if len(saved.KeptPaths) != 1 || saved.KeptPaths[0] != "internal/store.go" { + t.Fatalf("the session kept %v", saved.KeptPaths) + } + + // The resume path restores it into the agent. + next := newTestTUI() + next.adoptSession(saved) + if len(next.agent.KeptPaths) != 1 || next.agent.KeptPaths[0] != "internal/store.go" { + t.Fatalf("adoptSession restored %v", next.agent.KeptPaths) + } +} + +// TestKeepRefusedDuringARun pins the guard: the rule is read by the run's +// compaction, so it must not change under one. +func TestKeepRefusedDuringARun(t *testing.T) { + m := newTestTUI() + m.runMu.Lock() + m.runActive = true + m.runMu.Unlock() + t.Cleanup(func() { + m.runMu.Lock() + m.runActive = false + m.runMu.Unlock() + }) + + m.handleCommand("/keep internal/store.go") + + if !strings.Contains(m.statusMsg, "a run is in progress") { + t.Fatalf("status = %q", m.statusMsg) + } + if len(m.agent.KeptPaths) != 0 { + t.Fatalf("the rule changed during a run: %v", m.agent.KeptPaths) + } +} diff --git a/tui/model.go b/tui/model.go index ddbd4f0..bbb5864 100644 --- a/tui/model.go +++ b/tui/model.go @@ -249,6 +249,8 @@ func commandList() []cmdEntry { {"/pin", "Keep the last turn out of compression"}, {"/unpin", "Clear a pin on the last turn"}, {"/drop", "Drop the last turn from this context"}, + {"/keep", "Keep a file's reads out of compaction"}, + {"/unkeep", "Drop a kept path"}, {"/remember", "Store a fact for later sessions"}, {"/memory", "List or forget stored facts"}, {"/sandbox", "Sandbox status"}, @@ -322,6 +324,8 @@ func (m *TuiModel) adoptSession(branch *session.Session) { // The model's copy is rebuilt with the same rule a resume uses: user and // assistant turns only, never command output and never prior reasoning. m.agent.History = historyFromSession(branch.Messages) + // The kept-path rule is the user's, and it survives the file with the turns. + m.agent.KeptPaths = append([]string(nil), branch.KeptPaths...) if m.todoStore != nil { // Replace, never merge: an empty list has to clear the previous // session's items, which is exactly what a merge would keep. diff --git a/tui/testdata/golden/frames/palette_120x40.txt b/tui/testdata/golden/frames/palette_120x40.txt index 5ce0d08..32504cd 100644 --- a/tui/testdata/golden/frames/palette_120x40.txt +++ b/tui/testdata/golden/frames/palette_120x40.txt @@ -50,6 +50,8 @@ /pin Keep the last turn out of compression /unpin Clear a pin on the last turn /drop Drop the last turn from this context + /keep Keep a file's reads out of compaction + /unkeep Drop a kept path /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 92f8b11..9ae77fc 100644 --- a/tui/testdata/golden/frames/palette_30x12.txt +++ b/tui/testdata/golden/frames/palette_30x12.txt @@ -35,6 +35,10 @@ /drop Drop the last turn from this context + /keep + Keep a file's reads out of + compaction + /unkeep Drop a kept path /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 a1ce46d..ae1f1bb 100644 --- a/tui/testdata/golden/frames/palette_40x12.txt +++ b/tui/testdata/golden/frames/palette_40x12.txt @@ -28,6 +28,10 @@ /unpin Clear a pin on the last turn /drop Drop the last turn from this context + /keep + Keep a file's reads out of + compaction + /unkeep Drop a kept path /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 06f3442..902e135 100644 --- a/tui/testdata/golden/frames/palette_80x24.txt +++ b/tui/testdata/golden/frames/palette_80x24.txt @@ -34,6 +34,8 @@ /pin Keep the last turn out of compression /unpin Clear a pin on the last turn /drop Drop the last turn from this context + /keep Keep a file's reads out of compaction + /unkeep Drop a kept path /remember Store a fact for later sessions /memory List or forget stored facts /sandbox Sandbox status diff --git a/tui/testdata/scenarios/palette.scenario b/tui/testdata/scenarios/palette.scenario index c1ba361..d70f6fc 100644 --- a/tui/testdata/scenarios/palette.scenario +++ b/tui/testdata/scenarios/palette.scenario @@ -8,7 +8,10 @@ open --dir . --size 80x24 --env HOME=/tmp/tinyscen-home --env TERM=xterm-256colo wait --text "Type your request" --timeout 20s wait --text TinyCode --timeout 30s send --text / -wait --text Commands: --timeout 10s +# Wait for the first entry, not the "Commands:" heading: the palette grows, and at +# 80x24 the heading scrolls off before the list does. The first entry is what +# "the palette is open" means here. +wait --text "Show help" --timeout 10s send --text theme wait --text theme --timeout 10s screenshot /tmp/tinyscen-shots/palette-80x24.png --scale 2 diff --git a/tui/update.go b/tui/update.go index b715c9a..8fa0d85 100644 --- a/tui/update.go +++ b/tui/update.go @@ -993,6 +993,9 @@ func (m *TuiModel) persistSession() { s = session.New(id, m.SessionDir) } s.Containment = tool.ContainmentInfo().String() + if m.agent != nil { + s.KeptPaths = append([]string(nil), m.agent.KeptPaths...) + } for _, chatMsg := range m.messages { s.Append(types.Message{ Role: chatMsg.Role, @@ -1240,6 +1243,8 @@ Commands: /pin [n] Keep the last n turns out of compression (default 1) /unpin [n] Clear the pin on the last n pinned turns /drop [n] Drop the last n turns from this context (default 1) + /keep Keep a file's reads out of run compaction (/keep alone lists) + /unkeep Drop a kept path /model Switch provider/model Keyboard: @@ -1472,7 +1477,7 @@ Mouse: // Creating or resuming a goal while the session is idle starts working // on it after the grace period; pausing or clearing schedules nothing. return m, m.scheduleGoalRound() - case "/pin", "/unpin", "/drop": + case "/pin", "/unpin", "/drop", "/keep", "/unkeep": return m.handleContextCommand(cmd, base) case "/remember": m.handleRemember(strings.TrimSpace(strings.TrimPrefix(cmd, "/remember")))