feat: add optional startup instruction context
Fixes #100
- commit
- 5561cbd8994c3fe8ef41ccfaf1c266fb6629d897
- author
- patriceckhart · · 2026-07-24 07:29 UTC
- parent
- 5b23b1d
- signature
- unsigned
13 files · +259 -34
M README.md +3 -2
| @@ -135,9 +135,9 @@ Use `AGENTS.md` to give zot standing instructions that layer **on top of** the d | |||
| 135 | 135 | zot discovers `AGENTS.md` files automatically at startup and loads them in this order: | |
| 136 | 136 | ||
| 137 | 137 | 1. `$ZOT_HOME/AGENTS.md` (global, machine-wide instructions that apply to every project). | |
| 138 | - | 2. Every `AGENTS.md` from the top-most parent directory down to the current working directory. More specific (deeper) files may override earlier ones. | |
| 138 | + | 2. Every `AGENTS.md` from the filesystem root down to the current working directory. More specific (deeper) files may override earlier ones. This includes `~/AGENTS.md` when the working directory is inside your home directory. | |
| 139 | 139 | ||
| 140 | - | All discovered files are appended to the prompt in that order, so a global baseline can be refined per project. | |
| 140 | + | All discovered files are appended to the prompt in that order, so a global baseline can be refined per project. To list their paths above the interactive transcript, enable **show loaded instructions at startup** in `/settings`. The setting is off by default, and the displayed path list is not sent to the model or stored in the session transcript. | |
| 141 | 141 | ||
| 142 | 142 | For a general, non-project-specific instruction set, put your rules in `$ZOT_HOME/AGENTS.md`, for example: | |
| 143 | 143 | ||
| @@ -358,6 +358,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 358 | 358 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 359 | 359 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 360 | 360 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| 361 | + | - **show loaded instructions at startup**: list every loaded `AGENTS.md` file path above the transcript. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |
| 361 | 362 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | |
| 362 | 363 | - **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. | |
| 363 | 364 | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | |
| @@ -135,9 +135,9 @@ Use `AGENTS.md` to give zot standing instructions that layer **on top of** the d | |||||
| 135 | zot discovers `AGENTS.md` files automatically at startup and loads them in this order: | 135 | zot discovers `AGENTS.md` files automatically at startup and loads them in this order: | ||
| 136 | 136 | ||||
| 137 | 1. `$ZOT_HOME/AGENTS.md` (global, machine-wide instructions that apply to every project). | 137 | 1. `$ZOT_HOME/AGENTS.md` (global, machine-wide instructions that apply to every project). | ||
| 138 | - | 2. Every `AGENTS.md` from the top-most parent directory down to the current working directory. More specific (deeper) files may override earlier ones. | 138 | + | 2. Every `AGENTS.md` from the filesystem root down to the current working directory. More specific (deeper) files may override earlier ones. This includes `~/AGENTS.md` when the working directory is inside your home directory. |
| 139 | 139 | ||||
| 140 | - | All discovered files are appended to the prompt in that order, so a global baseline can be refined per project. | 140 | + | All discovered files are appended to the prompt in that order, so a global baseline can be refined per project. To list their paths above the interactive transcript, enable **show loaded instructions at startup** in `/settings`. The setting is off by default, and the displayed path list is not sent to the model or stored in the session transcript. |
| 141 | 141 | ||||
| 142 | For a general, non-project-specific instruction set, put your rules in `$ZOT_HOME/AGENTS.md`, for example: | 142 | For a general, non-project-specific instruction set, put your rules in `$ZOT_HOME/AGENTS.md`, for example: | ||
| 143 | 143 | ||||
| @@ -358,6 +358,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 358 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | 358 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | ||
| 359 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 359 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 360 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 360 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| 361 | + | - **show loaded instructions at startup**: list every loaded `AGENTS.md` file path above the transcript. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |||
| 361 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | 362 | - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields). | ||
| 362 | - **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. | 363 | - **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. | ||
| 363 | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | 364 | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | ||
M packages/agent/build.go +25 -11
| @@ -15,6 +15,12 @@ import ( | |||
| 15 | 15 | "github.com/patriceckhart/zot/packages/provider" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | + | // ContextFile is an instruction file loaded into the system prompt. | |
| 19 | + | type ContextFile struct { | |
| 20 | + | Path string | |
| 21 | + | Content string | |
| 22 | + | } | |
| 23 | + | ||
| 18 | 24 | // Resolved is the effective configuration after merging CLI, config, defaults. | |
| 19 | 25 | type Resolved struct { | |
| 20 | 26 | Provider string | |
| @@ -46,6 +52,12 @@ type Resolved struct { | |||
| 46 | 52 | // discovered. Exposed so the tui can list / preview skills. | |
| 47 | 53 | SkillTool *skills.Tool | |
| 48 | 54 | ||
| 55 | + | // ContextFiles records the AGENTS.md files appended to SystemPrompt, | |
| 56 | + | // in effective load order. Interactive mode uses this metadata to make | |
| 57 | + | // otherwise invisible startup context inspectable without adding fake | |
| 58 | + | // messages to the provider transcript. | |
| 59 | + | ContextFiles []ContextFile | |
| 60 | + | ||
| 49 | 61 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | |
| 50 | 62 | // so the system prompt can be rebuilt later without re-running | |
| 51 | 63 | // resolve. | |
| @@ -581,8 +593,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 581 | 593 | ||
| 582 | 594 | summaries := toolSummaries(reg, args) | |
| 583 | 595 | ||
| 596 | + | contextFiles := loadAgentsContext(args.CWD, ZotHome()) | |
| 584 | 597 | append_ := append([]string(nil), args.AppendSystemPrompt...) | |
| 585 | - | if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" { | |
| 598 | + | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { | |
| 586 | 599 | append_ = append(append_, agentsAddendum) | |
| 587 | 600 | } | |
| 588 | 601 | if skillAddendum != "" { | |
| @@ -635,6 +648,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 635 | 648 | MaxOutput: resolvedModel.MaxOutput, | |
| 636 | 649 | Sandbox: sandbox, | |
| 637 | 650 | SkillTool: skillTool, | |
| 651 | + | ContextFiles: contextFiles, | |
| 638 | 652 | systemAppend: append_, | |
| 639 | 653 | systemCustom: custom, | |
| 640 | 654 | toolDescriptions: descMapFromSummaries(summaries), | |
| @@ -658,16 +672,12 @@ func readUserSystemPrompt(zotHome string) string { | |||
| 658 | 672 | return strings.TrimSpace(string(raw)) | |
| 659 | 673 | } | |
| 660 | 674 | ||
| 661 | - | // readAgentsContext loads optional AGENTS.md instruction files. No | |
| 675 | + | // loadAgentsContext loads optional AGENTS.md instruction files. No | |
| 662 | 676 | // default file is created or required: zot only includes files that | |
| 663 | 677 | // already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first, | |
| 664 | - | // followed by project instructions from the top-most parent down to cwd. | |
| 665 | - | func readAgentsContext(cwd, zotHome string) string { | |
| 666 | - | type contextFile struct { | |
| 667 | - | path string | |
| 668 | - | content string | |
| 669 | - | } | |
| 670 | - | var files []contextFile | |
| 678 | + | // followed by project instructions from the filesystem root down to cwd. | |
| 679 | + | func loadAgentsContext(cwd, zotHome string) []ContextFile { | |
| 680 | + | var files []ContextFile | |
| 671 | 681 | seen := map[string]bool{} | |
| 672 | 682 | add := func(path string) { | |
| 673 | 683 | if path == "" { | |
| @@ -689,7 +699,7 @@ func readAgentsContext(cwd, zotHome string) string { | |||
| 689 | 699 | return | |
| 690 | 700 | } | |
| 691 | 701 | seen[path] = true | |
| 692 | - | files = append(files, contextFile{path: path, content: content}) | |
| 702 | + | files = append(files, ContextFile{Path: path, Content: content}) | |
| 693 | 703 | } | |
| 694 | 704 | addFirstFromDir := func(dir string) { | |
| 695 | 705 | if dir == "" { | |
| @@ -724,13 +734,17 @@ func readAgentsContext(cwd, zotHome string) string { | |||
| 724 | 734 | } | |
| 725 | 735 | } | |
| 726 | 736 | ||
| 737 | + | return files | |
| 738 | + | } | |
| 739 | + | ||
| 740 | + | func formatAgentsContext(files []ContextFile) string { | |
| 727 | 741 | if len(files) == 0 { | |
| 728 | 742 | return "" | |
| 729 | 743 | } | |
| 730 | 744 | var sb strings.Builder | |
| 731 | 745 | sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n") | |
| 732 | 746 | for _, f := range files { | |
| 733 | - | fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content) | |
| 747 | + | fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content) | |
| 734 | 748 | } | |
| 735 | 749 | return strings.TrimSpace(sb.String()) | |
| 736 | 750 | } | |
| @@ -15,6 +15,12 @@ import ( | |||||
| 15 | "github.com/patriceckhart/zot/packages/provider" | 15 | "github.com/patriceckhart/zot/packages/provider" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | + | // ContextFile is an instruction file loaded into the system prompt. | |||
| 19 | + | type ContextFile struct { | |||
| 20 | + | Path string | |||
| 21 | + | Content string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 18 | // Resolved is the effective configuration after merging CLI, config, defaults. | 24 | // Resolved is the effective configuration after merging CLI, config, defaults. | ||
| 19 | type Resolved struct { | 25 | type Resolved struct { | ||
| 20 | Provider string | 26 | Provider string | ||
| @@ -46,6 +52,12 @@ type Resolved struct { | |||||
| 46 | // discovered. Exposed so the tui can list / preview skills. | 52 | // discovered. Exposed so the tui can list / preview skills. | ||
| 47 | SkillTool *skills.Tool | 53 | SkillTool *skills.Tool | ||
| 48 | 54 | ||||
| 55 | + | // ContextFiles records the AGENTS.md files appended to SystemPrompt, | |||
| 56 | + | // in effective load order. Interactive mode uses this metadata to make | |||
| 57 | + | // otherwise invisible startup context inspectable without adding fake | |||
| 58 | + | // messages to the provider transcript. | |||
| 59 | + | ContextFiles []ContextFile | |||
| 60 | + | ||||
| 49 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | 61 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | ||
| 50 | // so the system prompt can be rebuilt later without re-running | 62 | // so the system prompt can be rebuilt later without re-running | ||
| 51 | // resolve. | 63 | // resolve. | ||
| @@ -581,8 +593,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 581 | 593 | ||||
| 582 | summaries := toolSummaries(reg, args) | 594 | summaries := toolSummaries(reg, args) | ||
| 583 | 595 | ||||
| 596 | + | contextFiles := loadAgentsContext(args.CWD, ZotHome()) | |||
| 584 | append_ := append([]string(nil), args.AppendSystemPrompt...) | 597 | append_ := append([]string(nil), args.AppendSystemPrompt...) | ||
| 585 | - | if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" { | 598 | + | if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { |
| 586 | append_ = append(append_, agentsAddendum) | 599 | append_ = append(append_, agentsAddendum) | ||
| 587 | } | 600 | } | ||
| 588 | if skillAddendum != "" { | 601 | if skillAddendum != "" { | ||
| @@ -635,6 +648,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 635 | MaxOutput: resolvedModel.MaxOutput, | 648 | MaxOutput: resolvedModel.MaxOutput, | ||
| 636 | Sandbox: sandbox, | 649 | Sandbox: sandbox, | ||
| 637 | SkillTool: skillTool, | 650 | SkillTool: skillTool, | ||
| 651 | + | ContextFiles: contextFiles, | |||
| 638 | systemAppend: append_, | 652 | systemAppend: append_, | ||
| 639 | systemCustom: custom, | 653 | systemCustom: custom, | ||
| 640 | toolDescriptions: descMapFromSummaries(summaries), | 654 | toolDescriptions: descMapFromSummaries(summaries), | ||
| @@ -658,16 +672,12 @@ func readUserSystemPrompt(zotHome string) string { | |||||
| 658 | return strings.TrimSpace(string(raw)) | 672 | return strings.TrimSpace(string(raw)) | ||
| 659 | } | 673 | } | ||
| 660 | 674 | ||||
| 661 | - | // readAgentsContext loads optional AGENTS.md instruction files. No | 675 | + | // loadAgentsContext loads optional AGENTS.md instruction files. No |
| 662 | // default file is created or required: zot only includes files that | 676 | // default file is created or required: zot only includes files that | ||
| 663 | // already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first, | 677 | // already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first, | ||
| 664 | - | // followed by project instructions from the top-most parent down to cwd. | 678 | + | // followed by project instructions from the filesystem root down to cwd. |
| 665 | - | func readAgentsContext(cwd, zotHome string) string { | 679 | + | func loadAgentsContext(cwd, zotHome string) []ContextFile { |
| 666 | - | type contextFile struct { | 680 | + | var files []ContextFile |
| 667 | - | path string | |||
| 668 | - | content string | |||
| 669 | - | } | |||
| 670 | - | var files []contextFile | |||
| 671 | seen := map[string]bool{} | 681 | seen := map[string]bool{} | ||
| 672 | add := func(path string) { | 682 | add := func(path string) { | ||
| 673 | if path == "" { | 683 | if path == "" { | ||
| @@ -689,7 +699,7 @@ func readAgentsContext(cwd, zotHome string) string { | |||||
| 689 | return | 699 | return | ||
| 690 | } | 700 | } | ||
| 691 | seen[path] = true | 701 | seen[path] = true | ||
| 692 | - | files = append(files, contextFile{path: path, content: content}) | 702 | + | files = append(files, ContextFile{Path: path, Content: content}) |
| 693 | } | 703 | } | ||
| 694 | addFirstFromDir := func(dir string) { | 704 | addFirstFromDir := func(dir string) { | ||
| 695 | if dir == "" { | 705 | if dir == "" { | ||
| @@ -724,13 +734,17 @@ func readAgentsContext(cwd, zotHome string) string { | |||||
| 724 | } | 734 | } | ||
| 725 | } | 735 | } | ||
| 726 | 736 | ||||
| 737 | + | return files | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | func formatAgentsContext(files []ContextFile) string { | |||
| 727 | if len(files) == 0 { | 741 | if len(files) == 0 { | ||
| 728 | return "" | 742 | return "" | ||
| 729 | } | 743 | } | ||
| 730 | var sb strings.Builder | 744 | var sb strings.Builder | ||
| 731 | sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n") | 745 | sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n") | ||
| 732 | for _, f := range files { | 746 | for _, f := range files { | ||
| 733 | - | fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content) | 747 | + | fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content) |
| 734 | } | 748 | } | ||
| 735 | return strings.TrimSpace(sb.String()) | 749 | return strings.TrimSpace(sb.String()) | ||
| 736 | } | 750 | } | ||
M packages/agent/build_test.go +22 -4
| @@ -31,10 +31,25 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||
| 31 | 31 | t.Fatal(err) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | got := readAgentsContext(nested, zotHome) | |
| 34 | + | files := loadAgentsContext(nested, zotHome) | |
| 35 | + | if len(files) != 3 { | |
| 36 | + | t.Fatalf("loaded %d context files, want 3: %#v", len(files), files) | |
| 37 | + | } | |
| 38 | + | wantPaths := []string{ | |
| 39 | + | filepath.Join(zotHome, "AGENTS.md"), | |
| 40 | + | filepath.Join(project, "AGENTS.md"), | |
| 41 | + | filepath.Join(nested, "AGENTS.md"), | |
| 42 | + | } | |
| 43 | + | for idx, want := range wantPaths { | |
| 44 | + | if files[idx].Path != want { | |
| 45 | + | t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | got := formatAgentsContext(files) | |
| 35 | 50 | for _, want := range []string{"global rule", "repo rule", "app rule"} { | |
| 36 | 51 | if !strings.Contains(got, want) { | |
| 37 | - | t.Fatalf("readAgentsContext missing %q in:\n%s", want, got) | |
| 52 | + | t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got) | |
| 38 | 53 | } | |
| 39 | 54 | } | |
| 40 | 55 | if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { | |
| @@ -43,8 +58,11 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||
| 43 | 58 | } | |
| 44 | 59 | ||
| 45 | 60 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |
| 46 | - | got := readAgentsContext(t.TempDir(), t.TempDir()) | |
| 47 | - | if got != "" { | |
| 61 | + | files := loadAgentsContext(t.TempDir(), t.TempDir()) | |
| 62 | + | if len(files) != 0 { | |
| 63 | + | t.Fatalf("expected no context files, got %#v", files) | |
| 64 | + | } | |
| 65 | + | if got := formatAgentsContext(files); got != "" { | |
| 48 | 66 | t.Fatalf("expected no context, got %q", got) | |
| 49 | 67 | } | |
| 50 | 68 | } | |
| @@ -31,10 +31,25 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||||
| 31 | t.Fatal(err) | 31 | t.Fatal(err) | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | got := readAgentsContext(nested, zotHome) | 34 | + | files := loadAgentsContext(nested, zotHome) |
| 35 | + | if len(files) != 3 { | |||
| 36 | + | t.Fatalf("loaded %d context files, want 3: %#v", len(files), files) | |||
| 37 | + | } | |||
| 38 | + | wantPaths := []string{ | |||
| 39 | + | filepath.Join(zotHome, "AGENTS.md"), | |||
| 40 | + | filepath.Join(project, "AGENTS.md"), | |||
| 41 | + | filepath.Join(nested, "AGENTS.md"), | |||
| 42 | + | } | |||
| 43 | + | for idx, want := range wantPaths { | |||
| 44 | + | if files[idx].Path != want { | |||
| 45 | + | t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | got := formatAgentsContext(files) | |||
| 35 | for _, want := range []string{"global rule", "repo rule", "app rule"} { | 50 | for _, want := range []string{"global rule", "repo rule", "app rule"} { | ||
| 36 | if !strings.Contains(got, want) { | 51 | if !strings.Contains(got, want) { | ||
| 37 | - | t.Fatalf("readAgentsContext missing %q in:\n%s", want, got) | 52 | + | t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got) |
| 38 | } | 53 | } | ||
| 39 | } | 54 | } | ||
| 40 | if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { | 55 | if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { | ||
| @@ -43,8 +58,11 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||||
| 43 | } | 58 | } | ||
| 44 | 59 | ||||
| 45 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | 60 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | ||
| 46 | - | got := readAgentsContext(t.TempDir(), t.TempDir()) | 61 | + | files := loadAgentsContext(t.TempDir(), t.TempDir()) |
| 47 | - | if got != "" { | 62 | + | if len(files) != 0 { |
| 63 | + | t.Fatalf("expected no context files, got %#v", files) | |||
| 64 | + | } | |||
| 65 | + | if got := formatAgentsContext(files); got != "" { | |||
| 48 | t.Fatalf("expected no context, got %q", got) | 66 | t.Fatalf("expected no context, got %q", got) | ||
| 49 | } | 67 | } | ||
| 50 | } | 68 | } | ||
M packages/agent/cli.go +4 -1
| @@ -890,7 +890,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 890 | 890 | ||
| 891 | 891 | // Push the new state into the running Interactive. | |
| 892 | 892 | if iv != nil { | |
| 893 | - | iv.ApplyChangedCWD(newAg, newProvider, newModel, absPath) | |
| 893 | + | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome())) | |
| 894 | + | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | |
| 894 | 895 | } | |
| 895 | 896 | ||
| 896 | 897 | // Re-scope the swarm dashboard to the new session. | |
| @@ -1005,6 +1006,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1005 | 1006 | Tools: r.ToolRegistry, | |
| 1006 | 1007 | MaxSteps: r.MaxSteps, | |
| 1007 | 1008 | CWD: r.CWD, | |
| 1009 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 1010 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 1008 | 1011 | ZotHome: ZotHome(), | |
| 1009 | 1012 | Version: version, | |
| 1010 | 1013 | UpdateInfoChan: updateCh, | |
| @@ -890,7 +890,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 890 | 890 | ||||
| 891 | // Push the new state into the running Interactive. | 891 | // Push the new state into the running Interactive. | ||
| 892 | if iv != nil { | 892 | if iv != nil { | ||
| 893 | - | iv.ApplyChangedCWD(newAg, newProvider, newModel, absPath) | 893 | + | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome())) |
| 894 | + | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | |||
| 894 | } | 895 | } | ||
| 895 | 896 | ||||
| 896 | // Re-scope the swarm dashboard to the new session. | 897 | // Re-scope the swarm dashboard to the new session. | ||
| @@ -1005,6 +1006,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1005 | Tools: r.ToolRegistry, | 1006 | Tools: r.ToolRegistry, | ||
| 1006 | MaxSteps: r.MaxSteps, | 1007 | MaxSteps: r.MaxSteps, | ||
| 1007 | CWD: r.CWD, | 1008 | CWD: r.CWD, | ||
| 1009 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |||
| 1010 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |||
| 1008 | ZotHome: ZotHome(), | 1011 | ZotHome: ZotHome(), | ||
| 1009 | Version: version, | 1012 | Version: version, | ||
| 1010 | UpdateInfoChan: updateCh, | 1013 | UpdateInfoChan: updateCh, | ||
M packages/agent/config.go +5 -0
| @@ -81,6 +81,11 @@ type Config struct { | |||
| 81 | 81 | // default; nil/missing means disabled. Toggle from /settings. | |
| 82 | 82 | CompactMode *bool `json:"compact_mode,omitempty"` | |
| 83 | 83 | ||
| 84 | + | // ShowInstructionsAtStartup lists loaded AGENTS.md file paths above | |
| 85 | + | // the transcript. Off by default; nil/missing means disabled. | |
| 86 | + | // Toggle from /settings. | |
| 87 | + | ShowInstructionsAtStartup *bool `json:"show_instructions_at_startup,omitempty"` | |
| 88 | + | ||
| 84 | 89 | // TUIInputStyle controls the main input rendering. Supported values: | |
| 85 | 90 | // "plain" (default), "lines", and "block". | |
| 86 | 91 | TUIInputStyle string `json:"tui_input_style,omitempty"` | |
| @@ -81,6 +81,11 @@ type Config struct { | |||||
| 81 | // default; nil/missing means disabled. Toggle from /settings. | 81 | // default; nil/missing means disabled. Toggle from /settings. | ||
| 82 | CompactMode *bool `json:"compact_mode,omitempty"` | 82 | CompactMode *bool `json:"compact_mode,omitempty"` | ||
| 83 | 83 | ||||
| 84 | + | // ShowInstructionsAtStartup lists loaded AGENTS.md file paths above | |||
| 85 | + | // the transcript. Off by default; nil/missing means disabled. | |||
| 86 | + | // Toggle from /settings. | |||
| 87 | + | ShowInstructionsAtStartup *bool `json:"show_instructions_at_startup,omitempty"` | |||
| 88 | + | ||||
| 84 | // TUIInputStyle controls the main input rendering. Supported values: | 89 | // TUIInputStyle controls the main input rendering. Supported values: | ||
| 85 | // "plain" (default), "lines", and "block". | 90 | // "plain" (default), "lines", and "block". | ||
| 86 | TUIInputStyle string `json:"tui_input_style,omitempty"` | 91 | TUIInputStyle string `json:"tui_input_style,omitempty"` | ||
A packages/agent/instruction_context.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | // instructionContextPaths returns the loaded instruction paths in effective | |
| 4 | + | // prompt order for display in interactive startup metadata. | |
| 5 | + | func instructionContextPaths(files []ContextFile) []string { | |
| 6 | + | paths := make([]string, 0, len(files)) | |
| 7 | + | for _, file := range files { | |
| 8 | + | paths = append(paths, file.Path) | |
| 9 | + | } | |
| 10 | + | return paths | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | // instructionContextPaths returns the loaded instruction paths in effective | |||
| 4 | + | // prompt order for display in interactive startup metadata. | |||
| 5 | + | func instructionContextPaths(files []ContextFile) []string { | |||
| 6 | + | paths := make([]string, 0, len(files)) | |||
| 7 | + | for _, file := range files { | |||
| 8 | + | paths = append(paths, file.Path) | |||
| 9 | + | } | |||
| 10 | + | return paths | |||
| 11 | + | } | |||
A packages/agent/instruction_context_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestInstructionContextPathsPreserveLoadOrder(t *testing.T) { | |
| 6 | + | files := []ContextFile{ | |
| 7 | + | {Path: "/home/user/AGENTS.md", Content: "global rule"}, | |
| 8 | + | {Path: "/repo/AGENTS.md", Content: "project rule"}, | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | paths := instructionContextPaths(files) | |
| 12 | + | if len(paths) != len(files) { | |
| 13 | + | t.Fatalf("got %d startup paths, want %d", len(paths), len(files)) | |
| 14 | + | } | |
| 15 | + | for idx, path := range paths { | |
| 16 | + | if path != files[idx].Path { | |
| 17 | + | t.Fatalf("path %d = %q, want %q", idx, path, files[idx].Path) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestInstructionContextPathsPreserveLoadOrder(t *testing.T) { | |||
| 6 | + | files := []ContextFile{ | |||
| 7 | + | {Path: "/home/user/AGENTS.md", Content: "global rule"}, | |||
| 8 | + | {Path: "/repo/AGENTS.md", Content: "project rule"}, | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | paths := instructionContextPaths(files) | |||
| 12 | + | if len(paths) != len(files) { | |||
| 13 | + | t.Fatalf("got %d startup paths, want %d", len(paths), len(files)) | |||
| 14 | + | } | |||
| 15 | + | for idx, path := range paths { | |||
| 16 | + | if path != files[idx].Path { | |||
| 17 | + | t.Fatalf("path %d = %q, want %q", idx, path, files[idx].Path) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | } | |||
M packages/agent/modes/interactive.go +65 -14
| @@ -38,6 +38,14 @@ type InteractiveConfig struct { | |||
| 38 | 38 | MaxSteps int | |
| 39 | 39 | CWD string | |
| 40 | 40 | ||
| 41 | + | // StartupContextPaths are loaded instruction file paths available to show | |
| 42 | + | // before the transcript. They are never sent to the provider or persisted. | |
| 43 | + | StartupContextPaths []string | |
| 44 | + | ||
| 45 | + | // ShowInstructionsAtStartup controls whether StartupContextPaths are visible. | |
| 46 | + | // nil means the default, off. | |
| 47 | + | ShowInstructionsAtStartup *bool | |
| 48 | + | ||
| 41 | 49 | // InlineImagesEnabled overrides terminal image rendering. nil means | |
| 42 | 50 | // auto-detect and render when supported; false disables; true uses | |
| 43 | 51 | // the detected protocol when available. | |
| @@ -304,6 +312,10 @@ type SettingsStore interface { | |||
| 304 | 312 | SetTheme(name string) error | |
| 305 | 313 | } | |
| 306 | 314 | ||
| 315 | + | type showInstructionsSettingsStore interface { | |
| 316 | + | SetShowInstructionsAtStartup(enabled bool) error | |
| 317 | + | } | |
| 318 | + | ||
| 307 | 319 | type Interactive struct { | |
| 308 | 320 | cfg InteractiveConfig | |
| 309 | 321 | view *tui.View | |
| @@ -517,14 +529,19 @@ const resumeTailExpandStep = 80 | |||
| 517 | 529 | func NewInteractive(cfg InteractiveConfig) *Interactive { | |
| 518 | 530 | renderer := tui.NewRenderer(cfg.Terminal) | |
| 519 | 531 | renderer.SetTheme(cfg.Theme) | |
| 532 | + | startupContextPaths := []string(nil) | |
| 533 | + | if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup { | |
| 534 | + | startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...) | |
| 535 | + | } | |
| 520 | 536 | i := &Interactive{ | |
| 521 | 537 | cfg: cfg, | |
| 522 | 538 | view: &tui.View{ | |
| 523 | - | Theme: cfg.Theme, | |
| 524 | - | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| 525 | - | FlatTools: cfg.FlatTools, | |
| 526 | - | CompactUser: cfg.CompactUser, | |
| 527 | - | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, | |
| 539 | + | Theme: cfg.Theme, | |
| 540 | + | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| 541 | + | FlatTools: cfg.FlatTools, | |
| 542 | + | CompactUser: cfg.CompactUser, | |
| 543 | + | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, | |
| 544 | + | StartupContextPaths: startupContextPaths, | |
| 528 | 545 | }, | |
| 529 | 546 | // Prompt is the standard half-block accent bar used by chat | |
| 530 | 547 | // speaker labels too, so the input gutter matches the rest | |
| @@ -2836,19 +2853,27 @@ func (i *Interactive) Submit(text string) { | |||
| 2836 | 2853 | i.startTurn(i.runCtx, text) | |
| 2837 | 2854 | } | |
| 2838 | 2855 | ||
| 2839 | - | // ApplyChangedCWD is called by the host after a successful /cd hook. | |
| 2840 | - | // The host has already rebuilt the agent and opened a fresh session | |
| 2841 | - | // in the new cwd; this method swaps the fresh agent into the running | |
| 2842 | - | // TUI, updates the displayed cwd, clears the transcript display | |
| 2843 | - | // caches, and points the file picker at the new directory. | |
| 2844 | - | // | |
| 2845 | - | // The fresh agent's transcript is empty (new session) so the chat | |
| 2846 | - | // view starts blank, matching what relaunching `zot --cwd <path>` | |
| 2847 | - | // would show. Cost meters reset. | |
| 2856 | + | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| 2857 | + | // not provide startup context metadata. | |
| 2848 | 2858 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | |
| 2859 | + | i.applyChangedCWD(ag, provider, model, cwd, nil) | |
| 2860 | + | } | |
| 2861 | + | ||
| 2862 | + | // ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only | |
| 2863 | + | // startup context into the running TUI after a successful /cd hook. | |
| 2864 | + | func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 2865 | + | i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths) | |
| 2866 | + | } | |
| 2867 | + | ||
| 2868 | + | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |
| 2849 | 2869 | i.mu.Lock() | |
| 2850 | 2870 | i.agent = ag | |
| 2851 | 2871 | i.cfg.CWD = cwd | |
| 2872 | + | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 2873 | + | i.view.StartupContextPaths = nil | |
| 2874 | + | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| 2875 | + | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |
| 2876 | + | } | |
| 2852 | 2877 | // Re-report the working directory to the terminal so "new tab here" | |
| 2853 | 2878 | // tracks the /cd change (OSC 7, see issue #38). | |
| 2854 | 2879 | if i.cfg.Terminal != nil { | |
| @@ -3062,6 +3087,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3062 | 3087 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 3063 | 3088 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 3064 | 3089 | compactMode := i.compactModeEnabled() | |
| 3090 | + | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | |
| 3065 | 3091 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 3066 | 3092 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 3067 | 3093 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| @@ -3191,6 +3217,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3191 | 3217 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | |
| 3192 | 3218 | value: compactMode, | |
| 3193 | 3219 | }, | |
| 3220 | + | { | |
| 3221 | + | key: "show_instructions_at_startup", | |
| 3222 | + | label: "show loaded instructions at startup", | |
| 3223 | + | desc: "list loaded AGENTS.md file paths above the transcript", | |
| 3224 | + | value: showInstructions, | |
| 3225 | + | }, | |
| 3194 | 3226 | { | |
| 3195 | 3227 | key: "tui_settings", | |
| 3196 | 3228 | label: "tui settings", | |
| @@ -3557,6 +3589,25 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3557 | 3589 | i.statusOK = "compact transcript rendering " + onOff(value) | |
| 3558 | 3590 | i.statusErr = "" | |
| 3559 | 3591 | i.mu.Unlock() | |
| 3592 | + | case "show_instructions_at_startup": | |
| 3593 | + | val := value | |
| 3594 | + | i.cfg.ShowInstructionsAtStartup = &val | |
| 3595 | + | if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok { | |
| 3596 | + | if err := store.SetShowInstructionsAtStartup(value); err != nil { | |
| 3597 | + | i.mu.Lock() | |
| 3598 | + | i.statusErr = "settings: " + err.Error() | |
| 3599 | + | i.mu.Unlock() | |
| 3600 | + | return | |
| 3601 | + | } | |
| 3602 | + | } | |
| 3603 | + | i.mu.Lock() | |
| 3604 | + | i.view.StartupContextPaths = nil | |
| 3605 | + | if value { | |
| 3606 | + | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | |
| 3607 | + | } | |
| 3608 | + | i.statusOK = "show loaded instructions at startup " + onOff(value) | |
| 3609 | + | i.statusErr = "" | |
| 3610 | + | i.mu.Unlock() | |
| 3560 | 3611 | } | |
| 3561 | 3612 | } | |
| 3562 | 3613 | ||
| @@ -38,6 +38,14 @@ type InteractiveConfig struct { | |||||
| 38 | MaxSteps int | 38 | MaxSteps int | ||
| 39 | CWD string | 39 | CWD string | ||
| 40 | 40 | ||||
| 41 | + | // StartupContextPaths are loaded instruction file paths available to show | |||
| 42 | + | // before the transcript. They are never sent to the provider or persisted. | |||
| 43 | + | StartupContextPaths []string | |||
| 44 | + | ||||
| 45 | + | // ShowInstructionsAtStartup controls whether StartupContextPaths are visible. | |||
| 46 | + | // nil means the default, off. | |||
| 47 | + | ShowInstructionsAtStartup *bool | |||
| 48 | + | ||||
| 41 | // InlineImagesEnabled overrides terminal image rendering. nil means | 49 | // InlineImagesEnabled overrides terminal image rendering. nil means | ||
| 42 | // auto-detect and render when supported; false disables; true uses | 50 | // auto-detect and render when supported; false disables; true uses | ||
| 43 | // the detected protocol when available. | 51 | // the detected protocol when available. | ||
| @@ -304,6 +312,10 @@ type SettingsStore interface { | |||||
| 304 | SetTheme(name string) error | 312 | SetTheme(name string) error | ||
| 305 | } | 313 | } | ||
| 306 | 314 | ||||
| 315 | + | type showInstructionsSettingsStore interface { | |||
| 316 | + | SetShowInstructionsAtStartup(enabled bool) error | |||
| 317 | + | } | |||
| 318 | + | ||||
| 307 | type Interactive struct { | 319 | type Interactive struct { | ||
| 308 | cfg InteractiveConfig | 320 | cfg InteractiveConfig | ||
| 309 | view *tui.View | 321 | view *tui.View | ||
| @@ -517,14 +529,19 @@ const resumeTailExpandStep = 80 | |||||
| 517 | func NewInteractive(cfg InteractiveConfig) *Interactive { | 529 | func NewInteractive(cfg InteractiveConfig) *Interactive { | ||
| 518 | renderer := tui.NewRenderer(cfg.Terminal) | 530 | renderer := tui.NewRenderer(cfg.Terminal) | ||
| 519 | renderer.SetTheme(cfg.Theme) | 531 | renderer.SetTheme(cfg.Theme) | ||
| 532 | + | startupContextPaths := []string(nil) | |||
| 533 | + | if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup { | |||
| 534 | + | startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...) | |||
| 535 | + | } | |||
| 520 | i := &Interactive{ | 536 | i := &Interactive{ | ||
| 521 | cfg: cfg, | 537 | cfg: cfg, | ||
| 522 | view: &tui.View{ | 538 | view: &tui.View{ | ||
| 523 | - | Theme: cfg.Theme, | 539 | + | Theme: cfg.Theme, |
| 524 | - | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | 540 | + | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), |
| 525 | - | FlatTools: cfg.FlatTools, | 541 | + | FlatTools: cfg.FlatTools, |
| 526 | - | CompactUser: cfg.CompactUser, | 542 | + | CompactUser: cfg.CompactUser, |
| 527 | - | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, | 543 | + | CompactMode: cfg.CompactMode != nil && *cfg.CompactMode, |
| 544 | + | StartupContextPaths: startupContextPaths, | |||
| 528 | }, | 545 | }, | ||
| 529 | // Prompt is the standard half-block accent bar used by chat | 546 | // Prompt is the standard half-block accent bar used by chat | ||
| 530 | // speaker labels too, so the input gutter matches the rest | 547 | // speaker labels too, so the input gutter matches the rest | ||
| @@ -2836,19 +2853,27 @@ func (i *Interactive) Submit(text string) { | |||||
| 2836 | i.startTurn(i.runCtx, text) | 2853 | i.startTurn(i.runCtx, text) | ||
| 2837 | } | 2854 | } | ||
| 2838 | 2855 | ||||
| 2839 | - | // ApplyChangedCWD is called by the host after a successful /cd hook. | 2856 | + | // ApplyChangedCWD is called by hosts after a successful /cd hook that do |
| 2840 | - | // The host has already rebuilt the agent and opened a fresh session | 2857 | + | // not provide startup context metadata. |
| 2841 | - | // in the new cwd; this method swaps the fresh agent into the running | |||
| 2842 | - | // TUI, updates the displayed cwd, clears the transcript display | |||
| 2843 | - | // caches, and points the file picker at the new directory. | |||
| 2844 | - | // | |||
| 2845 | - | // The fresh agent's transcript is empty (new session) so the chat | |||
| 2846 | - | // view starts blank, matching what relaunching `zot --cwd <path>` | |||
| 2847 | - | // would show. Cost meters reset. | |||
| 2848 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | 2858 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | ||
| 2859 | + | i.applyChangedCWD(ag, provider, model, cwd, nil) | |||
| 2860 | + | } | |||
| 2861 | + | ||||
| 2862 | + | // ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only | |||
| 2863 | + | // startup context into the running TUI after a successful /cd hook. | |||
| 2864 | + | func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |||
| 2865 | + | i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths) | |||
| 2866 | + | } | |||
| 2867 | + | ||||
| 2868 | + | func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) { | |||
| 2849 | i.mu.Lock() | 2869 | i.mu.Lock() | ||
| 2850 | i.agent = ag | 2870 | i.agent = ag | ||
| 2851 | i.cfg.CWD = cwd | 2871 | i.cfg.CWD = cwd | ||
| 2872 | + | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |||
| 2873 | + | i.view.StartupContextPaths = nil | |||
| 2874 | + | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |||
| 2875 | + | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |||
| 2876 | + | } | |||
| 2852 | // Re-report the working directory to the terminal so "new tab here" | 2877 | // Re-report the working directory to the terminal so "new tab here" | ||
| 2853 | // tracks the /cd change (OSC 7, see issue #38). | 2878 | // tracks the /cd change (OSC 7, see issue #38). | ||
| 2854 | if i.cfg.Terminal != nil { | 2879 | if i.cfg.Terminal != nil { | ||
| @@ -3062,6 +3087,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3062 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | 3087 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 3063 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | 3088 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | ||
| 3064 | compactMode := i.compactModeEnabled() | 3089 | compactMode := i.compactModeEnabled() | ||
| 3090 | + | showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup | |||
| 3065 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | 3091 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | ||
| 3066 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | 3092 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | ||
| 3067 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | 3093 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | ||
| @@ -3191,6 +3217,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3191 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | 3217 | desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles", | ||
| 3192 | value: compactMode, | 3218 | value: compactMode, | ||
| 3193 | }, | 3219 | }, | ||
| 3220 | + | { | |||
| 3221 | + | key: "show_instructions_at_startup", | |||
| 3222 | + | label: "show loaded instructions at startup", | |||
| 3223 | + | desc: "list loaded AGENTS.md file paths above the transcript", | |||
| 3224 | + | value: showInstructions, | |||
| 3225 | + | }, | |||
| 3194 | { | 3226 | { | ||
| 3195 | key: "tui_settings", | 3227 | key: "tui_settings", | ||
| 3196 | label: "tui settings", | 3228 | label: "tui settings", | ||
| @@ -3557,6 +3589,25 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 3557 | i.statusOK = "compact transcript rendering " + onOff(value) | 3589 | i.statusOK = "compact transcript rendering " + onOff(value) | ||
| 3558 | i.statusErr = "" | 3590 | i.statusErr = "" | ||
| 3559 | i.mu.Unlock() | 3591 | i.mu.Unlock() | ||
| 3592 | + | case "show_instructions_at_startup": | |||
| 3593 | + | val := value | |||
| 3594 | + | i.cfg.ShowInstructionsAtStartup = &val | |||
| 3595 | + | if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok { | |||
| 3596 | + | if err := store.SetShowInstructionsAtStartup(value); err != nil { | |||
| 3597 | + | i.mu.Lock() | |||
| 3598 | + | i.statusErr = "settings: " + err.Error() | |||
| 3599 | + | i.mu.Unlock() | |||
| 3600 | + | return | |||
| 3601 | + | } | |||
| 3602 | + | } | |||
| 3603 | + | i.mu.Lock() | |||
| 3604 | + | i.view.StartupContextPaths = nil | |||
| 3605 | + | if value { | |||
| 3606 | + | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | |||
| 3607 | + | } | |||
| 3608 | + | i.statusOK = "show loaded instructions at startup " + onOff(value) | |||
| 3609 | + | i.statusErr = "" | |||
| 3610 | + | i.mu.Unlock() | |||
| 3560 | } | 3611 | } | ||
| 3561 | } | 3612 | } | ||
| 3562 | 3613 | ||||
A packages/agent/modes/startup_instructions_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestStartupInstructionsAreOptIn(t *testing.T) { | |
| 6 | + | paths := []string{"/repo/AGENTS.md"} | |
| 7 | + | ||
| 8 | + | disabled := NewInteractive(InteractiveConfig{StartupContextPaths: paths}) | |
| 9 | + | disabled.rend = nil | |
| 10 | + | if len(disabled.view.StartupContextPaths) != 0 { | |
| 11 | + | t.Fatalf("default startup rendered %d instruction paths", len(disabled.view.StartupContextPaths)) | |
| 12 | + | } | |
| 13 | + | disabled.applySettingToggle("show_instructions_at_startup", true) | |
| 14 | + | if len(disabled.view.StartupContextPaths) != 1 { | |
| 15 | + | t.Fatalf("live toggle rendered %d instruction paths, want 1", len(disabled.view.StartupContextPaths)) | |
| 16 | + | } | |
| 17 | + | disabled.applySettingToggle("show_instructions_at_startup", false) | |
| 18 | + | if len(disabled.view.StartupContextPaths) != 0 { | |
| 19 | + | t.Fatalf("disabled live toggle left %d instruction paths", len(disabled.view.StartupContextPaths)) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | enabledValue := true | |
| 23 | + | enabled := NewInteractive(InteractiveConfig{ | |
| 24 | + | StartupContextPaths: paths, | |
| 25 | + | ShowInstructionsAtStartup: &enabledValue, | |
| 26 | + | }) | |
| 27 | + | if len(enabled.view.StartupContextPaths) != 1 { | |
| 28 | + | t.Fatalf("enabled startup rendered %d instruction paths, want 1", len(enabled.view.StartupContextPaths)) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestStartupInstructionsAreOptIn(t *testing.T) { | |||
| 6 | + | paths := []string{"/repo/AGENTS.md"} | |||
| 7 | + | ||||
| 8 | + | disabled := NewInteractive(InteractiveConfig{StartupContextPaths: paths}) | |||
| 9 | + | disabled.rend = nil | |||
| 10 | + | if len(disabled.view.StartupContextPaths) != 0 { | |||
| 11 | + | t.Fatalf("default startup rendered %d instruction paths", len(disabled.view.StartupContextPaths)) | |||
| 12 | + | } | |||
| 13 | + | disabled.applySettingToggle("show_instructions_at_startup", true) | |||
| 14 | + | if len(disabled.view.StartupContextPaths) != 1 { | |||
| 15 | + | t.Fatalf("live toggle rendered %d instruction paths, want 1", len(disabled.view.StartupContextPaths)) | |||
| 16 | + | } | |||
| 17 | + | disabled.applySettingToggle("show_instructions_at_startup", false) | |||
| 18 | + | if len(disabled.view.StartupContextPaths) != 0 { | |||
| 19 | + | t.Fatalf("disabled live toggle left %d instruction paths", len(disabled.view.StartupContextPaths)) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | enabledValue := true | |||
| 23 | + | enabled := NewInteractive(InteractiveConfig{ | |||
| 24 | + | StartupContextPaths: paths, | |||
| 25 | + | ShowInstructionsAtStartup: &enabledValue, | |||
| 26 | + | }) | |||
| 27 | + | if len(enabled.view.StartupContextPaths) != 1 { | |||
| 28 | + | t.Fatalf("enabled startup rendered %d instruction paths, want 1", len(enabled.view.StartupContextPaths)) | |||
| 29 | + | } | |||
| 30 | + | } | |||
M packages/agent/settings_store.go +9 -0
| @@ -86,6 +86,15 @@ func (configSettingsStore) SetCompactMode(enabled bool) error { | |||
| 86 | 86 | return SaveConfig(cfg) | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error { | |
| 90 | + | cfg, err := LoadConfig() | |
| 91 | + | if err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | cfg.ShowInstructionsAtStartup = &enabled | |
| 95 | + | return SaveConfig(cfg) | |
| 96 | + | } | |
| 97 | + | ||
| 89 | 98 | func (configSettingsStore) SetTUIInputStyle(style string) error { | |
| 90 | 99 | cfg, err := LoadConfig() | |
| 91 | 100 | if err != nil { | |
| @@ -86,6 +86,15 @@ func (configSettingsStore) SetCompactMode(enabled bool) error { | |||||
| 86 | return SaveConfig(cfg) | 86 | return SaveConfig(cfg) | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | + | func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error { | |||
| 90 | + | cfg, err := LoadConfig() | |||
| 91 | + | if err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | cfg.ShowInstructionsAtStartup = &enabled | |||
| 95 | + | return SaveConfig(cfg) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 89 | func (configSettingsStore) SetTUIInputStyle(style string) error { | 98 | func (configSettingsStore) SetTUIInputStyle(style string) error { | ||
| 90 | cfg, err := LoadConfig() | 99 | cfg, err := LoadConfig() | ||
| 91 | if err != nil { | 100 | if err != nil { | ||
M packages/agent/settings_store_test.go +21 -0
| @@ -2,6 +2,27 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | + | func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | |
| 6 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 7 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 8 | + | t.Fatal(err) | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | if err := (configSettingsStore{}).SetShowInstructionsAtStartup(true); err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | cfg, err := LoadConfig() | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | if cfg.ShowInstructionsAtStartup == nil || !*cfg.ShowInstructionsAtStartup { | |
| 19 | + | t.Fatal("show_instructions_at_startup was not persisted as enabled") | |
| 20 | + | } | |
| 21 | + | if cfg.Theme != "dark" { | |
| 22 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 5 | 26 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | |
| 6 | 27 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 7 | 28 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| @@ -2,6 +2,27 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | + | func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) { | |||
| 6 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 7 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 8 | + | t.Fatal(err) | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | if err := (configSettingsStore{}).SetShowInstructionsAtStartup(true); err != nil { | |||
| 12 | + | t.Fatal(err) | |||
| 13 | + | } | |||
| 14 | + | cfg, err := LoadConfig() | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatal(err) | |||
| 17 | + | } | |||
| 18 | + | if cfg.ShowInstructionsAtStartup == nil || !*cfg.ShowInstructionsAtStartup { | |||
| 19 | + | t.Fatal("show_instructions_at_startup was not persisted as enabled") | |||
| 20 | + | } | |||
| 21 | + | if cfg.Theme != "dark" { | |||
| 22 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 5 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | 26 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | ||
| 6 | t.Setenv("ZOT_HOME", t.TempDir()) | 27 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 7 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | 28 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | ||
M packages/tui/view.go +22 -2
| @@ -122,8 +122,12 @@ type View struct { | |||
| 122 | 122 | Streaming string // current assistant text delta | |
| 123 | 123 | StreamingActive bool | |
| 124 | 124 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| 125 | - | StatusLine string | |
| 126 | - | Err string | |
| 125 | + | ||
| 126 | + | // StartupContextPaths lists host-loaded instruction files before the | |
| 127 | + | // transcript without creating provider messages or persisted entries. | |
| 128 | + | StartupContextPaths []string | |
| 129 | + | StatusLine string | |
| 130 | + | Err string | |
| 127 | 131 | ||
| 128 | 132 | // liveBodyHigh tracks the tallest live-preview body height seen | |
| 129 | 133 | // per tool-call id, so a streaming edit/write/bash box never | |
| @@ -323,6 +327,21 @@ func (v *View) renderErr(width int) []string { | |||
| 323 | 327 | return out | |
| 324 | 328 | } | |
| 325 | 329 | ||
| 330 | + | func (v *View) renderStartupContext(width int) []string { | |
| 331 | + | if len(v.StartupContextPaths) == 0 { | |
| 332 | + | return nil | |
| 333 | + | } | |
| 334 | + | bodyWidth := width - 2 | |
| 335 | + | if bodyWidth < 8 { | |
| 336 | + | bodyWidth = 8 | |
| 337 | + | } | |
| 338 | + | out := []string{v.Theme.FG256(v.Theme.Accent, "[Context]")} | |
| 339 | + | for _, line := range wrapLine(strings.Join(v.StartupContextPaths, ", "), bodyWidth, "") { | |
| 340 | + | out = append(out, v.Theme.FG256(v.Theme.Muted, " "+line)) | |
| 341 | + | } | |
| 342 | + | return append(out, "") | |
| 343 | + | } | |
| 344 | + | ||
| 326 | 345 | // BuildWithAnchors is like Build but additionally reports the first | |
| 327 | 346 | // row occupied by each message in v.Messages. Callers that need to | |
| 328 | 347 | // scroll to a specific turn (the /jump dialog) use the anchor slice | |
| @@ -396,6 +415,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 396 | 415 | } | |
| 397 | 416 | ||
| 398 | 417 | out := make([]string, 0, total+16) | |
| 418 | + | out = append(out, v.renderStartupContext(width)...) | |
| 399 | 419 | anchors := make([]MessageAnchor, 0, len(v.Messages)) | |
| 400 | 420 | for idx := range v.Messages { | |
| 401 | 421 | anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)}) | |
| @@ -122,8 +122,12 @@ type View struct { | |||||
| 122 | Streaming string // current assistant text delta | 122 | Streaming string // current assistant text delta | ||
| 123 | StreamingActive bool | 123 | StreamingActive bool | ||
| 124 | ToolCalls []ToolCallView // tool calls in flight or completed | 124 | ToolCalls []ToolCallView // tool calls in flight or completed | ||
| 125 | - | StatusLine string | 125 | + | |
| 126 | - | Err string | 126 | + | // StartupContextPaths lists host-loaded instruction files before the |
| 127 | + | // transcript without creating provider messages or persisted entries. | |||
| 128 | + | StartupContextPaths []string | |||
| 129 | + | StatusLine string | |||
| 130 | + | Err string | |||
| 127 | 131 | ||||
| 128 | // liveBodyHigh tracks the tallest live-preview body height seen | 132 | // liveBodyHigh tracks the tallest live-preview body height seen | ||
| 129 | // per tool-call id, so a streaming edit/write/bash box never | 133 | // per tool-call id, so a streaming edit/write/bash box never | ||
| @@ -323,6 +327,21 @@ func (v *View) renderErr(width int) []string { | |||||
| 323 | return out | 327 | return out | ||
| 324 | } | 328 | } | ||
| 325 | 329 | ||||
| 330 | + | func (v *View) renderStartupContext(width int) []string { | |||
| 331 | + | if len(v.StartupContextPaths) == 0 { | |||
| 332 | + | return nil | |||
| 333 | + | } | |||
| 334 | + | bodyWidth := width - 2 | |||
| 335 | + | if bodyWidth < 8 { | |||
| 336 | + | bodyWidth = 8 | |||
| 337 | + | } | |||
| 338 | + | out := []string{v.Theme.FG256(v.Theme.Accent, "[Context]")} | |||
| 339 | + | for _, line := range wrapLine(strings.Join(v.StartupContextPaths, ", "), bodyWidth, "") { | |||
| 340 | + | out = append(out, v.Theme.FG256(v.Theme.Muted, " "+line)) | |||
| 341 | + | } | |||
| 342 | + | return append(out, "") | |||
| 343 | + | } | |||
| 344 | + | ||||
| 326 | // BuildWithAnchors is like Build but additionally reports the first | 345 | // BuildWithAnchors is like Build but additionally reports the first | ||
| 327 | // row occupied by each message in v.Messages. Callers that need to | 346 | // row occupied by each message in v.Messages. Callers that need to | ||
| 328 | // scroll to a specific turn (the /jump dialog) use the anchor slice | 347 | // scroll to a specific turn (the /jump dialog) use the anchor slice | ||
| @@ -396,6 +415,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 396 | } | 415 | } | ||
| 397 | 416 | ||||
| 398 | out := make([]string, 0, total+16) | 417 | out := make([]string, 0, total+16) | ||
| 418 | + | out = append(out, v.renderStartupContext(width)...) | |||
| 399 | anchors := make([]MessageAnchor, 0, len(v.Messages)) | 419 | anchors := make([]MessageAnchor, 0, len(v.Messages)) | ||
| 400 | for idx := range v.Messages { | 420 | for idx := range v.Messages { | ||
| 401 | anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)}) | 421 | anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)}) | ||
M packages/tui/view_tool_overlay_test.go +22 -0
| @@ -8,6 +8,28 @@ import ( | |||
| 8 | 8 | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | func TestStartupContextRendersOnlyLoadedPaths(t *testing.T) { | |
| 12 | + | v := View{ | |
| 13 | + | Theme: Dark, | |
| 14 | + | StartupContextPaths: []string{ | |
| 15 | + | "/home/user/AGENTS.md", | |
| 16 | + | "/repo/AGENTS.md", | |
| 17 | + | }, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | plain := stripANSI(strings.Join(v.Build(80), "\n")) | |
| 21 | + | if !strings.Contains(plain, "[Context]") || | |
| 22 | + | !strings.Contains(plain, "/home/user/AGENTS.md, /repo/AGENTS.md") { | |
| 23 | + | t.Fatalf("startup instruction paths were not rendered:\n%s", plain) | |
| 24 | + | } | |
| 25 | + | if strings.ContainsAny(plain, boxGlyphs) || strings.Contains(plain, "read ") { | |
| 26 | + | t.Fatalf("startup instruction paths rendered as a tool call:\n%s", plain) | |
| 27 | + | } | |
| 28 | + | if len(v.Messages) != 0 { | |
| 29 | + | t.Fatalf("startup context added %d transcript messages", len(v.Messages)) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 11 | 33 | func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) { | |
| 12 | 34 | args := json.RawMessage(`{"command":"sleep 1"}`) | |
| 13 | 35 | v := View{ | |
| @@ -8,6 +8,28 @@ import ( | |||||
| 8 | "github.com/patriceckhart/zot/packages/provider" | 8 | "github.com/patriceckhart/zot/packages/provider" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | func TestStartupContextRendersOnlyLoadedPaths(t *testing.T) { | |||
| 12 | + | v := View{ | |||
| 13 | + | Theme: Dark, | |||
| 14 | + | StartupContextPaths: []string{ | |||
| 15 | + | "/home/user/AGENTS.md", | |||
| 16 | + | "/repo/AGENTS.md", | |||
| 17 | + | }, | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | plain := stripANSI(strings.Join(v.Build(80), "\n")) | |||
| 21 | + | if !strings.Contains(plain, "[Context]") || | |||
| 22 | + | !strings.Contains(plain, "/home/user/AGENTS.md, /repo/AGENTS.md") { | |||
| 23 | + | t.Fatalf("startup instruction paths were not rendered:\n%s", plain) | |||
| 24 | + | } | |||
| 25 | + | if strings.ContainsAny(plain, boxGlyphs) || strings.Contains(plain, "read ") { | |||
| 26 | + | t.Fatalf("startup instruction paths rendered as a tool call:\n%s", plain) | |||
| 27 | + | } | |||
| 28 | + | if len(v.Messages) != 0 { | |||
| 29 | + | t.Fatalf("startup context added %d transcript messages", len(v.Messages)) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 11 | func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) { | 33 | func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) { | ||
| 12 | args := json.RawMessage(`{"command":"sleep 1"}`) | 34 | args := json.RawMessage(`{"command":"sleep 1"}`) | ||
| 13 | v := View{ | 35 | v := View{ | ||