From be172e949c52ddf130c27bdf493676e245a20686 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Sat, 20 Jun 2026 19:46:24 +0200 Subject: [PATCH 1/6] feat(skills): scan .agents/skills as a third skill location Discover Agent Skills from the open-standard .agents/skills//SKILL.md in addition to project .infer/skills/ and user-global ~/.infer/skills/. Precedence is project > agents > user (first match wins on name collision), so a repo that ships skills under the cross-tool .agents/skills/ convention works without moving them into .infer/skills/. - domain: add SkillScopeAgents scope value - config: add AgentsDirName; carve .agents/skills out of the sandbox so SKILL.md and references/*.md read even under a restrictive sandbox (parity with .infer/skills) - skills: insert .agents/skills into searchScopes, between project and user - cmd/skills + docs/skills + docs/directory-structure: document the location Closes #639 --- cmd/skills.go | 13 ++--- config/config.go | 11 +++-- config/config_test.go | 51 +++++++++++++++++++ docs/directory-structure.md | 8 +++ docs/skills.md | 20 +++++--- internal/domain/skills.go | 10 ++-- internal/services/skills/skills.go | 7 ++- internal/services/skills/skills_test.go | 66 +++++++++++++++++++++++++ 8 files changed, 164 insertions(+), 22 deletions(-) diff --git a/cmd/skills.go b/cmd/skills.go index 63f2cfcb..a84a5414 100644 --- a/cmd/skills.go +++ b/cmd/skills.go @@ -20,10 +20,11 @@ var skillsCmd = &cobra.Command{ Skills are folders containing a SKILL.md file with YAML frontmatter (name, description). The format matches the contract shared by the official spec, so existing skill folders authored for -any of them can be dropped into .infer/skills/ unchanged. +any of them can be dropped into .infer/skills/ (or the .agents/skills/ open standard) unchanged. -Locations scanned (project precedes user-global on name collision): +Locations scanned (highest precedence first; first match wins on name collision): - .infer/skills//SKILL.md (project) + - .agents/skills//SKILL.md (open standard) - ~/.infer/skills//SKILL.md (user-global) Skills are enabled by default - disable via agent.skills.enabled=false in config or @@ -34,11 +35,11 @@ the enable flag so you can verify discovery.`, var skillsListCmd = &cobra.Command{ Use: "list", Short: "List discovered skills", - Long: `List discovered Agent Skills from .infer/skills/ and ~/.infer/skills/. + Long: `List discovered Agent Skills from .infer/skills/, .agents/skills/, and ~/.infer/skills/. -Output includes each skill's name, scope (project / user), one-line description, -and the absolute path to its SKILL.md. Validation errors for skipped skills are -shown so you can fix authoring mistakes.`, +Output includes each skill's name, scope (project / agents / user), one-line +description, and the absolute path to its SKILL.md. Validation errors for +skipped skills are shown so you can fix authoring mistakes.`, RunE: listSkills, } diff --git a/config/config.go b/config/config.go index c9a65a47..81dfae80 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ import ( const ( ConfigDirName = ".infer" + AgentsDirName = ".agents" // open-standard skills dir (.agents/skills/), scanned project-relative only ConfigFileName = "config.yaml" GitignoreFileName = ".gitignore" LogsDirName = "logs" @@ -1161,17 +1162,21 @@ func (c *Config) ValidatePathInSandbox(path string) error { return fmt.Errorf("path '%s' is outside configured sandbox directories", path) } -// isWithinSkillsDir reports whether absPath lives inside either the project -// (./.infer/skills) or user-global (~/.infer/skills) skills directory. Feeds +// isWithinSkillsDir reports whether absPath lives inside one of the skills +// directories: the project (./.infer/skills), the open-standard +// (./.agents/skills), or the user-global (~/.infer/skills) location. Feeds // the carveOut path in ValidatePathInSandbox - gated there on // agent.skills.enabled - so reads of SKILL.md and references/*.md succeed even // though the broader .infer/ directory is in ProtectedPaths. File-level // protections like *.env still apply. func isWithinSkillsDir(absPath string) bool { - dirs := make([]string, 0, 2) + dirs := make([]string, 0, 3) if projectDir, err := filepath.Abs(filepath.Join(ConfigDirName, "skills")); err == nil { dirs = append(dirs, projectDir) } + if agentsDir, err := filepath.Abs(filepath.Join(AgentsDirName, "skills")); err == nil { + dirs = append(dirs, agentsDir) + } if homeDir, err := os.UserHomeDir(); err == nil { dirs = append(dirs, filepath.Join(homeDir, ConfigDirName, "skills")) } diff --git a/config/config_test.go b/config/config_test.go index 68b7d829..ca3e1138 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -957,6 +957,57 @@ func TestValidatePathInSandbox_SkillsCarveOut(t *testing.T) { }) } +// TestValidatePathInSandbox_AgentsSkillsCarveOut covers the .agents/skills open +// standard. Unlike .infer/, the .agents/ directory is not in ProtectedPaths, so +// the carve-out is only observable against a *restrictive* sandbox: with skills +// enabled, .agents/skills/** must be readable even though it sits outside the +// configured sandbox dirs (parity with .infer/skills), while non-skills paths +// under .agents and lookalike siblings stay denied. +func TestValidatePathInSandbox_AgentsSkillsCarveOut(t *testing.T) { + sandboxDir := t.TempDir() // an unrelated, explicitly-allowed sandbox dir + + agentsSkill, err := filepath.Abs(filepath.Join(AgentsDirName, "skills", "demo", "SKILL.md")) + if err != nil { + t.Fatalf("failed to resolve .agents skill path: %v", err) + } + relAgentsSkill := filepath.Join(AgentsDirName, "skills", "demo", "SKILL.md") + agentsRef := filepath.Join(AgentsDirName, "skills", "demo", "references", "guide.md") + agentsNonSkill := filepath.Join(AgentsDirName, "config.yaml") + agentsLookalike := filepath.Join(AgentsDirName, "skills-evil", "demo", "SKILL.md") + + t.Run("skills enabled: .agents/skills carved out of a restrictive sandbox", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Tools.Sandbox.Directories = []string{sandboxDir} + if !cfg.Agent.Skills.Enabled { + t.Fatalf("expected skills enabled by default") + } + + for _, p := range []string{agentsSkill, relAgentsSkill, agentsRef} { + if err := cfg.ValidatePathInSandbox(p); err != nil { + t.Fatalf("expected %s allowed via carve-out, got %v", p, err) + } + } + + for _, p := range []string{agentsNonSkill, agentsLookalike} { + if err := cfg.ValidatePathInSandbox(p); err == nil { + t.Fatalf("expected %s denied (not a skills path, outside sandbox)", p) + } + } + }) + + t.Run("skills disabled: .agents/skills denied by the restrictive sandbox", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Tools.Sandbox.Directories = []string{sandboxDir} + cfg.Agent.Skills.Enabled = false + + for _, p := range []string{agentsSkill, relAgentsSkill} { + if err := cfg.ValidatePathInSandbox(p); err == nil { + t.Fatalf("expected %s denied while skills are disabled", p) + } + } + }) +} + // TestValidatePathInSandbox_ConfigDir locks in the directory-wide protection of // the config dir: sensitive config files are denied wholesale, while the // operational subdirs (tmp, plans) stay reachable - except for files that match diff --git a/docs/directory-structure.md b/docs/directory-structure.md index 7afb27d8..94c7d44f 100644 --- a/docs/directory-structure.md +++ b/docs/directory-structure.md @@ -53,6 +53,7 @@ for the full precedence rules. │ ├── shells.yaml │ ├── export.yaml │ └── a2a.yaml +├── skills/ # Agent Skills - SKILL.md folders, see docs/skills.md ├── .gitignore # ignores the runtime-generated files below │ │ # --- created at runtime, not by `infer init` --- @@ -64,6 +65,10 @@ for the full precedence rules. ├── plans/ # plan-mode plans saved by RequestPlanApproval (one .md per plan) └── history # chat input history (one entry per line) +.agents/ # open-standard project layer (cross-tool skills) +└── skills/ # Agent Skills - SKILL.md folders (read-only discovery) + └── /SKILL.md # e.g. .agents/skills/pdf/SKILL.md + ~/.infer/ # userspace layer - same set of config files, # plus one extra: └── schedules/ # cron-driven scheduled jobs (one YAML per job) @@ -98,6 +103,9 @@ them exist in both layers (project and userspace). - **`shortcuts/*.yaml`** - `/git`, `/scm`, `/mcp`, `/shells`, `/export`, `/agents` shortcuts plus any you add. Drop new YAML files into `shortcuts/`. See [Shortcuts Guide](shortcuts-guide.md). +- **`skills/`** - Agent Skills directory. Drop a `SKILL.md` folder here (or + into the cross-tool `.agents/skills/` open standard) to extend the agent. + See [Skills](skills.md). - **`.gitignore`** - pre-populated to exclude the runtime-generated files below. diff --git a/docs/skills.md b/docs/skills.md index 93c05784..e8616c52 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -2,7 +2,8 @@ Skills are reusable, model-readable instruction folders that the agent loads on demand. The Inference Gateway CLI uses the **same on-disk format** as per standard, so a folder -authored for any of those tools drops into `.infer/skills/` unchanged. +authored for any of those tools drops into `.infer/skills/` (or the +`.agents/skills/` open standard) unchanged. ## Format @@ -41,13 +42,18 @@ failures even though the CLI ignores them. ## Locations -The CLI scans two directories: +The CLI scans three directories, in precedence order (first match wins on a +`name` collision): -- Project-local: `.infer/skills//SKILL.md` -- User-global: `~/.infer/skills//SKILL.md` +1. Project-local: `.infer/skills//SKILL.md` +2. Open standard: `.agents/skills//SKILL.md` +3. User-global: `~/.infer/skills//SKILL.md` -Project skills override user-global skills with the same `name` - useful for -overriding a personal default with a per-project variant. +`.agents/skills/` is the emerging cross-tool convention (Claude Code, Gemini +CLI, Codex CLI), so a repo that ships skills there works without moving them +into `.infer/skills/`. A project's `.infer/skills/` still wins over both the +open-standard and user-global locations - useful for overriding a personal or +shared default with a per-project variant. ## Enabling @@ -180,7 +186,7 @@ The on-disk contract is intentionally identical to: - OpenAI Codex CLI - Folders from `github.com/anthropics/skills` and `github.com/google/skills` -work without modification when copied into `.infer/skills/`. +work without modification when copied into `.infer/skills/` or `.agents/skills/`. ## Out of scope (for now) diff --git a/internal/domain/skills.go b/internal/domain/skills.go index e1639b92..b069f0f6 100644 --- a/internal/domain/skills.go +++ b/internal/domain/skills.go @@ -2,14 +2,16 @@ package domain import "context" -// SkillScope identifies whether a skill came from the project (.infer/skills/) -// or the user-global location (~/.infer/skills/). Used by the system-prompt -// injection and `infer skills list` to disambiguate same-name skills and to -// tell the user where each skill lives. +// SkillScope identifies where a skill came from: the project (.infer/skills/), +// the open-standard location (.agents/skills/), or the user-global location +// (~/.infer/skills/). Used by the system-prompt injection and `infer skills +// list` to disambiguate same-name skills and to tell the user where each skill +// lives. type SkillScope string const ( SkillScopeProject SkillScope = "project" + SkillScopeAgents SkillScope = "agents" SkillScopeUser SkillScope = "user" ) diff --git a/internal/services/skills/skills.go b/internal/services/skills/skills.go index 607b22ea..d3401610 100644 --- a/internal/services/skills/skills.go +++ b/internal/services/skills/skills.go @@ -165,11 +165,14 @@ type scopedDir struct { scope domain.SkillScope } -// searchScopes returns project-first then user-global. Project precedence is -// implemented by the caller via the `seen` map. +// searchScopes returns the skill directories in precedence order: project +// (.infer/skills), then the open-standard .agents/skills, then user-global +// (~/.infer/skills). Precedence is implemented by the caller via the `seen` +// map (first match wins on name collision). func searchScopes() []scopedDir { scopes := []scopedDir{ {dir: filepath.Join(config.ConfigDirName, skillsSubdir), scope: domain.SkillScopeProject}, + {dir: filepath.Join(config.AgentsDirName, skillsSubdir), scope: domain.SkillScopeAgents}, } if home, err := os.UserHomeDir(); err == nil { scopes = append(scopes, scopedDir{ diff --git a/internal/services/skills/skills_test.go b/internal/services/skills/skills_test.go index 47e8177a..7b0e033e 100644 --- a/internal/services/skills/skills_test.go +++ b/internal/services/skills/skills_test.go @@ -190,6 +190,72 @@ func TestPrecedence_ProjectOverridesUser(t *testing.T) { require.Equal(t, domain.SkillScopeUser, byName["user-only"].Scope) } +// TestPrecedence_AgentsMiddleScope locks in the three-way precedence introduced +// for the .agents/skills open standard: project > agents > user, first match +// wins on a name collision. +func TestPrecedence_AgentsMiddleScope(t *testing.T) { + projDir := t.TempDir() + agentsDir := t.TempDir() + userDir := t.TempDir() + + // Present in all three scopes -> project wins. + writeSkill(t, projDir, "all-three", validSkillBody("all-three", "Project version.")) + writeSkill(t, agentsDir, "all-three", validSkillBody("all-three", "Agents version.")) + writeSkill(t, userDir, "all-three", validSkillBody("all-three", "User version.")) + + // Present in agents and user only -> agents wins over user. + writeSkill(t, agentsDir, "agents-and-user", validSkillBody("agents-and-user", "Agents version.")) + writeSkill(t, userDir, "agents-and-user", validSkillBody("agents-and-user", "User version.")) + + // Scope-exclusive skills load tagged with their own scope. + writeSkill(t, agentsDir, "agents-only", validSkillBody("agents-only", "Only in .agents scope.")) + writeSkill(t, userDir, "user-only", validSkillBody("user-only", "Only in user scope.")) + + s := newWithScopes(enabledCfg(), []scopedDir{ + {dir: projDir, scope: domain.SkillScopeProject}, + {dir: agentsDir, scope: domain.SkillScopeAgents}, + {dir: userDir, scope: domain.SkillScopeUser}, + }) + require.NoError(t, s.Load(context.Background())) + + got := s.List() + require.Len(t, got, 4) + + byName := map[string]domain.Skill{} + for _, sk := range got { + byName[sk.Name] = sk + } + + require.Equal(t, "Project version.", byName["all-three"].Description) + require.Equal(t, domain.SkillScopeProject, byName["all-three"].Scope) + + require.Equal(t, "Agents version.", byName["agents-and-user"].Description) + require.Equal(t, domain.SkillScopeAgents, byName["agents-and-user"].Scope) + + require.Equal(t, domain.SkillScopeAgents, byName["agents-only"].Scope) + require.Equal(t, domain.SkillScopeUser, byName["user-only"].Scope) +} + +// TestSearchScopes_Order guards the scan order that the precedence dedup relies +// on: project (.infer/skills), then the open-standard .agents/skills, then +// user-global (~/.infer/skills). +func TestSearchScopes_Order(t *testing.T) { + scopes := searchScopes() + + require.GreaterOrEqual(t, len(scopes), 2) + require.Equal(t, domain.SkillScopeProject, scopes[0].scope) + require.Equal(t, filepath.Join(config.ConfigDirName, skillsSubdir), scopes[0].dir) + + require.Equal(t, domain.SkillScopeAgents, scopes[1].scope) + require.Equal(t, filepath.Join(config.AgentsDirName, skillsSubdir), scopes[1].dir) + + if home, err := os.UserHomeDir(); err == nil { + require.Len(t, scopes, 3) + require.Equal(t, domain.SkillScopeUser, scopes[2].scope) + require.Equal(t, filepath.Join(home, config.ConfigDirName, skillsSubdir), scopes[2].dir) + } +} + func TestDisabledFilter(t *testing.T) { tmp := t.TempDir() writeSkill(t, tmp, "alpha", validSkillBody("alpha", "First.")) From 931ec9abf39da84f3495ed06ae16405564621a06 Mon Sep 17 00:00:00 2001 From: "inference-gateway-maintainer[bot]" <246577062+inference-gateway-maintainer[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:49:53 +0200 Subject: [PATCH 2/6] ci(infer): centralize infer.yml + sync .infer config (#638) Centralized-config changes in one PR: 1. Adds/updates `.github/workflows/infer.yml` as a thin caller of the org reusable workflow `inference-gateway/.github/.github/workflows/infer.yml@v0.11.3`, wiring the `@infer` bot (inference-gateway/infer-action) into this repo. 2. Bumps this repo's Flox `infer` pin (`.flox/env/manifest.toml`) to `v0.121.1` (the latest `inference-gateway/cli` release) and refreshes `.flox/env/manifest.lock`. 3. Regenerates the committed `.infer/` config with `infer init --overwrite --skip-migrations` using that CLI. Setup lives centrally, so future moves (action bump, model, tool scope, CLI version) are a **re-run of `migrate-infer.yml`** instead of a hand-edit per repo. Ran in CI (mirrors `bump-adl.yml`): - sed the `inference-gateway/cli` pin in `.flox/env/manifest.toml` to the latest release - `flox upgrade infer` + `flox activate` to refresh `.flox/env/manifest.lock` - `flox activate -- infer init --overwrite --skip-migrations` to regenerate `.infer/` > **Heads-up:** `infer init --overwrite` resets `.infer/` to CLI defaults. `.infer/agents.yaml` > and `.infer/mcp.yaml` (this repo's A2A agents registry + MCP servers) are **preserved** > byte-for-byte; every other config file (`config.yaml`, `prompts.yaml`, `keybindings.yaml`, > `channels.yaml`, `computer_use.yaml`, `heartbeat.yaml`, `shortcuts/*`) is regenerated. > `.infer/bin`, `logs`, history and the conversations DB stay out via the nested > `.infer/.gitignore`. Review the diff before merging. - Triggers on `@infer` mentions in issues / issue comments. Default model `deepseek/deepseek-v4-flash`. The `infer-action` pin lives in the reusable `infer.yml`. - Also adds a manual `workflow_dispatch` form: from this repo's Actions tab pick **Infer**, type a free-text `prompt`, and the bot works it in that run and opens a PR (no issue needed) - mirroring the `@claude` workflow. - Tools = infer-action's default bash whitelist + the language preset (+ markdownlint where set), configured in `repos.yaml` (the entry's `orchestrators.infer` block). - Requires the maintainer GitHub App (`INFERENCE_GATEWAY_MAINTAINER_APP_ID` / `INFERENCE_GATEWAY_MAINTAINER_APP_PRIVATE_KEY`) installed on this repo plus the provider API-key secrets; both reach the reusable workflow via `secrets: inherit`. Filed by [migrate-infer.yml](https://github.com/inference-gateway/.github/blob/main/.github/workflows/migrate-infer.yml). Co-authored-by: inference-gateway-maintainer[bot] <246577062+inference-gateway-maintainer[bot]@users.noreply.github.com> --- .github/workflows/infer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/infer.yml b/.github/workflows/infer.yml index dcb36c10..968ab858 100644 --- a/.github/workflows/infer.yml +++ b/.github/workflows/infer.yml @@ -39,7 +39,7 @@ jobs: contents: write pull-requests: write issues: write - uses: inference-gateway/.github/.github/workflows/infer.yml@v0.10.3 + uses: inference-gateway/.github/.github/workflows/infer.yml@v0.11.3 with: language: "go" markdownlint: true From 227511cf784fb0b7de724b28b2623a517619a1c8 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Sat, 20 Jun 2026 19:20:25 +0200 Subject: [PATCH 3/6] feat(explorer): add snippet selection and annotation for targeted refactoring (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `/explorer` so you can browse a file, select a line range, optionally annotate it, and pull those lines into chat as **attached context** — part of making the CLI usable without reaching for a separate IDE. Selected snippets appear as a tree of file + line ranges below the chat input and are sent (selected lines only) with your next message. Closes #599. In `/explorer`, with a file previewed: - `s` — enter line-select mode - `↑/↓` (`j/k`) move the line cursor; `space`/`v` mark a range - **`enter` attaches the selected range immediately** (annotation optional) - `a` — add an optional natural-language note to the range, then `enter` attaches it - `esc` backs out of select mode; `esc`/`q` closes the explorer **carrying** the attachments to chat; `ctrl+c` discards Back in chat, attachments render as a tree (file + line ranges only) below the input. Multiple explorer trips accumulate. Manage them with: - `ctrl+g` — focus the tree - `↑/↓` move · `d` remove one · `c` clear all · `esc` leave On send, the **selected lines** (fenced, headed `path (lines X-Y):`, with the optional `note:`) are appended to your message and the tree clears. Slash/bash commands are not augmented, so attachments persist for the next real message. Empty input + attachments is a valid send. - **`internal/ui/components/file_explorer.go`** — `SnippetSelection` type + select-mode state. `enter` attaches the current range immediately (no required annotation); `a` adds an optional note. `esc`/`q` closes carrying selections, `ctrl+c` discards. `FormatAnnotations()` now emits **only the selected lines** (fenced, with a `path (lines X-Y):` header + optional `note:`) — not the whole file or a context window. - **`internal/ui/components/snippet_attachments_view.go`** *(new)* — `SnippetAttachmentsView`: renders pending attachments as a focusable tree (file parents, line-range children) with cursor/remove/clear and width-aware path truncation. - **`internal/ui/components/application_view.go`** — renders the attachments tree directly below the input, with height accounting so the input isn't pushed off-screen. - **`internal/app/chat.go`** — pending-attachments state; explorer close **carries** selections into chat (instead of dumping a blob into the input box); `ctrl+g` focus shim with nav/remove/clear; `SendMessage` appends the selected-lines block at send time and clears it (skipped for slash/bash commands). - **`config/keybindings.go`** — explorer `select`/`toggle_select`/`annotate`/`submit` actions + `chat.focus_attachments` (`ctrl+g`). - **Tests** — formatter (selected-lines-only), the attachments tree (grouping, cursor→index mapping, clamp), explorer keys (enter-attaches, close-carries), and the send-path gating/concatenation. - **Attached context, not input injection** — snippets land as a reviewable tree of file + line ranges below the input and are sent with the next message, instead of overwriting whatever you'd typed. - **Selected lines only** — exactly the chosen range is sent (no whole-file or context-window dump), keeping token use tight and the model's focus precise. - **Line-based selection** — deterministic and language-agnostic (no tree-sitter dependency). Syntax-tree-node selection remains a future enhancement. - **Optional annotations** — `enter` attaches immediately; `a` adds a per-snippet note when wanted. The tree shows only file + lines. - [x] `/explorer` supports a snippet-selection mode on an open file. - [x] Select a line range, rendered with a distinct gutter style. - [x] Attach an optional natural-language annotation. - [x] Selections stored as `(file, start_line, end_line, annotation)`. - [x] The selected lines (+ annotation) reach the LLM — sent with the next message. - [x] Multiple disjoint selections within one file and across files. - [x] Tests cover selection, attachment, the tree, and the send path. - `golangci-lint run` → 0 issues - `go test ./...` → all pass - `gofmt` / pre-commit hooks → pass None. CLI-internal TUI feature; docs added in-repo under `docs/features/`. ```bash task test go test ./internal/ui/components/... ./internal/app/... ``` --- .infer/keybindings.yaml | 31 ++ config/keybindings.go | 10 + docs/features/explorer.md | 53 ++ internal/app/chat.go | 177 ++++++- internal/app/chat_snippets_test.go | 58 +++ internal/handlers/chat_shortcut_handler.go | 2 +- internal/ui/components/application_view.go | 36 +- internal/ui/components/file_explorer.go | 420 ++++++++++++++- internal/ui/components/file_explorer_test.go | 480 ++++++++++++++++++ .../ui/components/snippet_attachments_view.go | 200 ++++++++ .../snippet_attachments_view_test.go | 105 ++++ 11 files changed, 1549 insertions(+), 23 deletions(-) create mode 100644 docs/features/explorer.md create mode 100644 internal/app/chat_snippets_test.go create mode 100644 internal/ui/components/snippet_attachments_view.go create mode 100644 internal/ui/components/snippet_attachments_view_test.go diff --git a/.infer/keybindings.yaml b/.infer/keybindings.yaml index 88beb39b..7f00fd56 100644 --- a/.infer/keybindings.yaml +++ b/.infer/keybindings.yaml @@ -7,6 +7,12 @@ bindings: description: send message or insert newline category: chat enabled: true + chat_focus_attachments: + keys: + - ctrl+g + description: focus the attached-snippets tree below the input (↑/↓ move · d remove · c clear · esc done) + category: chat + enabled: true clipboard_copy_text: keys: - ctrl+shift+c @@ -185,6 +191,12 @@ bindings: description: toggle todo list category: display enabled: true + explorer_annotate: + keys: + - a + description: annotate the selected range with an instruction + category: explorer + enabled: true explorer_cancel: keys: - esc @@ -257,6 +269,18 @@ bindings: description: scroll the preview up category: explorer enabled: true + explorer_select: + keys: + - s + description: enter line-selection mode on the previewed file + category: explorer + enabled: true + explorer_submit: + keys: + - enter + description: submit annotations and inject into chat + category: explorer + enabled: true explorer_toggle: keys: - enter @@ -270,6 +294,13 @@ bindings: description: show/hide hidden and ignored files category: explorer enabled: true + explorer_toggle_select: + keys: + - space + - v + description: start/clear a line-range selection + category: explorer + enabled: true global_cancel: keys: - esc diff --git a/config/keybindings.go b/config/keybindings.go index 87e21634..ca20a936 100644 --- a/config/keybindings.go +++ b/config/keybindings.go @@ -100,6 +100,12 @@ func addChatBindings(bindings map[string]KeyBindingEntry) { Category: "chat", Enabled: &enabled, } + bindings[ActionID(NamespaceChat, "focus_attachments")] = KeyBindingEntry{ + Keys: []string{"ctrl+g"}, + Description: "focus the attached-snippets tree below the input (↑/↓ move · d remove · c clear · esc done)", + Category: "chat", + Enabled: &enabled, + } } func addDisplayBindings(bindings map[string]KeyBindingEntry) { @@ -413,6 +419,10 @@ func addExplorerBindings(bindings map[string]KeyBindingEntry) { add("open", "open the selected file in your editor", "v") add("find", "open the fuzzy file finder", "/") add("toggle_hidden", "show/hide hidden and ignored files", ".") + add("select", "enter line-selection mode on the previewed file", "s") + add("toggle_select", "start/clear a line-range selection", "space", "v") + add("annotate", "annotate the selected range with an instruction", "a") + add("submit", "submit annotations and inject into chat", "enter") add("scroll_up", "scroll the preview up", "pgup") add("scroll_down", "scroll the preview down", "pgdown", "pgdn") add("halfpage_up", "scroll the preview up half a page", "ctrl+u") diff --git a/docs/features/explorer.md b/docs/features/explorer.md new file mode 100644 index 00000000..c06d1eba --- /dev/null +++ b/docs/features/explorer.md @@ -0,0 +1,53 @@ +# File Explorer: Snippet Selection and Annotation + +The `/explorer` command opens a VS Code-style file browser with a syntax-highlighted +preview pane. In addition to browsing files, you can **select a line range** within a +previewed file, **annotate it** with a natural-language instruction, and **inject** the +annotated snippet plus surrounding file context into the chat — so the LLM knows exactly +which code to change and how. + +## Workflow + +1. Open the explorer with `/explorer`. +2. Navigate the tree (`j`/`k` or arrow keys) to a file and let the preview render. +3. Press `s` to enter **select mode** on the previewed file. +4. Move the line cursor with `j`/`k`. Press `space` (or `v`) to anchor the other end of + the range. The selected lines are highlighted with a `▌` gutter; the cursor line shows + a `▶` gutter. +5. Press `a` to open the **annotation input**. Type your instruction (e.g. "refactor this + function to use early returns") and press `enter` to confirm. +6. Repeat steps 4–5 for additional disjoint ranges (in the same file or navigate to + another file after exiting select mode with `esc`). +7. Press `enter` (submit) to inject all annotated snippets into the chat input. Review + the formatted context and press `enter` again to send. + +## Keybindings + +All keys are configurable via `keybindings.yaml` under the `explorer` namespace. + +| Action | Default | Description | +|-----------------|--------------|--------------------------------------------------| +| `select` | `s` | Enter line-selection mode on the previewed file | +| `toggle_select` | `space`, `v` | Start or clear a line-range selection | +| `annotate` | `a` | Annotate the selected range with an instruction | +| `submit` | `enter` | Submit annotations and inject into chat | +| `cancel` | `esc`, `q` | Exit select mode (`esc`) or close explorer (`q`) | + +## Injected Context Format + +On submit, the explorer builds a structured prompt grouped by file: + +- For files under 1 MiB, the **full file** is included in a fenced code block with line + numbers, followed by per-selection instruction lines. +- For larger files, each snippet is included individually with a ±5-line context window + and a `▶` marker on the annotated lines. + +The prompt is placed in the chat input for review before sending, so you can edit or +add context before the LLM sees it. + +## Multi-Snippet and Cross-File Support + +You can annotate multiple disjoint ranges in a single file, and ranges across different +files. Each annotation is stored independently as a `(file, start_line, end_line, +annotation)` tuple. Navigate to another file by pressing `esc` to exit select mode, then +use tree navigation and re-enter select mode with `s`. diff --git a/internal/app/chat.go b/internal/app/chat.go index 0a85fb5d..6ea70fd6 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "time" @@ -31,6 +32,10 @@ import ( styles "github.com/inference-gateway/cli/internal/ui/styles" ) +// actChatFocusAttachments is the chat-namespace action that moves key focus to +// the snippet attachments tree below the input. +var actChatFocusAttachments = config.ActionID(config.NamespaceChat, "focus_attachments") + // ChatApplication represents the main application model using state management type ChatApplication struct { // Dependencies @@ -87,6 +92,8 @@ type ChatApplication struct { fileExplorer *components.FileExplorerImpl helpView *components.HelpViewImpl + snippetAttachmentsView *components.SnippetAttachmentsView + // Presentation layer applicationViewRenderer *components.ApplicationViewRenderer fileSelectionHandler *components.FileSelectionHandler @@ -98,9 +105,18 @@ type ChatApplication struct { // Current active component for key handling focusedComponent ui.InputComponent + // Pending snippet attachments captured in the file explorer, shown as a tree + // below the input and sent (then cleared) with the next chat message. + pendingSnippets []components.SnippetSelection + attachmentsFocused bool + // Key binding system keyBindingManager *keybinding.KeyBindingManager + // Resolved chat-namespace keybindings (actionID -> keys), used for the + // snippet-attachments focus shim that runs ahead of the key binding manager. + chatKeys map[string][]string + // Track last key handled by keybinding action to prevent double-handling lastHandledKey string @@ -239,6 +255,8 @@ func NewChatApplication( app.helpView = components.NewHelpView(app.themeService, styleProvider) app.queueBoxView = components.NewQueueBoxView(styleProvider) app.todoBoxView = components.NewTodoBoxView(styleProvider) + app.snippetAttachmentsView = components.NewSnippetAttachmentsView(styleProvider) + app.chatKeys = config.ResolveNamespaceBindings(app.config.Chat.Keybindings, config.NamespaceChat) app.approvalBoxView = components.NewApprovalBoxView(styleProvider, app.stateManager, toolFormatterService) app.fileSelectionView = components.NewFileSelectionView(styleProvider) @@ -683,6 +701,14 @@ func (app *ChatApplication) handleChatViewKeyPress(keyMsg tea.KeyMsg) []tea.Cmd return app.handleMessageHistoryKeys(keyMsg) } + if app.attachmentsFocused && keyMsg.String() != "ctrl+c" { + return app.handleAttachmentsKeys(keyMsg) + } + if !app.attachmentsFocused && len(app.pendingSnippets) > 0 && app.matchesFocusAttachments(keyMsg) { + app.attachmentsFocused = true + return nil + } + isHandledByAction := app.keyBindingManager.IsKeyHandledByAction(keyMsg) if cmd := app.keyBindingManager.ProcessKey(keyMsg); cmd != nil { @@ -696,6 +722,48 @@ func (app *ChatApplication) handleChatViewKeyPress(keyMsg tea.KeyMsg) []tea.Cmd return cmds } +// matchesFocusAttachments reports whether the pressed key is bound to the +// chat-namespace focus-attachments action. +func (app *ChatApplication) matchesFocusAttachments(keyMsg tea.KeyMsg) bool { + return slices.Contains(app.chatKeys[actChatFocusAttachments], keyMsg.String()) +} + +// handleAttachmentsKeys interprets keys while the snippet attachments tree holds +// focus: navigate, remove one, clear all, or leave. All keys are consumed. +func (app *ChatApplication) handleAttachmentsKeys(keyMsg tea.KeyMsg) []tea.Cmd { + if app.matchesFocusAttachments(keyMsg) { + app.attachmentsFocused = false + return nil + } + switch keyMsg.String() { + case "up", "k": + app.snippetAttachmentsView.MoveCursor(-1) + case "down", "j": + app.snippetAttachmentsView.MoveCursor(1) + case "d", "x", "backspace", "delete": + app.removeFocusedSnippet() + case "c": + app.pendingSnippets = nil + app.attachmentsFocused = false + case "esc", "q": + app.attachmentsFocused = false + } + return nil +} + +// removeFocusedSnippet drops the snippet under the tree cursor, leaving focus +// only while attachments remain. +func (app *ChatApplication) removeFocusedSnippet() { + idx := app.snippetAttachmentsView.SelectedIndex() + if idx < 0 || idx >= len(app.pendingSnippets) { + return + } + app.pendingSnippets = append(app.pendingSnippets[:idx], app.pendingSnippets[idx+1:]...) + if len(app.pendingSnippets) == 0 { + app.attachmentsFocused = false + } +} + func (app *ChatApplication) handleFileSelectionView(msg tea.Msg) []tea.Cmd { var cmds []tea.Cmd @@ -1527,6 +1595,9 @@ func (app *ChatApplication) handleExplorerView(msg tea.Msg) []tea.Cmd { } func (app *ChatApplication) handleExplorerClose(cmds []tea.Cmd) []tea.Cmd { + if app.fileExplorer.IsDone() { + return app.handleExplorerSubmit(cmds) + } if !app.fileExplorer.IsCancelled() { return cmds } @@ -1540,6 +1611,7 @@ func (app *ChatApplication) handleExplorerClose(cmds []tea.Cmd) []tea.Cmd { iv.ClearCustomHint() } app.focusedComponent = app.inputView + app.attachmentsFocused = false cmds = append(cmds, func() tea.Msg { return domain.SetStatusEvent{Message: "", Spinner: false, StatusType: domain.StatusDefault} @@ -1547,6 +1619,34 @@ func (app *ChatApplication) handleExplorerClose(cmds []tea.Cmd) []tea.Cmd { return cmds } +// handleExplorerSubmit runs when the explorer closes normally (IsDone). It +// carries any captured selections into the pending attachments shown as a tree +// below the chat input (their content is sent with the next message), then +// returns to the chat view. +func (app *ChatApplication) handleExplorerSubmit(cmds []tea.Cmd) []tea.Cmd { + sels := app.fileExplorer.Selections() + app.pendingSnippets = append(app.pendingSnippets, sels...) + + if err := app.stateManager.TransitionToView(domain.ViewStateChat); err != nil { + return []tea.Cmd{tea.Quit} + } + if iv, ok := app.inputView.(*components.InputView); ok { + iv.SetDisabled(false) + iv.ClearCustomHint() + } + app.focusedComponent = app.inputView + app.attachmentsFocused = false + + status := "" + if len(sels) > 0 { + status = fmt.Sprintf("%d snippet(s) attached — sent with your next message", len(sels)) + } + cmds = append(cmds, func() tea.Msg { + return domain.SetStatusEvent{Message: status, Spinner: false, StatusType: domain.StatusDefault} + }) + return cmds +} + func (app *ChatApplication) renderExplorer() string { if app.fileExplorer == nil { return "Loading explorer…" @@ -1584,6 +1684,8 @@ func (app *ChatApplication) renderChatInterface() string { QueuedMessages: queuedMessages, } + app.syncSnippetAttachmentsView() + chatInterface := app.applicationViewRenderer.RenderChatInterface( data, app.conversationView, @@ -1596,11 +1698,36 @@ func (app *ChatApplication) renderChatInterface() string { app.queueBoxView, app.todoBoxView, app.approvalBoxView, + app.snippetAttachmentsView, ) return chatInterface } +// syncSnippetAttachmentsView pushes the current pending snippets and focus state +// into the attachments view before each render. +func (app *ChatApplication) syncSnippetAttachmentsView() { + if app.snippetAttachmentsView == nil { + return + } + app.snippetAttachmentsView.SetData(app.pendingSnippets) + app.snippetAttachmentsView.SetFocusHint(app.focusAttachmentsKeyLabel()) + if app.attachmentsFocused { + app.snippetAttachmentsView.Focus() + } else { + app.snippetAttachmentsView.Blur() + } +} + +// focusAttachmentsKeyLabel returns the primary key bound to the focus-attachments +// action, for display in the attachments tree header ("" when unbound). +func (app *ChatApplication) focusAttachmentsKeyLabel() string { + if ks := app.chatKeys[actChatFocusAttachments]; len(ks) > 0 { + return ks[0] + } + return "" +} + func (app *ChatApplication) renderModelSelection() string { width, height := app.stateManager.GetDimensions() app.modelSelector.SetWidth(width) @@ -1997,13 +2124,17 @@ func (app *ChatApplication) SendMessage() tea.Cmd { input := strings.TrimSpace(app.inputView.GetInput()) images := app.inputView.GetImageAttachments() + editing := app.stateManager.IsEditingMessage() - if input == "" && len(images) == 0 { + hasSnippets := len(app.pendingSnippets) > 0 && !editing + if input == "" && len(images) == 0 && !hasSnippets { return nil } - if err := app.inputView.AddToHistory(input); err != nil { - logger.Error("failed to add input to history", "error", err) + if input != "" { + if err := app.inputView.AddToHistory(input); err != nil { + logger.Error("failed to add input to history", "error", err) + } } app.inputView.ClearInput() @@ -2018,7 +2149,7 @@ func (app *ChatApplication) SendMessage() tea.Cmd { } } - if app.stateManager.IsEditingMessage() { + if editing { editState := app.stateManager.GetMessageEditState() app.stateManager.ClearMessageEditState() @@ -2037,14 +2168,50 @@ func (app *ChatApplication) SendMessage() tea.Cmd { } } + content := input + if augmented, appended := app.augmentWithSnippets(input); appended { + content = augmented + app.pendingSnippets = nil + app.attachmentsFocused = false + } + return func() tea.Msg { return domain.UserInputEvent{ - Content: input, + Content: content, Images: images, } } } +// augmentWithSnippets appends the pending snippet attachments (selected lines +// only, via FormatAnnotations) to the outgoing message content. It is skipped +// for slash/bash commands, which must not carry a trailing code blob — their +// attachments are preserved for the next regular message. Returns the content +// to send and whether snippets were appended. +func (app *ChatApplication) augmentWithSnippets(input string) (string, bool) { + if len(app.pendingSnippets) == 0 || isCommandInput(input) { + return input, false + } + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + block := components.FormatAnnotations(cwd, app.pendingSnippets) + if block == "" { + return input, false + } + if input == "" { + return block, true + } + return input + "\n\n" + block, true +} + +// isCommandInput reports whether the message is a slash command or a bash (!) +// invocation routed by the message processor rather than sent to the model. +func isCommandInput(input string) bool { + return strings.HasPrefix(input, "/") || strings.HasPrefix(input, "!") +} + // ToggleToolResultExpansion toggles tool result expansion func (app *ChatApplication) ToggleToolResultExpansion() { app.toggleToolResultExpansion() diff --git a/internal/app/chat_snippets_test.go b/internal/app/chat_snippets_test.go new file mode 100644 index 00000000..ba49756e --- /dev/null +++ b/internal/app/chat_snippets_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "strings" + "testing" + + components "github.com/inference-gateway/cli/internal/ui/components" +) + +func TestAugmentWithSnippets_AppendsAndGates(t *testing.T) { + sels := []components.SnippetSelection{{File: "no_such_file_xyz.go", StartLine: 1, EndLine: 1, Annotation: "n"}} + + app := &ChatApplication{} + if got, appended := app.augmentWithSnippets("hello"); appended || got != "hello" { + t.Fatalf("no snippets: got (%q,%v), want (\"hello\",false)", got, appended) + } + + app = &ChatApplication{pendingSnippets: sels} + + if got, appended := app.augmentWithSnippets("/clear"); appended || got != "/clear" { + t.Fatalf("slash command: got (%q,%v), want (\"/clear\",false)", got, appended) + } + if got, appended := app.augmentWithSnippets("!ls"); appended || got != "!ls" { + t.Fatalf("bash command: got (%q,%v), want (\"!ls\",false)", got, appended) + } + + got, appended := app.augmentWithSnippets("please refactor") + if !appended { + t.Fatal("normal input should append snippets") + } + if !strings.HasPrefix(got, "please refactor\n\n") { + t.Fatalf("appended content should start with the typed input, got %q", got) + } + + got, appended = app.augmentWithSnippets("") + if !appended { + t.Fatal("empty input with snippets should still append") + } + if strings.HasPrefix(got, "\n\n") { + t.Fatalf("empty input should not be prefixed with separators, got %q", got) + } +} + +func TestIsCommandInput(t *testing.T) { + cases := map[string]bool{ + "/clear": true, + "!ls -la": true, + "!!": true, + "hello": false, + " /not-trimmed": false, + "": false, + } + for in, want := range cases { + if got := isCommandInput(in); got != want { + t.Errorf("isCommandInput(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/handlers/chat_shortcut_handler.go b/internal/handlers/chat_shortcut_handler.go index 1f438159..79d57c42 100644 --- a/internal/handlers/chat_shortcut_handler.go +++ b/internal/handlers/chat_shortcut_handler.go @@ -427,7 +427,7 @@ func (s *ChatShortcutHandler) handleShowExplorerSideEffect() tea.Msg { } return domain.SetStatusEvent{ - Message: "Explorer - ↑/↓ select · →/← expand/collapse · / find · v open · esc back", + Message: "Explorer - ↑/↓ select · →/← expand/collapse · / find · s select · v open · esc back", Spinner: false, StatusType: domain.StatusDefault, } diff --git a/internal/ui/components/application_view.go b/internal/ui/components/application_view.go index 851d9d58..6db9f053 100644 --- a/internal/ui/components/application_view.go +++ b/internal/ui/components/application_view.go @@ -43,20 +43,21 @@ func (r *ApplicationViewRenderer) RenderChatInterface( queueBoxView *QueueBoxView, todoBoxView *TodoBoxView, approvalBoxView *ApprovalBoxView, + snippetAttachments *SnippetAttachmentsView, ) string { width, height := data.Width, data.Height - heights := r.calculateComponentHeights(data, height, conversationView, helpBar, queueBoxView, todoBoxView, approvalBoxView) + heights := r.calculateComponentHeights(data, height, conversationView, helpBar, queueBoxView, todoBoxView, approvalBoxView, snippetAttachments) r.setComponentDimensions(width, conversationView, inputView, autocomplete, inputStatusBar, statusView, - modeIndicator, queueBoxView, todoBoxView, approvalBoxView, heights) + modeIndicator, queueBoxView, todoBoxView, approvalBoxView, snippetAttachments, heights) header := r.renderHeader(data, width) conversationArea := conversationView.Render() inputArea := inputView.Render() components := r.assembleComponents(data, header, conversationArea, inputArea, conversationView, statusView, modeIndicator, - inputView, inputStatusBar, autocomplete, helpBar, queueBoxView, todoBoxView, approvalBoxView, width, heights.statusHeight) + inputView, inputStatusBar, autocomplete, helpBar, queueBoxView, todoBoxView, approvalBoxView, snippetAttachments, width, heights.statusHeight) return strings.Join(components, "\n") } @@ -68,6 +69,7 @@ type componentHeights struct { queueBoxHeight int todoBoxHeight int approvalBoxHeight int + attachmentsHeight int backgroundTasksLines int conversationHeight int inputHeight int @@ -83,6 +85,7 @@ func (r *ApplicationViewRenderer) calculateComponentHeights( queueBoxView *QueueBoxView, todoBoxView *TodoBoxView, approvalBoxView *ApprovalBoxView, + snippetAttachments *SnippetAttachmentsView, ) componentHeights { if approvalBoxView != nil { approvalBoxView.SetHeight(totalHeight) @@ -105,6 +108,10 @@ func (r *ApplicationViewRenderer) calculateComponentHeights( heights.todoBoxHeight = todoBoxView.GetHeight() } + if snippetAttachments != nil { + heights.attachmentsHeight = snippetAttachments.GetHeight() + } + if approvalBoxView != nil { approvalContent := approvalBoxView.Render() if approvalContent != "" { @@ -119,7 +126,7 @@ func (r *ApplicationViewRenderer) calculateComponentHeights( adjustedHeight := totalHeight - heights.headerHeight - heights.helpBarHeight - heights.queueBoxHeight - heights.todoBoxHeight - heights.approvalBoxHeight - - heights.backgroundTasksLines + heights.attachmentsHeight - heights.backgroundTasksLines heights.conversationHeight = ui.CalculateConversationHeight(adjustedHeight) heights.inputHeight = ui.CalculateInputHeight(adjustedHeight) heights.statusHeight = ui.CalculateStatusHeight(adjustedHeight) @@ -143,6 +150,7 @@ func (r *ApplicationViewRenderer) setComponentDimensions( queueBoxView *QueueBoxView, todoBoxView *TodoBoxView, approvalBoxView *ApprovalBoxView, + snippetAttachments *SnippetAttachmentsView, heights componentHeights, ) { conversationWidth := formatting.GetResponsiveWidth(width) @@ -171,6 +179,10 @@ func (r *ApplicationViewRenderer) setComponentDimensions( todoBoxView.SetWidth(width) } + if snippetAttachments != nil { + snippetAttachments.SetWidth(width) + } + if approvalBoxView != nil { approvalBoxView.SetWidth(width) } @@ -197,6 +209,7 @@ func (r *ApplicationViewRenderer) assembleComponents( queueBoxView *QueueBoxView, todoBoxView *TodoBoxView, approvalBoxView *ApprovalBoxView, + snippetAttachments *SnippetAttachmentsView, width, statusHeight int, ) []string { components := []string{header, "", conversationArea} @@ -208,6 +221,7 @@ func (r *ApplicationViewRenderer) assembleComponents( components = r.appendStatusView(components, statusView, statusHeight) components = r.appendApprovalBox(components, approvalBoxView) components = append(components, inputArea) + components = r.appendSnippetAttachments(components, snippetAttachments) components = r.appendAutocomplete(components, autocomplete) components = r.appendInputStatusBar(components, inputView, inputStatusBar) components = r.appendHelpBar(components, helpBar, width) @@ -242,6 +256,20 @@ func (r *ApplicationViewRenderer) appendTodoBox( return components } +// appendSnippetAttachments appends the snippet attachments tree (file + line +// ranges) directly below the input when any snippet is pending. +func (r *ApplicationViewRenderer) appendSnippetAttachments( + components []string, + snippetAttachments *SnippetAttachmentsView, +) []string { + if snippetAttachments != nil { + if content := snippetAttachments.Render(); content != "" { + components = append(components, content) + } + } + return components +} + // appendBackgroundTaskBar appends the sticky background-task indicator // rendered by the ConversationView, when any A2A task is currently tracked. // Always visible just above the input until the 5s post-terminal-state diff --git a/internal/ui/components/file_explorer.go b/internal/ui/components/file_explorer.go index bb78f786..31027c6f 100644 --- a/internal/ui/components/file_explorer.go +++ b/internal/ui/components/file_explorer.go @@ -55,6 +55,10 @@ var ( actExpHalfUp = config.ActionID(config.NamespaceExplorer, "halfpage_up") actExpHalfDown = config.ActionID(config.NamespaceExplorer, "halfpage_down") actExpCancel = config.ActionID(config.NamespaceExplorer, "cancel") + actExpSelect = config.ActionID(config.NamespaceExplorer, "select") + actExpToggleSelect = config.ActionID(config.NamespaceExplorer, "toggle_select") + actExpAnnotate = config.ActionID(config.NamespaceExplorer, "annotate") + actExpSubmit = config.ActionID(config.NamespaceExplorer, "submit") ) // explorerNode is one filesystem entry (the cached, sorted children of a dir). @@ -80,6 +84,16 @@ type explorerWalkDoneMsg struct { truncated bool } +// SnippetSelection is one annotated line range captured in explorer select +// mode. Line numbers are 1-indexed and inclusive. The Annotation is the +// natural-language instruction the user attached to the highlighted range. +type SnippetSelection struct { + File string + StartLine int + EndLine int + Annotation string +} + // FileExplorerImpl is the VS Code-style file explorer side panel: a left tree of // the working directory (lazy, collapsible, .gitignore-aware) plus a scrollable, // syntax-highlighted preview of the selected file. A `/` fuzzy finder jumps to any @@ -113,6 +127,7 @@ type FileExplorerImpl struct { previewKey string previewWidth int dirtyPreview bool + previewRaw string // cached highlighted content before gutter markers // Fuzzy finder (find mode). findMode bool @@ -127,9 +142,22 @@ type FileExplorerImpl struct { editMode bool editor *ptyEditor + // Select mode - line-range selection + annotation within the preview pane. + // The preview cursor (previewCursor) is a 0-indexed line within the current + // file; selAnchor (-1 = none) marks the other end of an inclusive range. + // Captured ranges accumulate in selections; closing the explorer (done) hands + // them to the chat, where they appear as attachments until the next message. + selectMode bool + previewCursor int + previewLines int + selAnchor int + selections []SnippetSelection + annotateMode bool + annotateInput string + loadErr error - done bool - cancel bool + done bool // explorer closed normally (esc/q) — carry selections to chat + cancel bool // discard everything (ctrl+c) } // NewFileExplorer creates an explorer rooted at the given working directory. @@ -149,6 +177,7 @@ func NewFileExplorer(root string, styleProvider *styles.Provider, themeService d viewport: vp, dirtyPreview: true, ignore: newIgnoreFilter(root, false), + selAnchor: -1, } t.ensureChildren("") t.flatten() @@ -180,6 +209,13 @@ func (t *FileExplorerImpl) Reset() { t.findCursor = 0 t.walking = false t.walkTruncated = false + t.selectMode = false + t.previewCursor = 0 + t.previewLines = 0 + t.selAnchor = -1 + t.selections = nil + t.annotateMode = false + t.annotateInput = "" if t.editor != nil { t.editor.close() } @@ -214,13 +250,23 @@ func (t *FileExplorerImpl) HintText() string { if t.editMode && t.editor != nil { return "(editor) - :wq to save & return" } + if t.annotateMode { + return "(annotate) type note (optional) · enter confirm · esc cancel" + } + if t.selectMode { + return fmt.Sprintf("(select) %s/%s move · %s range · %s attach · %s note · esc back", + t.keymap.display(actExpNavUp), t.keymap.display(actExpNavDown), + t.keymap.display(actExpToggleSelect), t.keymap.display(actExpSubmit), + t.keymap.display(actExpAnnotate)) + } if t.findMode { return "type to filter · ↑/↓ select · enter open · esc back to tree" } - return fmt.Sprintf("%s/%s select · %s/%s expand · %s find · %s open · %s hidden · %s back", + return fmt.Sprintf("%s/%s select · %s/%s expand · %s find · %s open · %s select · %s hidden · %s back", t.keymap.display(actExpNavUp), t.keymap.display(actExpNavDown), t.keymap.display(actExpExpand), t.keymap.display(actExpCollapse), t.keymap.display(actExpFind), t.keymap.display(actExpOpen), + t.keymap.display(actExpSelect), t.keymap.display(actExpToggleHidden), t.keymap.display(actExpCancel)) } @@ -261,6 +307,12 @@ func (t *FileExplorerImpl) handleWheel(msg tea.MouseWheelMsg) { } func (t *FileExplorerImpl) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if t.annotateMode { + return t.handleAnnotateKey(msg) + } + if t.selectMode { + return t.handleSelectKey(msg) + } if t.findMode { return t.handleFindKey(msg) } @@ -271,10 +323,11 @@ func (t *FileExplorerImpl) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch t.keymap.match(pressed, actExpNavUp, actExpNavDown, actExpToggle, actExpExpand, actExpCollapse, - actExpOpen, actExpFind, actExpToggleHidden, + actExpOpen, actExpFind, actExpToggleHidden, actExpSelect, actExpScrollUp, actExpScrollDown, actExpHalfUp, actExpHalfDown, actExpCancel) { case actExpCancel: - t.cancel = true + // esc/q closes the explorer, carrying any captured selections to chat. + t.done = true case actExpNavUp: t.moveCursor(-1) case actExpNavDown: @@ -289,6 +342,8 @@ func (t *FileExplorerImpl) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return t, t.enterEditCmd() case actExpFind: return t, t.enterFind() + case actExpSelect: + t.enterSelectMode() case actExpToggleHidden: t.toggleHidden() case actExpScrollUp: @@ -312,6 +367,11 @@ func (t *FileExplorerImpl) moveCursor(delta int) { t.selectedKey = t.rows[t.cursor].node.relPath if t.selectedFilePath() != prev { t.dirtyPreview = true + // Leaving the current file resets select-mode state (the preview cursor + // and anchor belong to the previous file); captured selections persist. + if t.selectMode { + t.exitSelectMode() + } } } @@ -540,6 +600,263 @@ func (t *FileExplorerImpl) startWalkCmd() tea.Cmd { } } +// --- select mode (line-range selection + annotation) --- + +// enterSelectMode activates line-range selection on the currently previewed file. +// It is a no-op when no file is selected or the preview has no lines (binary / +// oversized placeholder). The preview cursor starts at the viewport's current +// scroll position so the user sees where they are. +func (t *FileExplorerImpl) enterSelectMode() { + if t.selectedFilePath() == "" || t.previewLines <= 0 { + return + } + t.selectMode = true + t.selAnchor = -1 + t.previewCursor = clampInt(t.viewport.YOffset(), 0, t.previewLines-1) +} + +// exitSelectMode returns to tree navigation, clearing the active range anchor. +// Captured selections are preserved so they are still carried to chat on close. +func (t *FileExplorerImpl) exitSelectMode() { + t.selectMode = false + t.selAnchor = -1 +} + +// previewSelectionRange returns the inclusive 0-indexed [lo,hi] line range of +// the active selection, or ok=false when no anchor is set. Mirrors the diff +// viewer's patchSelectionRange. +func (t *FileExplorerImpl) previewSelectionRange() (lo, hi int, ok bool) { + if t.selAnchor < 0 { + return 0, 0, false + } + lo, hi = t.selAnchor, t.previewCursor + if lo > hi { + lo, hi = hi, lo + } + max := t.previewLines - 1 + if hi > max { + hi = max + } + if lo > max { + lo = max + } + return lo, hi, true +} + +// movePreviewCursor moves the line cursor within the preview, clamping to the +// file's line count and scrolling the viewport only as far as needed to keep +// the cursor within the visible window. Leaving YOffset untouched while the +// cursor is already on-screen keeps an anchored selection's gutter markers +// visible instead of scrolling them off the top. +func (t *FileExplorerImpl) movePreviewCursor(delta int) { + if t.previewLines <= 0 { + return + } + t.previewCursor = clampInt(t.previewCursor+delta, 0, t.previewLines-1) + + height := t.viewport.Height() + if height <= 0 { + // No render has sized the viewport yet; fall back to the simple pin. + t.viewport.SetYOffset(t.previewCursor) + return + } + + top := t.viewport.YOffset() + switch { + case t.previewCursor < top: + t.viewport.SetYOffset(t.previewCursor) + case t.previewCursor > top+height-1: + t.viewport.SetYOffset(t.previewCursor - height + 1) + } + // else: cursor already visible — leave YOffset unchanged. +} + +func (t *FileExplorerImpl) handleSelectKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + pressed := normalizeKey(msg.String()) + if pressed == "ctrl+c" { // universal escape; intentionally not remappable + t.cancel = true + return t, nil + } + switch t.keymap.match(pressed, + actExpNavUp, actExpNavDown, actExpToggleSelect, actExpAnnotate, + actExpSubmit, actExpCancel) { + case actExpCancel: + // esc exits select mode (preserving selections); q (or any other cancel + // binding) closes the explorer, carrying captured selections to chat. + if pressed == "esc" { + t.exitSelectMode() + } else { + t.done = true + } + case actExpNavUp: + t.movePreviewCursor(-1) + case actExpNavDown: + t.movePreviewCursor(1) + case actExpToggleSelect: + if t.selAnchor >= 0 { + t.selAnchor = -1 + } else { + t.selAnchor = t.previewCursor + } + case actExpAnnotate: + // No explicit range: treat the single cursor line as a 1-line selection. + if t.selAnchor < 0 { + t.selAnchor = t.previewCursor + } + t.annotateMode = true + t.annotateInput = "" + case actExpSubmit: + // Attach the current range immediately (annotation optional); stay in + // select mode so the user can capture more ranges before closing. + t.attachCurrentSelection() + } + return t, nil +} + +// handleAnnotateKey drives the inline annotation text input. enter confirms +// (storing the selection), esc cancels (keeping the anchor so the user can +// retry). Mirrors handleFindKey's typing model. +func (t *FileExplorerImpl) handleAnnotateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch normalizeKey(msg.String()) { + case "ctrl+c": + t.cancel = true + return t, nil + case "esc": + t.annotateMode = false + t.annotateInput = "" + return t, nil + case "enter": + t.confirmAnnotation() + return t, nil + case "backspace": + if r := []rune(t.annotateInput); len(r) > 0 { + t.annotateInput = string(r[:len(r)-1]) + } + return t, nil + } + if kp, ok := msg.(tea.KeyPressMsg); ok && kp.Text != "" { + t.annotateInput += kp.Text + } + return t, nil +} + +// attachSelection captures the current range (or the single cursor line when no +// anchor is set) as a SnippetSelection carrying the given note. Line numbers are +// converted from 0-indexed to 1-indexed inclusive; the anchor is then cleared. +func (t *FileExplorerImpl) attachSelection(note string) { + lo, hi, ok := t.previewSelectionRange() + if !ok { + lo = t.previewCursor + hi = t.previewCursor + } + t.selections = append(t.selections, SnippetSelection{ + File: t.selectedFilePath(), + StartLine: lo + 1, + EndLine: hi + 1, + Annotation: note, + }) + t.selAnchor = -1 +} + +// attachCurrentSelection captures the current range with no note (the Enter +// path); the user can still add an optional note via the annotate key first. +func (t *FileExplorerImpl) attachCurrentSelection() { t.attachSelection("") } + +// confirmAnnotation stores the current range + typed note as a SnippetSelection +// and exits annotate mode. +func (t *FileExplorerImpl) confirmAnnotation() { + t.attachSelection(t.annotateInput) + t.annotateMode = false + t.annotateInput = "" +} + +// Selections returns the annotated line ranges captured during select mode. +// The app reads this when the explorer closes (IsDone) and carries them into +// chat as attachments sent with the next message. +func (t *FileExplorerImpl) Selections() []SnippetSelection { + return t.selections +} + +// FormatAnnotations builds an LLM-ready context block from a set of snippet +// selections. Selections are grouped by file (deduping file reads). Only the +// selected line ranges are emitted — never the whole file — each as a fenced +// block headed by ` (lines X-Y):`, optionally followed by a `note:` line +// when the user attached an instruction. +// +// The output format is: +// +// (lines -): +// ``` +// +// ``` +// note: (only when an annotation was attached) +// +// This is a pure function (no receiver state) so it is independently +// unit-testable. +func FormatAnnotations(root string, sels []SnippetSelection) string { + if len(sels) == 0 { + return "" + } + + // Group selections by file, preserving first-seen order. + fileOrder := make([]string, 0, len(sels)) + byFile := make(map[string][]SnippetSelection) + for _, s := range sels { + if _, ok := byFile[s.File]; !ok { + fileOrder = append(fileOrder, s.File) + } + byFile[s.File] = append(byFile[s.File], s) + } + + var b strings.Builder + b.WriteString("The following code snippets were attached from the file explorer as context:\n") + + for _, file := range fileOrder { + abs := filepath.Join(root, file) + data, err := os.ReadFile(abs) + ext := snippetExt(file) + if err != nil { + for _, s := range byFile[file] { + fmt.Fprintf(&b, "\n%s (lines %d-%d): file unavailable: %s\n", file, s.StartLine, s.EndLine, err) + if s.Annotation != "" { + fmt.Fprintf(&b, "note: %s\n", s.Annotation) + } + } + continue + } + allLines := strings.Split(string(data), "\n") + for _, s := range byFile[file] { + appendSnippetBlock(&b, file, allLines, ext, s) + } + } + return b.String() +} + +// appendSnippetBlock writes one snippet (the selected lines only) as a fenced +// block headed by the file + 1-indexed inclusive range, plus an optional note. +func appendSnippetBlock(b *strings.Builder, file string, allLines []string, ext string, s SnippetSelection) { + lo := clampInt(s.StartLine-1, 0, len(allLines)-1) + hi := clampInt(s.EndLine-1, 0, len(allLines)-1) + fmt.Fprintf(b, "\n%s (lines %d-%d):\n```%s\n", file, s.StartLine, s.EndLine, ext) + for i := lo; i <= hi; i++ { + b.WriteString(allLines[i]) + b.WriteByte('\n') + } + b.WriteString("```\n") + if s.Annotation != "" { + fmt.Fprintf(b, "note: %s\n", s.Annotation) + } +} + +// snippetExt returns the fenced-code extension for a file path. +func snippetExt(file string) string { + ext := strings.TrimPrefix(filepath.Ext(file), ".") + if ext == "" { + return "" + } + return ext +} + // --- tree model --- // ensureChildren reads and caches a directory's immediate children (filtered and @@ -650,39 +967,89 @@ func (t *FileExplorerImpl) chromaStyle() *chroma.Style { } // ensurePreview (re)renders the selected file into the viewport, gated so the -// read + highlight only run when the selection or width actually changed. +// read + highlight only run when the selection or width actually changed. The +// raw highlighted content is cached in previewRaw; selection gutters are +// applied separately by applyPreviewGutters so cursor/anchor moves refresh +// without re-tokenising the file. func (t *FileExplorerImpl) ensurePreview(rel string, width int) { if !t.dirtyPreview && rel == t.previewKey && width == t.previewWidth { + t.applyPreviewGutters() return } - content := t.computePreview(rel) + raw, lines := t.computePreviewRaw(rel) changed := rel != t.previewKey t.previewKey = rel t.previewWidth = width t.dirtyPreview = false - t.viewport.SetContent(content) + t.previewRaw = raw + t.previewLines = lines if changed { t.viewport.GotoTop() + if t.previewCursor >= t.previewLines { + t.previewCursor = max(t.previewLines-1, 0) + } } + t.applyPreviewGutters() +} + +// applyPreviewGutters sets the viewport content from previewRaw, prefixing the +// cursor line (▶) and selected range lines (▌) when select mode is active. The +// marker is a 2-char gutter so it stays alignment-safe with the line-number +// prefix produced by diffview.Highlight. Cheap enough to run every render. +func (t *FileExplorerImpl) applyPreviewGutters() { + raw := t.previewRaw + if !t.selectMode && len(t.selections) == 0 { + t.viewport.SetContent(raw) + return + } + lines := strings.Split(raw, "\n") + accent := t.styleProvider.GetThemeColor("accent") + cursorGutter := t.styleProvider.RenderWithColorAndBold("▶ ", accent) + selGutter := t.styleProvider.RenderWithColor("▌ ", accent) + lo, hi, hasSel := t.previewSelectionRange() + for i := range lines { + marker := " " + switch { + case t.selectMode && i == t.previewCursor: + marker = cursorGutter + case hasSel && i >= lo && i <= hi: + marker = selGutter + } + lines[i] = marker + lines[i] + } + t.viewport.SetContent(strings.Join(lines, "\n")) } func (t *FileExplorerImpl) computePreview(rel string) string { + raw, _ := t.computePreviewRaw(rel) + return raw +} + +// computePreviewRaw returns the highlighted content and the number of source +// lines (0 for binary/oversized/errored placeholders, which can't be selected). +func (t *FileExplorerImpl) computePreviewRaw(rel string) (string, int) { abs := filepath.Join(t.root, rel) info, err := os.Stat(abs) if err != nil { - return t.styleProvider.RenderErrorText("Failed to read file: " + err.Error()) + return t.styleProvider.RenderErrorText("Failed to read file: " + err.Error()), 0 } if info.Size() > explorerMaxPreviewBytes { - return t.styleProvider.RenderDimText("⊘ Binary or large file - not shown") + return t.styleProvider.RenderDimText("⊘ Binary or large file - not shown"), 0 } data, err := os.ReadFile(abs) if err != nil { - return t.styleProvider.RenderErrorText("Failed to read file: " + err.Error()) + return t.styleProvider.RenderErrorText("Failed to read file: " + err.Error()), 0 } if bytes.IndexByte(data, 0) >= 0 { - return t.styleProvider.RenderDimText("⊘ Binary or large file - not shown") + return t.styleProvider.RenderDimText("⊘ Binary or large file - not shown"), 0 + } + src := string(data) + highlighted := diffview.Highlight(rel, src, t.chromaStyle(), true) + lines := strings.Count(src, "\n") + if len(src) > 0 && !strings.HasSuffix(src, "\n") { + lines++ } - return diffview.Highlight(rel, string(data), t.chromaStyle(), true) + return highlighted, lines } // --- rendering --- @@ -807,6 +1174,8 @@ func (t *FileExplorerImpl) renderPane(width, height int) string { switch { case t.editMode && t.editor != nil: return t.editor.View(width, height) + case t.annotateMode: + return t.renderAnnotatePane(width, height) case t.findMode: return t.renderFindResults(width, height) case t.loadErr != nil: @@ -823,6 +1192,31 @@ func (t *FileExplorerImpl) renderPane(width, height int) string { return t.viewport.View() } +// renderAnnotatePane draws an inline annotation prompt at the top of the preview +// pane with the file preview (selected range highlighted) beneath it, so the +// user sees the snippet they are annotating while typing the instruction. +func (t *FileExplorerImpl) renderAnnotatePane(width, height int) string { + rel := t.selectedFilePath() + if rel == "" { + return t.styleProvider.PlaceCenter(width, height, t.styleProvider.RenderDimText("No file selected")) + } + + lo, hi, _ := t.previewSelectionRange() + prompt := fmt.Sprintf("Annotate (%s lines %d-%d): %s", rel, lo+1, hi+1, t.annotateInput) + header := t.styleProvider.RenderWithColorAndBold(truncateRunes(prompt, width), t.styleProvider.GetThemeColor("accent")) + + previewHeight := max(height-1, 0) + t.ensurePreview(rel, width) + t.viewport.SetWidth(width) + t.viewport.SetHeight(previewHeight) + + var b strings.Builder + b.WriteString(header) + b.WriteByte('\n') + b.WriteString(t.viewport.View()) + return b.String() +} + func (t *FileExplorerImpl) renderFindResults(width, height int) string { var b strings.Builder prompt := "❯ " + t.findQuery diff --git a/internal/ui/components/file_explorer_test.go b/internal/ui/components/file_explorer_test.go index fca53a31..248ff743 100644 --- a/internal/ui/components/file_explorer_test.go +++ b/internal/ui/components/file_explorer_test.go @@ -1,6 +1,7 @@ package components import ( + "fmt" "os" "path/filepath" "reflect" @@ -359,3 +360,482 @@ func TestExplorer_OversizedPreviewPlaceholder(t *testing.T) { t.Fatalf("expected oversized placeholder, got %q", out) } } + +// selectFileForPreview positions the explorer cursor on rel and renders once so +// the preview pane (and previewLines) is populated. Returns the explorer for +// chaining. +func selectFileForPreview(t *testing.T, e *FileExplorerImpl, rel string) *FileExplorerImpl { + t.Helper() + e.cursor = mustRowIndex(t, e, rel) + e.selectedKey = rel + e.dirtyPreview = true + e.Render("") // triggers ensurePreview → sets previewLines + return e +} + +func TestExplorer_EnterSelectMode(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "main.go"), "package main\n\nfunc main() {}\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "main.go") + + if e.previewLines <= 0 { + t.Fatalf("previewLines = %d, want > 0", e.previewLines) + } + + e.enterSelectMode() + if !e.selectMode { + t.Fatal("enterSelectMode should set selectMode=true") + } + if e.selAnchor != -1 { + t.Fatalf("selAnchor = %d, want -1", e.selAnchor) + } + if e.previewCursor < 0 || e.previewCursor >= e.previewLines { + t.Fatalf("previewCursor = %d, want in [0,%d]", e.previewCursor, e.previewLines-1) + } +} + +func TestExplorer_EnterSelectModeNoFile(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "main.go"), "package main\n") + e := newTestExplorer(t, root) + + // No file selected (cursor on a dir or nothing). + e.enterSelectMode() + if e.selectMode { + t.Fatal("enterSelectMode should be a no-op when no file is previewed") + } +} + +func TestExplorer_PreviewCursorMovement(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\nd\ne\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + // Move down a few lines. + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + if e.previewCursor != 2 { + t.Fatalf("after 2x nav_down previewCursor = %d, want 2", e.previewCursor) + } + + // Move back up. + e.Update(tea.KeyPressMsg{Text: "k", Code: 'k'}) + if e.previewCursor != 1 { + t.Fatalf("after nav_up previewCursor = %d, want 1", e.previewCursor) + } + + // Clamp at bottom. + e.previewCursor = e.previewLines - 1 + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + if e.previewCursor != e.previewLines-1 { + t.Fatalf("nav_down at bottom: previewCursor = %d, want %d", e.previewCursor, e.previewLines-1) + } + + // Clamp at top. + e.previewCursor = 0 + e.Update(tea.KeyPressMsg{Text: "k", Code: 'k'}) + if e.previewCursor != 0 { + t.Fatalf("nav_up at top: previewCursor = %d, want 0", e.previewCursor) + } +} + +// TestExplorer_PreviewCursorKeepsSelectionInView guards the keep-in-view scroll +// behavior on a file taller than the preview pane: moving the cursor must scroll +// only as far as needed (cursor tracked at the bottom edge), NOT pin the cursor +// line to the top (the original bug, which scrolled the anchored selection off +// the top so its ▌ gutter markers were never rendered). +func TestExplorer_PreviewCursorKeepsSelectionInView(t *testing.T) { + root := t.TempDir() + var sb strings.Builder + for i := 0; i < 40; i++ { + fmt.Fprintf(&sb, "line %d\n", i) + } + writeTestFile(t, filepath.Join(root, "tall.go"), sb.String()) + + e := newTestExplorer(t, root) + e.SetHeight(8) // force a short pane (well under the 40-line file); h is read back below + selectFileForPreview(t, e, "tall.go") + e.Render("") // apply the short height to the viewport + + h := e.viewport.Height() + if h <= 0 || h >= e.previewLines { + t.Fatalf("test needs a pane shorter than the file: height=%d previewLines=%d", h, e.previewLines) + } + + e.enterSelectMode() + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) // anchor at the top line + anchor := e.selAnchor + + // Drive the cursor to a mid-file line: far enough below the fold to scroll, + // but well clear of the bottom clamp (where pin-to-top and keep-in-view would + // coincide at maxYOffset and the test couldn't tell them apart). + target := e.previewLines / 2 + for i := 0; i < target; i++ { + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + } + e.Render("") + + top := e.viewport.YOffset() + if got := e.previewCursor; got != top+h-1 { + t.Fatalf("cursor not kept at bottom of view: cursor=%d YOffset=%d height=%d", got, top, h) + } + if top == e.previewCursor { + t.Fatalf("YOffset pinned to cursor (old bug): YOffset=%d cursor=%d", top, e.previewCursor) + } + lo, hi, ok := e.previewSelectionRange() + if !ok || lo != anchor { + t.Fatalf("selection range = (%d,%d,%v), want lo=%d", lo, hi, ok, anchor) + } + if hi < top || hi > top+h-1 { + t.Fatalf("selection cursor end hi=%d outside visible window [%d,%d)", hi, top, top+h) + } +} + +func TestExplorer_ToggleRangeSelection(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\nd\ne\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + // Anchor at line 0. + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) // toggle_select (space) + if e.selAnchor != 0 { + t.Fatalf("after toggle_select selAnchor = %d, want 0", e.selAnchor) + } + + // Move cursor to line 2. + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + + lo, hi, ok := e.previewSelectionRange() + if !ok || lo != 0 || hi != 2 { + t.Fatalf("previewSelectionRange = (%d,%d,%v), want (0,2,true)", lo, hi, ok) + } + + // Toggle again clears the anchor. + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) + if e.selAnchor != -1 { + t.Fatalf("after second toggle selAnchor = %d, want -1", e.selAnchor) + } + _, _, ok = e.previewSelectionRange() + if ok { + t.Fatal("previewSelectionRange should report no selection after clear") + } +} + +func TestExplorer_AnnotateConfirmStoresSelection(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\nd\ne\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + // Anchor at line 0, move to line 1. + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + + // Enter annotate mode. + e.Update(tea.KeyPressMsg{Text: "a", Code: 'a'}) + if !e.annotateMode { + t.Fatal("annotate key should enter annotate mode") + } + + // Type the instruction. + e.Update(tea.KeyPressMsg{Text: "r", Code: 'r'}) + e.Update(tea.KeyPressMsg{Text: "e", Code: 'e'}) + e.Update(tea.KeyPressMsg{Text: "f", Code: 'f'}) + if e.annotateInput != "ref" { + t.Fatalf("annotateInput = %q, want ref", e.annotateInput) + } + + // Confirm with enter. + e.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if e.annotateMode { + t.Fatal("enter should exit annotate mode") + } + sels := e.Selections() + if len(sels) != 1 { + t.Fatalf("Selections = %d, want 1", len(sels)) + } + s := sels[0] + if s.File != "f.go" { + t.Errorf("File = %q, want f.go", s.File) + } + if s.StartLine != 1 || s.EndLine != 2 { + t.Errorf("StartLine/EndLine = %d/%d, want 1/2 (1-indexed inclusive)", s.StartLine, s.EndLine) + } + if s.Annotation != "ref" { + t.Errorf("Annotation = %q, want ref", s.Annotation) + } +} + +func TestExplorer_AnnotateEscapeDiscards(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) // anchor + e.Update(tea.KeyPressMsg{Text: "a", Code: 'a'}) // annotate + e.Update(tea.KeyPressMsg{Text: "x", Code: 'x'}) + e.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if e.annotateMode { + t.Fatal("esc should exit annotate mode") + } + if len(e.Selections()) != 0 { + t.Fatalf("Selections = %d, want 0 after esc discard", len(e.Selections())) + } + // Anchor is retained so the user can retry. + if e.selAnchor < 0 { + t.Fatal("selAnchor should be retained after annotate esc") + } +} + +func TestExplorer_MultipleSelectionsAcrossFiles(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "a.go"), "a\nb\nc\n") + writeTestFile(t, filepath.Join(root, "b.go"), "d\ne\nf\n") + e := newTestExplorer(t, root) + + // File A: select+annotate lines 1-2. + selectFileForPreview(t, e, "a.go") + e.enterSelectMode() + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) + e.Update(tea.KeyPressMsg{Text: "a", Code: 'a'}) + e.Update(tea.KeyPressMsg{Text: "x", Code: 'x'}) + e.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // Exit select mode, then navigate to file B (tree nav). + e.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) // exit select mode + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) // nav_down in tree + + // File B: select+annotate line 1. + selectFileForPreview(t, e, "b.go") + e.enterSelectMode() + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) + e.Update(tea.KeyPressMsg{Text: "a", Code: 'a'}) + e.Update(tea.KeyPressMsg{Text: "y", Code: 'y'}) + e.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + sels := e.Selections() + if len(sels) != 2 { + t.Fatalf("Selections = %d, want 2", len(sels)) + } + if sels[0].File != "a.go" || sels[1].File != "b.go" { + t.Fatalf("Files = %q,%q want a.go,b.go", sels[0].File, sels[1].File) + } + if sels[0].Annotation != "x" || sels[1].Annotation != "y" { + t.Fatalf("Annotations = %q,%q want x,y", sels[0].Annotation, sels[1].Annotation) + } +} + +func TestExplorer_EnterAttachesSelection(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + // Enter attaches the current range immediately (no annotation required), + // stays in select mode, and does NOT close the explorer. + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) // anchor at line 1 + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) // extend to line 2 + e.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + sels := e.Selections() + if len(sels) != 1 { + t.Fatalf("Enter should attach one selection, got %d", len(sels)) + } + if sels[0].StartLine != 1 || sels[0].EndLine != 2 { + t.Fatalf("attached range = %d-%d, want 1-2", sels[0].StartLine, sels[0].EndLine) + } + if sels[0].Annotation != "" { + t.Fatalf("Enter-attached snippet should carry no annotation, got %q", sels[0].Annotation) + } + if e.IsDone() { + t.Fatal("Enter should not close the explorer") + } + if !e.selectMode { + t.Fatal("Enter should stay in select mode so more ranges can be captured") + } +} + +func TestExplorer_CloseCarriesSelections(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) + e.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // attach a range + + // q closes the explorer normally (done), carrying selections to chat. + e.Update(tea.KeyPressMsg{Text: "q", Code: 'q'}) + if !e.IsDone() { + t.Fatal("q should close the explorer (done) so selections are carried") + } + if e.IsCancelled() { + t.Fatal("q should not discard (cancel) the selections") + } + if len(e.Selections()) != 1 { + t.Fatalf("carried selections = %d, want 1", len(e.Selections())) + } +} + +func TestExplorer_EscExitsSelectMode(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "a\nb\nc\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) // anchor a range + e.selections = append(e.selections, SnippetSelection{File: "f.go", StartLine: 1, EndLine: 1, Annotation: "prior"}) + + e.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if e.selectMode { + t.Fatal("esc should exit select mode") + } + if e.IsCancelled() { + t.Fatal("esc in select mode should not cancel the explorer") + } + if len(e.selections) != 1 { + t.Fatalf("selections should be preserved after esc, got %d", len(e.selections)) + } +} + +func TestExplorer_PreviewHighlightRender(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "line1\nline2\nline3\nline4\nline5\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + + // Select lines 2-3 (0-indexed 1-2). + e.previewCursor = 1 + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) // anchor at 1 + e.Update(tea.KeyPressMsg{Text: "j", Code: 'j'}) // cursor → 2 + + out := e.Render("") + if !strings.Contains(out, "▌") { + t.Fatal("select-mode render should contain the ▌ selection gutter marker") + } + if !strings.Contains(out, "▶") { + t.Fatal("select-mode render should contain the ▶ cursor gutter marker") + } +} + +func TestExplorer_AnnotateModeRenderSmoke(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "f.go"), "line1\nline2\nline3\n") + e := newTestExplorer(t, root) + selectFileForPreview(t, e, "f.go") + e.enterSelectMode() + e.Update(tea.KeyPressMsg{Text: " ", Code: ' '}) + e.Update(tea.KeyPressMsg{Text: "a", Code: 'a'}) + e.Update(tea.KeyPressMsg{Text: "r", Code: 'r'}) + + if !e.annotateMode { + t.Fatal("should be in annotate mode") + } + out := e.Render("") + if !strings.Contains(out, "Annotate") { + t.Fatalf("annotate-mode render should contain 'Annotate' prompt, got %q", out) + } + if !strings.Contains(out, "r") { + t.Fatal("annotate-mode render should show typed text") + } +} + +func TestExplorer_FormatAnnotations(t *testing.T) { + root := t.TempDir() + content := "package main\n\nfunc main() {\n\tprintln(\"hi\")\n}\n" + writeTestFile(t, filepath.Join(root, "main.go"), content) + + sels := []SnippetSelection{ + {File: "main.go", StartLine: 3, EndLine: 5, Annotation: "refactor to use early returns"}, + } + out := FormatAnnotations(root, sels) + + checks := []string{ + "main.go (lines 3-5):", + "```go", + "func main() {", + "println(\"hi\")", + "note: refactor to use early returns", + } + for _, want := range checks { + if !strings.Contains(out, want) { + t.Errorf("FormatAnnotations output missing %q\n--- output ---\n%s", want, out) + } + } + // Only the selected lines (3-5) are emitted — not the whole file. + if strings.Contains(out, "package main") { + t.Errorf("output should not include non-selected line 1 (package main)\n--- output ---\n%s", out) + } +} + +func TestExplorer_FormatAnnotationsLargeFileSelectedLinesOnly(t *testing.T) { + root := t.TempDir() + // A large file: the formatter must still emit ONLY the selected lines, never + // the whole file or a context window. + content := strings.Repeat("line\n", explorerMaxPreviewBytes/5+10) + writeTestFile(t, filepath.Join(root, "big.txt"), content) + + sels := []SnippetSelection{ + {File: "big.txt", StartLine: 10, EndLine: 12, Annotation: "fix this"}, + } + out := FormatAnnotations(root, sels) + + if !strings.Contains(out, "big.txt (lines 10-12):") { + t.Errorf("output should head the snippet with the file + range\n--- output ---\n%s", out) + } + if !strings.Contains(out, "note: fix this") { + t.Errorf("output should contain the note\n--- output ---\n%s", out) + } + if strings.Contains(out, "### Lines") || strings.Contains(out, "(context ") { + t.Errorf("the old windowed/context format must be gone\n--- output ---\n%s", out) + } + // Exactly the 3 selected lines are emitted (not the whole file). + if got := strings.Count(out, "line\n"); got != 3 { + t.Errorf("expected exactly 3 selected lines, got %d\n--- output ---\n%s", got, out) + } +} + +func TestExplorer_FormatAnnotationsMultiFileAndMissing(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "a.go"), "a\nb\nc\n") + writeTestFile(t, filepath.Join(root, "b.go"), "d\ne\nf\n") + + sels := []SnippetSelection{ + {File: "a.go", StartLine: 1, EndLine: 2, Annotation: "first"}, + {File: "b.go", StartLine: 2, EndLine: 3, Annotation: "second"}, + {File: "gone.go", StartLine: 1, EndLine: 1, Annotation: "missing"}, + } + out := FormatAnnotations(root, sels) + + if !strings.Contains(out, "a.go") || !strings.Contains(out, "b.go") { + t.Errorf("output should list both files\n--- output ---\n%s", out) + } + if !strings.Contains(out, "first") || !strings.Contains(out, "second") { + t.Errorf("output should contain both annotations\n--- output ---\n%s", out) + } + if !strings.Contains(out, "unavailable") { + t.Errorf("output should mention the missing file\n--- output ---\n%s", out) + } +} + +func TestExplorer_FormatAnnotationsEmpty(t *testing.T) { + if out := FormatAnnotations(t.TempDir(), nil); out != "" { + t.Fatalf("FormatAnnotations(nil) = %q, want empty", out) + } +} diff --git a/internal/ui/components/snippet_attachments_view.go b/internal/ui/components/snippet_attachments_view.go new file mode 100644 index 00000000..b6dcb7e8 --- /dev/null +++ b/internal/ui/components/snippet_attachments_view.go @@ -0,0 +1,200 @@ +package components + +import ( + "fmt" + "strings" + + styles "github.com/inference-gateway/cli/internal/ui/styles" +) + +// maxVisibleSnippetRows caps how many snippet child rows are shown at once; the +// window scrolls to keep the focused row visible. This keeps the inline box from +// pushing the chat input around when many snippets are attached. +const maxVisibleSnippetRows = 10 + +// SnippetAttachmentsView renders the pending snippet attachments as a small tree +// below the chat input: one parent row per file, one indented child row per +// captured line range. It is a passive renderer driven by ChatApplication +// (mirroring TodoBoxView) — focus, cursor and the snippet list are pushed in +// before each render. The selected content is sent with the next chat message. +type SnippetAttachmentsView struct { + styleProvider *styles.Provider + width int + snippets []SnippetSelection + order []int + focused bool + cursor int + focusHint string +} + +// NewSnippetAttachmentsView creates an empty attachments view. +func NewSnippetAttachmentsView(styleProvider *styles.Provider) *SnippetAttachmentsView { + return &SnippetAttachmentsView{styleProvider: styleProvider, width: 80} +} + +// SetWidth sets the component width. +func (v *SnippetAttachmentsView) SetWidth(width int) { v.width = width } + +// SetFocusHint sets the key label shown in the unfocused header. +func (v *SnippetAttachmentsView) SetFocusHint(key string) { v.focusHint = key } + +// SetData stores a copy of the pending selections and recomputes display order. +func (v *SnippetAttachmentsView) SetData(sels []SnippetSelection) { + v.snippets = append(v.snippets[:0], sels...) + v.order = groupedOrder(v.snippets) + if v.cursor >= len(v.order) { + v.cursor = max(len(v.order)-1, 0) + } +} + +// Count returns the number of attached snippets. +func (v *SnippetAttachmentsView) Count() int { return len(v.order) } + +// IsFocused reports whether the tree currently has key focus. +func (v *SnippetAttachmentsView) IsFocused() bool { return v.focused } + +// Blur removes key focus from the tree. +func (v *SnippetAttachmentsView) Blur() { v.focused = false } + +// Focus gives the tree key focus, clamping the cursor into range. +func (v *SnippetAttachmentsView) Focus() { + v.focused = true + v.cursor = clampInt(v.cursor, 0, max(len(v.order)-1, 0)) +} + +// MoveCursor moves the selection by delta rows, clamping to the list bounds. +func (v *SnippetAttachmentsView) MoveCursor(delta int) { + if len(v.order) == 0 { + return + } + v.cursor = clampInt(v.cursor+delta, 0, len(v.order)-1) +} + +// SelectedIndex returns the index into the app's pending list for the focused +// row, or -1 when empty. +func (v *SnippetAttachmentsView) SelectedIndex() int { + if v.cursor < 0 || v.cursor >= len(v.order) { + return -1 + } + return v.order[v.cursor] +} + +// GetHeight returns the rendered line count (0 when there is nothing to show). +func (v *SnippetAttachmentsView) GetHeight() int { + lines := v.contentLines() + if len(lines) == 0 { + return 0 + } + return len(lines) + 2 +} + +// Render returns the framed tree, or "" when there are no attachments. +func (v *SnippetAttachmentsView) Render() string { + lines := v.contentLines() + if len(lines) == 0 { + return "" + } + return v.styleProvider.RenderBorderedBox(strings.Join(lines, "\n"), v.styleProvider.GetThemeColor("dim"), 0, 1) +} + +// contentLines builds the (unframed) rendered lines. Render and GetHeight both +// derive from this so the reserved layout height never drifts from what's drawn. +func (v *SnippetAttachmentsView) contentLines() []string { + if len(v.order) == 0 { + return nil + } + accent := v.styleProvider.GetThemeColor("accent") + dim := v.styleProvider.GetThemeColor("dim") + inner := max(v.width-4, 8) + + lines := []string{v.styleProvider.RenderWithColorAndBold(truncateRunes(v.headerText(), inner), accent)} + + start, end := v.window() + if start > 0 { + lines = append(lines, v.styleProvider.RenderWithColor(fmt.Sprintf(" … (%d above)", start), dim)) + } + lastFile := "" + for pos := start; pos < end; pos++ { + s := v.snippets[v.order[pos]] + if s.File != lastFile { + lines = append(lines, v.styleProvider.RenderWithColor("▸ "+truncatePathLeft(s.File, inner-2), dim)) + lastFile = s.File + } + lines = append(lines, v.renderChildRow(pos, s, accent, dim, inner)) + } + if end < len(v.order) { + lines = append(lines, v.styleProvider.RenderWithColor(fmt.Sprintf(" … (%d more)", len(v.order)-end), dim)) + } + return lines +} + +// renderChildRow renders one line-range row, highlighting it when it is the +// focused cursor row. +func (v *SnippetAttachmentsView) renderChildRow(pos int, s SnippetSelection, accent, dim string, inner int) string { + label := truncateRunes(" "+lineRangeLabel(s), inner) + if v.focused && pos == v.cursor { + return v.styleProvider.RenderWithColorAndBold(label, accent) + } + return v.styleProvider.RenderWithColor(label, dim) +} + +// headerText returns the box title; it shows the action keys when focused and a +// focus affordance otherwise. +func (v *SnippetAttachmentsView) headerText() string { + count := len(v.order) + if v.focused { + return fmt.Sprintf("Attached context (%d) — ↑/↓ move · d remove · c clear · esc done", count) + } + if v.focusHint != "" { + return fmt.Sprintf("Attached context (%d) · %s to edit · sent with your next message", count, v.focusHint) + } + return fmt.Sprintf("Attached context (%d) · sent with your next message", count) +} + +// window returns the [start,end) slice of order to render, scrolled to keep the +// focused row visible when there are more rows than fit. +func (v *SnippetAttachmentsView) window() (start, end int) { + n := len(v.order) + if n <= maxVisibleSnippetRows { + return 0, n + } + start = clampInt(v.cursor-maxVisibleSnippetRows/2, 0, n-maxVisibleSnippetRows) + return start, start + maxVisibleSnippetRows +} + +// groupedOrder returns indices into sels grouped by file in first-seen order, so +// the same file's ranges render together as one tree branch. +func groupedOrder(sels []SnippetSelection) []int { + order := make([]int, 0, len(sels)) + seen := make(map[string]bool, len(sels)) + for i := range sels { + if seen[sels[i].File] { + continue + } + seen[sels[i].File] = true + for j := range sels { + if sels[j].File == sels[i].File { + order = append(order, j) + } + } + } + return order +} + +// lineRangeLabel renders a selection's 1-indexed inclusive line range. +func lineRangeLabel(s SnippetSelection) string { + if s.StartLine == s.EndLine { + return fmt.Sprintf("L%d", s.StartLine) + } + return fmt.Sprintf("L%d-%d", s.StartLine, s.EndLine) +} + +// truncatePathLeft left-truncates a path to maxWidth runes, preserving the tail +// (filename) which is the most informative part. +func truncatePathLeft(p string, maxWidth int) string { + r := []rune(p) + if maxWidth <= 1 || len(r) <= maxWidth { + return p + } + return "…" + string(r[len(r)-(maxWidth-1):]) +} diff --git a/internal/ui/components/snippet_attachments_view_test.go b/internal/ui/components/snippet_attachments_view_test.go new file mode 100644 index 00000000..b5a43a7d --- /dev/null +++ b/internal/ui/components/snippet_attachments_view_test.go @@ -0,0 +1,105 @@ +package components + +import ( + "strings" + "testing" + + domain "github.com/inference-gateway/cli/internal/domain" + styles "github.com/inference-gateway/cli/internal/ui/styles" +) + +func newTestSnippetView() *SnippetAttachmentsView { + ts := domain.NewThemeProvider() + v := NewSnippetAttachmentsView(styles.NewProvider(ts)) + v.SetWidth(80) + return v +} + +func TestSnippetAttachments_EmptyRendersNothing(t *testing.T) { + v := newTestSnippetView() + if got := v.Render(); got != "" { + t.Fatalf("empty Render() = %q, want \"\"", got) + } + if got := v.GetHeight(); got != 0 { + t.Fatalf("empty GetHeight() = %d, want 0", got) + } + if got := v.SelectedIndex(); got != -1 { + t.Fatalf("empty SelectedIndex() = %d, want -1", got) + } +} + +func TestSnippetAttachments_RendersFilesAndRanges(t *testing.T) { + v := newTestSnippetView() + v.SetData([]SnippetSelection{ + {File: "internal/app/chat.go", StartLine: 3, EndLine: 5}, + {File: "cmd/root.go", StartLine: 7, EndLine: 7}, + }) + out := v.Render() + for _, want := range []string{"chat.go", "L3-5", "root.go", "L7"} { + if !strings.Contains(out, want) { + t.Errorf("Render() missing %q\n--- output ---\n%s", want, out) + } + } + if v.Count() != 2 { + t.Fatalf("Count() = %d, want 2", v.Count()) + } + if v.GetHeight() <= 0 { + t.Fatalf("GetHeight() = %d, want > 0", v.GetHeight()) + } +} + +func TestSnippetAttachments_GroupsByFileAndMapsIndex(t *testing.T) { + v := newTestSnippetView() + v.SetData([]SnippetSelection{ + {File: "a.go", StartLine: 1, EndLine: 1}, + {File: "b.go", StartLine: 2, EndLine: 2}, + {File: "a.go", StartLine: 9, EndLine: 9}, + }) + v.Focus() + + if got := v.SelectedIndex(); got != 0 { + t.Fatalf("cursor 0 SelectedIndex = %d, want 0 (a.go:1)", got) + } + v.MoveCursor(1) + if got := v.SelectedIndex(); got != 2 { + t.Fatalf("cursor 1 SelectedIndex = %d, want 2 (a.go:9)", got) + } + v.MoveCursor(1) + if got := v.SelectedIndex(); got != 1 { + t.Fatalf("cursor 2 SelectedIndex = %d, want 1 (b.go:2)", got) + } + v.MoveCursor(5) + if got := v.SelectedIndex(); got != 1 { + t.Fatalf("over-move SelectedIndex = %d, want 1 (clamped)", got) + } + v.MoveCursor(-99) + if got := v.SelectedIndex(); got != 0 { + t.Fatalf("under-move SelectedIndex = %d, want 0 (clamped)", got) + } +} + +func TestSnippetAttachments_SetDataClampsCursor(t *testing.T) { + v := newTestSnippetView() + v.SetData([]SnippetSelection{ + {File: "a.go", StartLine: 1, EndLine: 1}, + {File: "a.go", StartLine: 2, EndLine: 2}, + {File: "a.go", StartLine: 3, EndLine: 3}, + }) + v.Focus() + v.MoveCursor(2) + + v.SetData([]SnippetSelection{{File: "a.go", StartLine: 1, EndLine: 1}}) + if got := v.SelectedIndex(); got != 0 { + t.Fatalf("after shrink SelectedIndex = %d, want 0", got) + } +} + +func TestSnippetAttachments_TruncatePathLeftKeepsTail(t *testing.T) { + got := truncatePathLeft("internal/ui/components/snippet_attachments_view.go", 20) + if !strings.HasSuffix(got, "view.go") { + t.Fatalf("truncatePathLeft should keep the filename tail, got %q", got) + } + if []rune(got)[0] != '…' { + t.Fatalf("truncatePathLeft should prefix an ellipsis, got %q", got) + } +} From dea087d9ca28e635c3d60efe3994ce4c9cc28e72 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Sat, 20 Jun 2026 19:53:02 +0200 Subject: [PATCH 4/6] chore: apply suggestions from code review Co-authored-by: Eden Reich --- config/config_test.go | 2 +- internal/services/skills/skills_test.go | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/config/config_test.go b/config/config_test.go index ca3e1138..14a22930 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -964,7 +964,7 @@ func TestValidatePathInSandbox_SkillsCarveOut(t *testing.T) { // configured sandbox dirs (parity with .infer/skills), while non-skills paths // under .agents and lookalike siblings stay denied. func TestValidatePathInSandbox_AgentsSkillsCarveOut(t *testing.T) { - sandboxDir := t.TempDir() // an unrelated, explicitly-allowed sandbox dir + sandboxDir := t.TempDir() agentsSkill, err := filepath.Abs(filepath.Join(AgentsDirName, "skills", "demo", "SKILL.md")) if err != nil { diff --git a/internal/services/skills/skills_test.go b/internal/services/skills/skills_test.go index 7b0e033e..20dbdc33 100644 --- a/internal/services/skills/skills_test.go +++ b/internal/services/skills/skills_test.go @@ -198,16 +198,13 @@ func TestPrecedence_AgentsMiddleScope(t *testing.T) { agentsDir := t.TempDir() userDir := t.TempDir() - // Present in all three scopes -> project wins. writeSkill(t, projDir, "all-three", validSkillBody("all-three", "Project version.")) writeSkill(t, agentsDir, "all-three", validSkillBody("all-three", "Agents version.")) writeSkill(t, userDir, "all-three", validSkillBody("all-three", "User version.")) - // Present in agents and user only -> agents wins over user. writeSkill(t, agentsDir, "agents-and-user", validSkillBody("agents-and-user", "Agents version.")) writeSkill(t, userDir, "agents-and-user", validSkillBody("agents-and-user", "User version.")) - // Scope-exclusive skills load tagged with their own scope. writeSkill(t, agentsDir, "agents-only", validSkillBody("agents-only", "Only in .agents scope.")) writeSkill(t, userDir, "user-only", validSkillBody("user-only", "Only in user scope.")) From 4a9a2064f81eec397615451115881ae231f7b065 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Sat, 20 Jun 2026 20:02:32 +0200 Subject: [PATCH 5/6] fix: remove trailing whitespace in SnippetAttachmentsView struct --- internal/ui/components/snippet_attachments_view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/components/snippet_attachments_view.go b/internal/ui/components/snippet_attachments_view.go index b6dcb7e8..7bc32370 100644 --- a/internal/ui/components/snippet_attachments_view.go +++ b/internal/ui/components/snippet_attachments_view.go @@ -23,7 +23,7 @@ type SnippetAttachmentsView struct { snippets []SnippetSelection order []int focused bool - cursor int + cursor int focusHint string } From 1d957c0458402ec3a51fb881c5117aea05fe73f7 Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Sat, 20 Jun 2026 20:03:54 +0200 Subject: [PATCH 6/6] chore: add agent wrap up text and threshold config --- .infer/prompts.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.infer/prompts.yaml b/.infer/prompts.yaml index 76d76754..04af4784 100644 --- a/.infer/prompts.yaml +++ b/.infer/prompts.yaml @@ -117,6 +117,8 @@ agent: This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user. + wrap_up_text: "" + wrap_up_threshold: 0 git: commit_message: system_prompt: |-