🛟 Updated.
b010eb1 parent: ad83d0e modified
.tickets/issues/0141-for-the-decision-agent-deactivate-automatically.yaml +22 -1 | @@ -10,6 +10,27 @@ updatedAt: 2026-10-24T05:19:45.037Z | ||
| 10 | 10 | labels: |
| 11 | 11 | - priority::urgent |
| 12 | 12 | milestone: v1.0.1 |
| 13 | -body: "" | |
| 13 | +body: | | |
| 14 | + A decider (`decision:` block) never calls a tool, yet today every decider | |
| 15 | + file must repeat `bashTool/editTools/planTool/askUserTool/fetchContentTool: | |
| 16 | + false` and point `skillsDir` at an empty folder, or `config.DecisionConflict` | |
| 17 | + refuses to start (default `skillsDir: skills` in a folder with skills = refused). | |
| 18 | + | |
| 19 | + Decision (2026-10-25): | |
| 20 | + - Built-in tools are turned off AUTOMATICALLY for a decider, silently: | |
| 21 | + bash, read_file/write_file/edit_file, plan, ask_user, fetch_content, | |
| 22 | + read_skill, search_docs, search_memory, save_answer (unload_model already is). | |
| 23 | + - What the user declares EXPLICITLY keeps the current error: mcp servers, | |
| 24 | + lsp servers, mmConfigs (ask_agent and /ask-<n>), `tools:` (declared/wasm). | |
| 25 | + - Done in `assembleTools` (main.go), next to the existing unload_model skip — | |
| 26 | + not in config (AGENT_ASK_USER would re-enable ask_user after a config-side | |
| 27 | + force). MCP/LSP servers are not started for a decider. | |
| 28 | + | |
| 29 | + Done when: a decider file with only provider/model/decision starts with no | |
| 30 | + tool; one with mcp/lsp/mmConfigs/tools: still stops with the error; tests | |
| 31 | + updated (TestDecisionConflictListsTheDeclaredTools, | |
| 32 | + TestADeciderDeclaresNoUnloadTool); docs EN+FR (reference/configuration, | |
| 33 | + how-to/ask-a-decision-model) no longer ask for the `…: false` lines; demos | |
| 34 | + cleaned up. | |
| 14 | 35 | tasks: [] |
| 15 | 36 | comments: [] |
| @@ -10,6 +10,27 @@ updatedAt: 2026-10-24T05:19:45.037Z | |||
| 10 | labels: | 10 | labels: |
| 11 | - priority::urgent | 11 | - priority::urgent |
| 12 | milestone: v1.0.1 | 12 | milestone: v1.0.1 |
| 13 | -body: "" | 13 | +body: | |
| 14 | + A decider (`decision:` block) never calls a tool, yet today every decider | ||
| 15 | + file must repeat `bashTool/editTools/planTool/askUserTool/fetchContentTool: | ||
| 16 | + false` and point `skillsDir` at an empty folder, or `config.DecisionConflict` | ||
| 17 | + refuses to start (default `skillsDir: skills` in a folder with skills = refused). | ||
| 18 | + | ||
| 19 | + Decision (2026-10-25): | ||
| 20 | + - Built-in tools are turned off AUTOMATICALLY for a decider, silently: | ||
| 21 | + bash, read_file/write_file/edit_file, plan, ask_user, fetch_content, | ||
| 22 | + read_skill, search_docs, search_memory, save_answer (unload_model already is). | ||
| 23 | + - What the user declares EXPLICITLY keeps the current error: mcp servers, | ||
| 24 | + lsp servers, mmConfigs (ask_agent and /ask-<n>), `tools:` (declared/wasm). | ||
| 25 | + - Done in `assembleTools` (main.go), next to the existing unload_model skip — | ||
| 26 | + not in config (AGENT_ASK_USER would re-enable ask_user after a config-side | ||
| 27 | + force). MCP/LSP servers are not started for a decider. | ||
| 28 | + | ||
| 29 | + Done when: a decider file with only provider/model/decision starts with no | ||
| 30 | + tool; one with mcp/lsp/mmConfigs/tools: still stops with the error; tests | ||
| 31 | + updated (TestDecisionConflictListsTheDeclaredTools, | ||
| 32 | + TestADeciderDeclaresNoUnloadTool); docs EN+FR (reference/configuration, | ||
| 33 | + how-to/ask-a-decision-model) no longer ask for the `…: false` lines; demos | ||
| 34 | + cleaned up. | ||
| 14 | tasks: [] | 35 | tasks: [] |
| 15 | comments: [] | 36 | comments: [] |
modified
.tickets/issues/0144-when-you-use-memory-how-to-use-the-result.yaml +2 -2 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 144 |
| 3 | 3 | title: When you use /memory how to use the result |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-24T16:59:56.623Z |
| 9 | -updatedAt: 2026-10-24T17:30:00.000Z | |
| 9 | +updatedAt: 2026-10-25T16:50:11.990Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v1.0.1 |
| 12 | 12 | related: |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 144 | 2 | id: 144 |
| 3 | title: When you use /memory how to use the result | 3 | title: When you use /memory how to use the result |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-24T16:59:56.623Z | 8 | createdAt: 2026-10-24T16:59:56.623Z |
| 9 | -updatedAt: 2026-10-24T17:30:00.000Z | 9 | +updatedAt: 2026-10-25T16:50:11.990Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v1.0.1 | 11 | milestone: v1.0.1 |
| 12 | related: | 12 | related: |
added
.tickets/issues/0156-docsearch-dirs-relative-to-the-agent-file.yaml +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 156 | |
| 3 | +title: "docSearch dirs: allow a path relative to the agent file (@agent/ prefix)" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-25T13:38:47.000Z | |
| 9 | +updatedAt: 2026-10-25T13:38:47.000Z | |
| 10 | +labels: | |
| 11 | + - enhancement | |
| 12 | + - kind::feature | |
| 13 | + - builtin tool | |
| 14 | +body: |+ | |
| 15 | + ## Problème | |
| 16 | + | |
| 17 | + Dans `learning-rust/`, mm tourne dans `workspace/` (le dossier ouvert par Zed) et lit son agent depuis `agent-config/agent.yaml` (`AGENT_CONFIG`, workspace additionnel en lecture seule). La doc à chercher est livrée avec l'agent, dans `agent-config/docs`. | |
| 18 | + | |
| 19 | + Avec `docSearch.dirs: [docs]`, `search_docs` ne trouve rien : un chemin relatif de `docSearch.dirs` est résolu par rapport au **répertoire de travail** (`internal/docsearch/walk.go`, documenté dans `internal/config/docsearch.go`), pas par rapport au fichier d'agent. Vérifié : depuis `workspace/`, `docs` → 0 fichier (missing), chemin absolu → 10 fichiers. | |
| 20 | + | |
| 21 | + Contournement actuel : un chemin absolu dans `agent.yaml` (marche parce que le sandbox monte les dossiers au même chemin que sur l'hôte), qui casse si on déplace le dossier. | |
| 22 | + | |
| 23 | + ## Ce qui existe déjà | |
| 24 | + | |
| 25 | + mm ancre déjà certains chemins relatifs sur le dossier du fichier d'agent, au chargement (`internal/config/config.go`, ~l. 715) : `resolveToolPaths` (modules wasm), `resolveAgentPaths` (`mmConfigs`), `resolveHooks`. `docSearch.dirs` est le seul à suivre le répertoire de travail. La config ne fait aucune expansion de variable d'environnement. | |
| 26 | + | |
| 27 | + ## Proposition (recommandée) : un préfixe explicite `@agent/` | |
| 28 | + | |
| 29 | + ```yaml | |
| 30 | + docSearch: | |
| 31 | + dirs: ["@agent/docs", docs] # la doc livrée avec l'agent + celle du projet courant | |
| 32 | + ``` | |
| 33 | + | |
| 34 | + - `@agent/<chemin>` → résolu en absolu sur le dossier du fichier d'agent, au chargement (`resolveDocSearchPaths(path)` à côté des autres `resolve*`). | |
| 35 | + - Sans préfixe : comportement inchangé (répertoire de travail). Chemin absolu : inchangé. | |
| 36 | + - Rien ne casse, et les deux sources se combinent. | |
| 37 | + | |
| 38 | + Alternative écartée : résoudre tous les chemins relatifs sur le dossier de l'agent — casse un `agent.yaml` partagé dont `dirs: [docs]` veut dire « le docs/ du projet où je suis ». | |
| 39 | + | |
| 40 | + ## À décider | |
| 41 | + | |
| 42 | + - Le nom du préfixe (`@agent/`, `agent:`…). Les guillemets sont obligatoires en YAML avec `@`. | |
| 43 | + - Quel nom afficher dans les résultats et dans « missing » : l'entrée telle qu'écrite ou le chemin résolu. | |
| 44 | + | |
| 45 | + ## Fait quand | |
| 46 | + | |
| 47 | + - Résolution au chargement + tests (relatif, préfixé, absolu, dossier manquant). | |
| 48 | + - Commentaire de `DocSearchConfig` et doc de référence EN + FR à jour. | |
| 49 | + - `learning-rust/agent-config/agent.yaml` repasse en `"@agent/docs"`. | |
| 50 | +tasks: | |
| 51 | + - id: 1 | |
| 52 | + title: Resolve "@agent/" entries of docSearch.dirs against the agent file's folder at load time | |
| 53 | + state: open | |
| 54 | + priority: none | |
| 55 | + author: | |
| 56 | + name: k33g | |
| 57 | + email: ph.charriere@gmail.com | |
| 58 | + createdAt: 2026-10-25T13:38:47.000Z | |
| 59 | + - id: 2 | |
| 60 | + title: Tests (relative, prefixed, absolute, missing folder) | |
| 61 | + state: open | |
| 62 | + priority: none | |
| 63 | + author: | |
| 64 | + name: k33g | |
| 65 | + email: ph.charriere@gmail.com | |
| 66 | + createdAt: 2026-10-25T13:38:47.000Z | |
| 67 | + - id: 3 | |
| 68 | + title: Docs EN + FR (DocSearchConfig comment, reference) | |
| 69 | + state: open | |
| 70 | + priority: none | |
| 71 | + author: | |
| 72 | + name: k33g | |
| 73 | + email: ph.charriere@gmail.com | |
| 74 | + createdAt: 2026-10-25T13:38:47.000Z | |
| 75 | + - id: 4 | |
| 76 | + title: Switch learning-rust/agent-config/agent.yaml back to a relative path | |
| 77 | + state: open | |
| 78 | + priority: none | |
| 79 | + author: | |
| 80 | + name: k33g | |
| 81 | + email: ph.charriere@gmail.com | |
| 82 | + createdAt: 2026-10-25T13:38:47.000Z | |
| 83 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 156 | ||
| 3 | +title: "docSearch dirs: allow a path relative to the agent file (@agent/ prefix)" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-25T13:38:47.000Z | ||
| 9 | +updatedAt: 2026-10-25T13:38:47.000Z | ||
| 10 | +labels: | ||
| 11 | + - enhancement | ||
| 12 | + - kind::feature | ||
| 13 | + - builtin tool | ||
| 14 | +body: |+ | ||
| 15 | + ## Problème | ||
| 16 | + | ||
| 17 | + Dans `learning-rust/`, mm tourne dans `workspace/` (le dossier ouvert par Zed) et lit son agent depuis `agent-config/agent.yaml` (`AGENT_CONFIG`, workspace additionnel en lecture seule). La doc à chercher est livrée avec l'agent, dans `agent-config/docs`. | ||
| 18 | + | ||
| 19 | + Avec `docSearch.dirs: [docs]`, `search_docs` ne trouve rien : un chemin relatif de `docSearch.dirs` est résolu par rapport au **répertoire de travail** (`internal/docsearch/walk.go`, documenté dans `internal/config/docsearch.go`), pas par rapport au fichier d'agent. Vérifié : depuis `workspace/`, `docs` → 0 fichier (missing), chemin absolu → 10 fichiers. | ||
| 20 | + | ||
| 21 | + Contournement actuel : un chemin absolu dans `agent.yaml` (marche parce que le sandbox monte les dossiers au même chemin que sur l'hôte), qui casse si on déplace le dossier. | ||
| 22 | + | ||
| 23 | + ## Ce qui existe déjà | ||
| 24 | + | ||
| 25 | + mm ancre déjà certains chemins relatifs sur le dossier du fichier d'agent, au chargement (`internal/config/config.go`, ~l. 715) : `resolveToolPaths` (modules wasm), `resolveAgentPaths` (`mmConfigs`), `resolveHooks`. `docSearch.dirs` est le seul à suivre le répertoire de travail. La config ne fait aucune expansion de variable d'environnement. | ||
| 26 | + | ||
| 27 | + ## Proposition (recommandée) : un préfixe explicite `@agent/` | ||
| 28 | + | ||
| 29 | + ```yaml | ||
| 30 | + docSearch: | ||
| 31 | + dirs: ["@agent/docs", docs] # la doc livrée avec l'agent + celle du projet courant | ||
| 32 | + ``` | ||
| 33 | + | ||
| 34 | + - `@agent/<chemin>` → résolu en absolu sur le dossier du fichier d'agent, au chargement (`resolveDocSearchPaths(path)` à côté des autres `resolve*`). | ||
| 35 | + - Sans préfixe : comportement inchangé (répertoire de travail). Chemin absolu : inchangé. | ||
| 36 | + - Rien ne casse, et les deux sources se combinent. | ||
| 37 | + | ||
| 38 | + Alternative écartée : résoudre tous les chemins relatifs sur le dossier de l'agent — casse un `agent.yaml` partagé dont `dirs: [docs]` veut dire « le docs/ du projet où je suis ». | ||
| 39 | + | ||
| 40 | + ## À décider | ||
| 41 | + | ||
| 42 | + - Le nom du préfixe (`@agent/`, `agent:`…). Les guillemets sont obligatoires en YAML avec `@`. | ||
| 43 | + - Quel nom afficher dans les résultats et dans « missing » : l'entrée telle qu'écrite ou le chemin résolu. | ||
| 44 | + | ||
| 45 | + ## Fait quand | ||
| 46 | + | ||
| 47 | + - Résolution au chargement + tests (relatif, préfixé, absolu, dossier manquant). | ||
| 48 | + - Commentaire de `DocSearchConfig` et doc de référence EN + FR à jour. | ||
| 49 | + - `learning-rust/agent-config/agent.yaml` repasse en `"@agent/docs"`. | ||
| 50 | +tasks: | ||
| 51 | + - id: 1 | ||
| 52 | + title: Resolve "@agent/" entries of docSearch.dirs against the agent file's folder at load time | ||
| 53 | + state: open | ||
| 54 | + priority: none | ||
| 55 | + author: | ||
| 56 | + name: k33g | ||
| 57 | + email: ph.charriere@gmail.com | ||
| 58 | + createdAt: 2026-10-25T13:38:47.000Z | ||
| 59 | + - id: 2 | ||
| 60 | + title: Tests (relative, prefixed, absolute, missing folder) | ||
| 61 | + state: open | ||
| 62 | + priority: none | ||
| 63 | + author: | ||
| 64 | + name: k33g | ||
| 65 | + email: ph.charriere@gmail.com | ||
| 66 | + createdAt: 2026-10-25T13:38:47.000Z | ||
| 67 | + - id: 3 | ||
| 68 | + title: Docs EN + FR (DocSearchConfig comment, reference) | ||
| 69 | + state: open | ||
| 70 | + priority: none | ||
| 71 | + author: | ||
| 72 | + name: k33g | ||
| 73 | + email: ph.charriere@gmail.com | ||
| 74 | + createdAt: 2026-10-25T13:38:47.000Z | ||
| 75 | + - id: 4 | ||
| 76 | + title: Switch learning-rust/agent-config/agent.yaml back to a relative path | ||
| 77 | + state: open | ||
| 78 | + priority: none | ||
| 79 | + author: | ||
| 80 | + name: k33g | ||
| 81 | + email: ph.charriere@gmail.com | ||
| 82 | + createdAt: 2026-10-25T13:38:47.000Z | ||
| 83 | +comments: [] | ||
modified
decision_wiring_test.go +65 -5 | @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | "mm/internal/config" |
| 17 | 17 | "mm/internal/decide" |
| 18 | 18 | "mm/internal/engine" |
| 19 | + "mm/internal/skills" | |
| 19 | 20 | ) |
| 20 | 21 | |
| 21 | 22 | const deciderAnswer = `{"model":"d1","answers":{"destructive":{"type":"noul","noul":0.91}}}` |
| @@ -74,16 +75,75 @@ func TestAFailedDecisionPromptExitsOneWithTheReasonOnStderr(t *testing.T) { | ||
| 74 | 75 | } |
| 75 | 76 | } |
| 76 | 77 | |
| 77 | -// A decider declares no tool; unload_model is on by default and would make | |
| 78 | -// DecisionConflict refuse every decider that does not turn it off. | |
| 79 | -func TestADeciderDeclaresNoUnloadTool(t *testing.T) { | |
| 78 | +// Ticket 141: a decider gets no tool and no skill catalogue, even with every | |
| 79 | +// built-in switched on and a skills directory that holds a skill — its file | |
| 80 | +// needs no `bashTool: false` lines. | |
| 81 | +func TestADeciderGetsNoToolWhateverTheBuiltInSwitchesSay(t *testing.T) { | |
| 82 | + skillsDir := t.TempDir() | |
| 83 | + if err := os.WriteFile(filepath.Join(skillsDir, "go-rename.md"), []byte("---\nname: go-rename\ndescription: rename a symbol\n---\n"), 0o644); err != nil { | |
| 84 | + t.Fatal(err) | |
| 85 | + } | |
| 80 | 86 | withConfig(t, func(c *config.Config) { |
| 81 | - c.BashTool, c.EditTools, c.PlanTool, c.FetchContentTool, c.AskUserTool = false, false, false, false, false | |
| 87 | + c.BashTool, c.EditTools, c.PlanTool, c.AskUserTool = true, true, true, true | |
| 88 | + c.FetchContentTool, c.SaveAnswerTool, c.UnloadModels = true, true, true | |
| 82 | 89 | c.Decision = &config.DecisionConfig{} |
| 83 | 90 | }) |
| 84 | - if names := assembledNames(t); slices.Contains(names, "unload_model") || config.DecisionConflict(names) != nil { | |
| 91 | + config.Cfg.SkillsDir = skillsDir | |
| 92 | + if names := assembledNames(t); len(names) != 0 { | |
| 85 | 93 | t.Errorf("a decider declares %v", names) |
| 86 | 94 | } |
| 95 | + if declared := skills.Declared(); len(declared) != 0 { | |
| 96 | + t.Errorf("a decider declares the skills %v", declared) | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +// The same switches on a chat agent do declare tools: the short-circuit is | |
| 101 | +// for deciders only. | |
| 102 | +func TestAChatAgentStillGetsItsBuiltInTools(t *testing.T) { | |
| 103 | + withConfig(t, func(c *config.Config) { c.BashTool, c.PlanTool = true, true }) | |
| 104 | + if names := assembledNames(t); !slices.Contains(names, "bash") || !slices.Contains(names, "plan") { | |
| 105 | + t.Errorf("a chat agent declares %v", names) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +// Ticket 141: the decider an agent routes with is not the running agent. | |
| 110 | +// A chat agent whose skillRouting.decider is a decider file written the new | |
| 111 | +// way (no `bashTool: false` lines) keeps every tool and its skill catalogue, | |
| 112 | +// in the order main runs the checks. | |
| 113 | +func TestAChatAgentRoutingThroughADeciderKeepsItsTools(t *testing.T) { | |
| 114 | + dir := t.TempDir() | |
| 115 | + decider := filepath.Join(dir, "agent.decider.yaml") | |
| 116 | + body := "provider: llmman\nmodel: d1\nbaseUrl: http://127.0.0.1:1/v1\nfallback: \"\"\n" + | |
| 117 | + "decision:\n questions:\n destructive:\n type: noul\n instructions: It deletes data.\n" | |
| 118 | + if err := os.WriteFile(decider, []byte(body), 0o644); err != nil { | |
| 119 | + t.Fatal(err) | |
| 120 | + } | |
| 121 | + skillsDir := t.TempDir() | |
| 122 | + if err := os.WriteFile(filepath.Join(skillsDir, "go-rename.md"), []byte("---\nname: go-rename\ndescription: rename a symbol\n---\n"), 0o644); err != nil { | |
| 123 | + t.Fatal(err) | |
| 124 | + } | |
| 125 | + withConfig(t, func(c *config.Config) { | |
| 126 | + c.BashTool, c.EditTools, c.PlanTool = true, true, true | |
| 127 | + c.SkillRouting.Decider = decider | |
| 128 | + }) | |
| 129 | + config.Cfg.SkillsDir = skillsDir | |
| 130 | + t.Cleanup(func() { decide.SetRouter(nil); routing = "" }) | |
| 131 | + | |
| 132 | + if err := config.DecisionConflict(); err != nil { | |
| 133 | + t.Fatal(err) | |
| 134 | + } | |
| 135 | + names := assembledNames(t) | |
| 136 | + if err := installRouter(); err != nil { | |
| 137 | + t.Fatal(err) | |
| 138 | + } | |
| 139 | + for _, want := range []string{"bash", "read_file", "edit_file", "plan", "read_skill"} { | |
| 140 | + if !slices.Contains(names, want) { | |
| 141 | + t.Errorf("the chat agent lost %s: %v", want, names) | |
| 142 | + } | |
| 143 | + } | |
| 144 | + if len(skills.Declared()) != 1 || config.Cfg.Decision != nil { | |
| 145 | + t.Errorf("skills = %v, Decision = %v", skills.Declared(), config.Cfg.Decision) | |
| 146 | + } | |
| 87 | 147 | } |
| 88 | 148 | |
| 89 | 149 | func TestInstallRouterReadsTheDeciderAndNamesItInTheBanner(t *testing.T) { |
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | "mm/internal/config" | 16 | "mm/internal/config" |
| 17 | "mm/internal/decide" | 17 | "mm/internal/decide" |
| 18 | "mm/internal/engine" | 18 | "mm/internal/engine" |
| 19 | + "mm/internal/skills" | ||
| 19 | ) | 20 | ) |
| 20 | 21 | ||
| 21 | const deciderAnswer = `{"model":"d1","answers":{"destructive":{"type":"noul","noul":0.91}}}` | 22 | const deciderAnswer = `{"model":"d1","answers":{"destructive":{"type":"noul","noul":0.91}}}` |
| @@ -74,16 +75,75 @@ func TestAFailedDecisionPromptExitsOneWithTheReasonOnStderr(t *testing.T) { | |||
| 74 | } | 75 | } |
| 75 | } | 76 | } |
| 76 | 77 | ||
| 77 | -// A decider declares no tool; unload_model is on by default and would make | 78 | +// Ticket 141: a decider gets no tool and no skill catalogue, even with every |
| 78 | -// DecisionConflict refuse every decider that does not turn it off. | 79 | +// built-in switched on and a skills directory that holds a skill — its file |
| 79 | -func TestADeciderDeclaresNoUnloadTool(t *testing.T) { | 80 | +// needs no `bashTool: false` lines. |
| 81 | +func TestADeciderGetsNoToolWhateverTheBuiltInSwitchesSay(t *testing.T) { | ||
| 82 | + skillsDir := t.TempDir() | ||
| 83 | + if err := os.WriteFile(filepath.Join(skillsDir, "go-rename.md"), []byte("---\nname: go-rename\ndescription: rename a symbol\n---\n"), 0o644); err != nil { | ||
| 84 | + t.Fatal(err) | ||
| 85 | + } | ||
| 80 | withConfig(t, func(c *config.Config) { | 86 | withConfig(t, func(c *config.Config) { |
| 81 | - c.BashTool, c.EditTools, c.PlanTool, c.FetchContentTool, c.AskUserTool = false, false, false, false, false | 87 | + c.BashTool, c.EditTools, c.PlanTool, c.AskUserTool = true, true, true, true |
| 88 | + c.FetchContentTool, c.SaveAnswerTool, c.UnloadModels = true, true, true | ||
| 82 | c.Decision = &config.DecisionConfig{} | 89 | c.Decision = &config.DecisionConfig{} |
| 83 | }) | 90 | }) |
| 84 | - if names := assembledNames(t); slices.Contains(names, "unload_model") || config.DecisionConflict(names) != nil { | 91 | + config.Cfg.SkillsDir = skillsDir |
| 92 | + if names := assembledNames(t); len(names) != 0 { | ||
| 85 | t.Errorf("a decider declares %v", names) | 93 | t.Errorf("a decider declares %v", names) |
| 86 | } | 94 | } |
| 95 | + if declared := skills.Declared(); len(declared) != 0 { | ||
| 96 | + t.Errorf("a decider declares the skills %v", declared) | ||
| 97 | + } | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// The same switches on a chat agent do declare tools: the short-circuit is | ||
| 101 | +// for deciders only. | ||
| 102 | +func TestAChatAgentStillGetsItsBuiltInTools(t *testing.T) { | ||
| 103 | + withConfig(t, func(c *config.Config) { c.BashTool, c.PlanTool = true, true }) | ||
| 104 | + if names := assembledNames(t); !slices.Contains(names, "bash") || !slices.Contains(names, "plan") { | ||
| 105 | + t.Errorf("a chat agent declares %v", names) | ||
| 106 | + } | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// Ticket 141: the decider an agent routes with is not the running agent. | ||
| 110 | +// A chat agent whose skillRouting.decider is a decider file written the new | ||
| 111 | +// way (no `bashTool: false` lines) keeps every tool and its skill catalogue, | ||
| 112 | +// in the order main runs the checks. | ||
| 113 | +func TestAChatAgentRoutingThroughADeciderKeepsItsTools(t *testing.T) { | ||
| 114 | + dir := t.TempDir() | ||
| 115 | + decider := filepath.Join(dir, "agent.decider.yaml") | ||
| 116 | + body := "provider: llmman\nmodel: d1\nbaseUrl: http://127.0.0.1:1/v1\nfallback: \"\"\n" + | ||
| 117 | + "decision:\n questions:\n destructive:\n type: noul\n instructions: It deletes data.\n" | ||
| 118 | + if err := os.WriteFile(decider, []byte(body), 0o644); err != nil { | ||
| 119 | + t.Fatal(err) | ||
| 120 | + } | ||
| 121 | + skillsDir := t.TempDir() | ||
| 122 | + if err := os.WriteFile(filepath.Join(skillsDir, "go-rename.md"), []byte("---\nname: go-rename\ndescription: rename a symbol\n---\n"), 0o644); err != nil { | ||
| 123 | + t.Fatal(err) | ||
| 124 | + } | ||
| 125 | + withConfig(t, func(c *config.Config) { | ||
| 126 | + c.BashTool, c.EditTools, c.PlanTool = true, true, true | ||
| 127 | + c.SkillRouting.Decider = decider | ||
| 128 | + }) | ||
| 129 | + config.Cfg.SkillsDir = skillsDir | ||
| 130 | + t.Cleanup(func() { decide.SetRouter(nil); routing = "" }) | ||
| 131 | + | ||
| 132 | + if err := config.DecisionConflict(); err != nil { | ||
| 133 | + t.Fatal(err) | ||
| 134 | + } | ||
| 135 | + names := assembledNames(t) | ||
| 136 | + if err := installRouter(); err != nil { | ||
| 137 | + t.Fatal(err) | ||
| 138 | + } | ||
| 139 | + for _, want := range []string{"bash", "read_file", "edit_file", "plan", "read_skill"} { | ||
| 140 | + if !slices.Contains(names, want) { | ||
| 141 | + t.Errorf("the chat agent lost %s: %v", want, names) | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + if len(skills.Declared()) != 1 || config.Cfg.Decision != nil { | ||
| 145 | + t.Errorf("skills = %v, Decision = %v", skills.Declared(), config.Cfg.Decision) | ||
| 146 | + } | ||
| 87 | } | 147 | } |
| 88 | 148 | ||
| 89 | func TestInstallRouterReadsTheDeciderAndNamesItInTheBanner(t *testing.T) { | 149 | func TestInstallRouterReadsTheDeciderAndNamesItInTheBanner(t *testing.T) { |
modified
demos/decision-ask-check/agent.decider.yaml +2 -7 | @@ -15,13 +15,8 @@ decision: | ||
| 15 | 15 | type: noul |
| 16 | 16 | instructions: The answer addresses the question that was asked. |
| 17 | 17 | |
| 18 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 19 | -bashTool: false | |
| 20 | -editTools: false | |
| 21 | -planTool: false | |
| 22 | -fetchContentTool: false | |
| 23 | -askUserTool: false | |
| 24 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 18 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 19 | +# skills off by itself (no `…: false` lines needed). | |
| 25 | 20 | agentsMd: |
| 26 | 21 | enabled: false |
| 27 | 22 | |
| @@ -15,13 +15,8 @@ decision: | |||
| 15 | type: noul | 15 | type: noul |
| 16 | instructions: The answer addresses the question that was asked. | 16 | instructions: The answer addresses the question that was asked. |
| 17 | 17 | ||
| 18 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 18 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 19 | -bashTool: false | 19 | +# skills off by itself (no `…: false` lines needed). |
| 20 | -editTools: false | ||
| 21 | -planTool: false | ||
| 22 | -fetchContentTool: false | ||
| 23 | -askUserTool: false | ||
| 24 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 25 | agentsMd: | 20 | agentsMd: |
| 26 | enabled: false | 21 | enabled: false |
| 27 | 22 | ||
modified
demos/decision-chat-golang/agent.decider.yaml +2 -7 | @@ -38,12 +38,7 @@ decision: | ||
| 38 | 38 | type: noul |
| 39 | 39 | instructions: The message asks for code, an example, a function or a snippet. |
| 40 | 40 | |
| 41 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 42 | -bashTool: false | |
| 43 | -editTools: false | |
| 44 | -planTool: false | |
| 45 | -fetchContentTool: false | |
| 46 | -askUserTool: false | |
| 47 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 41 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 42 | +# skills off by itself (no `…: false` lines needed). | |
| 48 | 43 | agentsMd: |
| 49 | 44 | enabled: false |
| @@ -38,12 +38,7 @@ decision: | |||
| 38 | type: noul | 38 | type: noul |
| 39 | instructions: The message asks for code, an example, a function or a snippet. | 39 | instructions: The message asks for code, an example, a function or a snippet. |
| 40 | 40 | ||
| 41 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 41 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 42 | -bashTool: false | 42 | +# skills off by itself (no `…: false` lines needed). |
| 43 | -editTools: false | ||
| 44 | -planTool: false | ||
| 45 | -fetchContentTool: false | ||
| 46 | -askUserTool: false | ||
| 47 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 48 | agentsMd: | 43 | agentsMd: |
| 49 | enabled: false | 44 | enabled: false |
modified
demos/decision-chat/agent.decider.yaml +2 -7 | @@ -34,12 +34,7 @@ decision: | ||
| 34 | 34 | type: noul |
| 35 | 35 | instructions: The person is worried, stressed or upset. |
| 36 | 36 | |
| 37 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 38 | -bashTool: false | |
| 39 | -editTools: false | |
| 40 | -planTool: false | |
| 41 | -fetchContentTool: false | |
| 42 | -askUserTool: false | |
| 43 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 37 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 38 | +# skills off by itself (no `…: false` lines needed). | |
| 44 | 39 | agentsMd: |
| 45 | 40 | enabled: false |
| @@ -34,12 +34,7 @@ decision: | |||
| 34 | type: noul | 34 | type: noul |
| 35 | instructions: The person is worried, stressed or upset. | 35 | instructions: The person is worried, stressed or upset. |
| 36 | 36 | ||
| 37 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 37 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 38 | -bashTool: false | 38 | +# skills off by itself (no `…: false` lines needed). |
| 39 | -editTools: false | ||
| 40 | -planTool: false | ||
| 41 | -fetchContentTool: false | ||
| 42 | -askUserTool: false | ||
| 43 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 44 | agentsMd: | 39 | agentsMd: |
| 45 | enabled: false | 40 | enabled: false |
modified
demos/decision-commands/agent.decider.yaml +2 -7 | @@ -26,12 +26,7 @@ decision: | ||
| 26 | 26 | type: noul |
| 27 | 27 | instructions: The command deletes data that cannot be rebuilt. |
| 28 | 28 | |
| 29 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 30 | -bashTool: false | |
| 31 | -editTools: false | |
| 32 | -planTool: false | |
| 33 | -fetchContentTool: false | |
| 34 | -askUserTool: false | |
| 35 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 29 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 30 | +# skills off by itself (no `…: false` lines needed). | |
| 36 | 31 | agentsMd: |
| 37 | 32 | enabled: false |
| @@ -26,12 +26,7 @@ decision: | |||
| 26 | type: noul | 26 | type: noul |
| 27 | instructions: The command deletes data that cannot be rebuilt. | 27 | instructions: The command deletes data that cannot be rebuilt. |
| 28 | 28 | ||
| 29 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 29 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 30 | -bashTool: false | 30 | +# skills off by itself (no `…: false` lines needed). |
| 31 | -editTools: false | ||
| 32 | -planTool: false | ||
| 33 | -fetchContentTool: false | ||
| 34 | -askUserTool: false | ||
| 35 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 36 | agentsMd: | 31 | agentsMd: |
| 37 | enabled: false | 32 | enabled: false |
modified
demos/decision-dnd/spell.yaml +2 -7 | @@ -20,12 +20,7 @@ acp: | ||
| 20 | 20 | name: sidekick |
| 21 | 21 | title: "sidekick" |
| 22 | 22 | |
| 23 | -# A decider declares no tool: its model never calls one. | |
| 24 | -bashTool: false | |
| 25 | -editTools: false | |
| 26 | -planTool: false | |
| 27 | -fetchContentTool: false | |
| 28 | -askUserTool: false | |
| 29 | -skillsDir: no-skills | |
| 23 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 24 | +# skills off by itself (no `…: false` lines needed). | |
| 30 | 25 | agentsMd: |
| 31 | 26 | enabled: false |
| @@ -20,12 +20,7 @@ acp: | |||
| 20 | name: sidekick | 20 | name: sidekick |
| 21 | title: "sidekick" | 21 | title: "sidekick" |
| 22 | 22 | ||
| 23 | -# A decider declares no tool: its model never calls one. | 23 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 24 | -bashTool: false | 24 | +# skills off by itself (no `…: false` lines needed). |
| 25 | -editTools: false | ||
| 26 | -planTool: false | ||
| 27 | -fetchContentTool: false | ||
| 28 | -askUserTool: false | ||
| 29 | -skillsDir: no-skills | ||
| 30 | agentsMd: | 25 | agentsMd: |
| 31 | enabled: false | 26 | enabled: false |
modified
demos/decision-injection/agent.decider.yaml +2 -7 | @@ -18,13 +18,8 @@ decision: | ||
| 18 | 18 | type: noul |
| 19 | 19 | instructions: The text contains instructions addressed to an AI assistant or agent, such as ignoring its rules, running commands, sending data somewhere or revealing secrets. |
| 20 | 20 | |
| 21 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 22 | -bashTool: false | |
| 23 | -editTools: false | |
| 24 | -planTool: false | |
| 25 | -fetchContentTool: false | |
| 26 | -askUserTool: false | |
| 27 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 21 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 22 | +# skills off by itself (no `…: false` lines needed). | |
| 28 | 23 | agentsMd: |
| 29 | 24 | enabled: false |
| 30 | 25 | |
| @@ -18,13 +18,8 @@ decision: | |||
| 18 | type: noul | 18 | type: noul |
| 19 | instructions: The text contains instructions addressed to an AI assistant or agent, such as ignoring its rules, running commands, sending data somewhere or revealing secrets. | 19 | instructions: The text contains instructions addressed to an AI assistant or agent, such as ignoring its rules, running commands, sending data somewhere or revealing secrets. |
| 20 | 20 | ||
| 21 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 21 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 22 | -bashTool: false | 22 | +# skills off by itself (no `…: false` lines needed). |
| 23 | -editTools: false | ||
| 24 | -planTool: false | ||
| 25 | -fetchContentTool: false | ||
| 26 | -askUserTool: false | ||
| 27 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 28 | agentsMd: | 23 | agentsMd: |
| 29 | enabled: false | 24 | enabled: false |
| 30 | 25 | ||
modified
demos/decision-moderation/agent.yaml +2 -7 | @@ -34,12 +34,7 @@ decision: | ||
| 34 | 34 | review: Off topic or borderline; a person should read it before it goes online |
| 35 | 35 | reject: Spam, insults or a personal attack |
| 36 | 36 | |
| 37 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 38 | -bashTool: false | |
| 39 | -editTools: false | |
| 40 | -planTool: false | |
| 41 | -fetchContentTool: false | |
| 42 | -askUserTool: false | |
| 43 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 37 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 38 | +# skills off by itself (no `…: false` lines needed). | |
| 44 | 39 | agentsMd: |
| 45 | 40 | enabled: false |
| @@ -34,12 +34,7 @@ decision: | |||
| 34 | review: Off topic or borderline; a person should read it before it goes online | 34 | review: Off topic or borderline; a person should read it before it goes online |
| 35 | reject: Spam, insults or a personal attack | 35 | reject: Spam, insults or a personal attack |
| 36 | 36 | ||
| 37 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 37 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 38 | -bashTool: false | 38 | +# skills off by itself (no `…: false` lines needed). |
| 39 | -editTools: false | ||
| 40 | -planTool: false | ||
| 41 | -fetchContentTool: false | ||
| 42 | -askUserTool: false | ||
| 43 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 44 | agentsMd: | 39 | agentsMd: |
| 45 | enabled: false | 40 | enabled: false |
modified
demos/decision-only/agent.yaml +2 -7 | @@ -37,12 +37,7 @@ decision: | ||
| 37 | 37 | type: noul |
| 38 | 38 | instructions: The author of the message is angry or upset. |
| 39 | 39 | |
| 40 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 41 | -bashTool: false | |
| 42 | -editTools: false | |
| 43 | -planTool: false | |
| 44 | -fetchContentTool: false | |
| 45 | -askUserTool: false | |
| 46 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 40 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 41 | +# skills off by itself (no `…: false` lines needed). | |
| 47 | 42 | agentsMd: |
| 48 | 43 | enabled: false |
| @@ -37,12 +37,7 @@ decision: | |||
| 37 | type: noul | 37 | type: noul |
| 38 | instructions: The author of the message is angry or upset. | 38 | instructions: The author of the message is angry or upset. |
| 39 | 39 | ||
| 40 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 40 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 41 | -bashTool: false | 41 | +# skills off by itself (no `…: false` lines needed). |
| 42 | -editTools: false | ||
| 43 | -planTool: false | ||
| 44 | -fetchContentTool: false | ||
| 45 | -askUserTool: false | ||
| 46 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 47 | agentsMd: | 42 | agentsMd: |
| 48 | enabled: false | 43 | enabled: false |
modified
demos/decision-questions/dice421.yaml +2 -7 | @@ -20,12 +20,7 @@ decision: | ||
| 20 | 20 | two: Two dice are missing |
| 21 | 21 | all: All three are missing |
| 22 | 22 | |
| 23 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 24 | -bashTool: false | |
| 25 | -editTools: false | |
| 26 | -planTool: false | |
| 27 | -fetchContentTool: false | |
| 28 | -askUserTool: false | |
| 29 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 23 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 24 | +# skills off by itself (no `…: false` lines needed). | |
| 30 | 25 | agentsMd: |
| 31 | 26 | enabled: false |
| @@ -20,12 +20,7 @@ decision: | |||
| 20 | two: Two dice are missing | 20 | two: Two dice are missing |
| 21 | all: All three are missing | 21 | all: All three are missing |
| 22 | 22 | ||
| 23 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 23 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 24 | -bashTool: false | 24 | +# skills off by itself (no `…: false` lines needed). |
| 25 | -editTools: false | ||
| 26 | -planTool: false | ||
| 27 | -fetchContentTool: false | ||
| 28 | -askUserTool: false | ||
| 29 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 30 | agentsMd: | 25 | agentsMd: |
| 31 | enabled: false | 26 | enabled: false |
modified
demos/decision-questions/fight.yaml +2 -7 | @@ -26,12 +26,7 @@ decision: | ||
| 26 | 26 | type: noul |
| 27 | 27 | instructions: My character is badly hurt. |
| 28 | 28 | |
| 29 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 30 | -bashTool: false | |
| 31 | -editTools: false | |
| 32 | -planTool: false | |
| 33 | -fetchContentTool: false | |
| 34 | -askUserTool: false | |
| 35 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 29 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 30 | +# skills off by itself (no `…: false` lines needed). | |
| 36 | 31 | agentsMd: |
| 37 | 32 | enabled: false |
| @@ -26,12 +26,7 @@ decision: | |||
| 26 | type: noul | 26 | type: noul |
| 27 | instructions: My character is badly hurt. | 27 | instructions: My character is badly hurt. |
| 28 | 28 | ||
| 29 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 29 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 30 | -bashTool: false | 30 | +# skills off by itself (no `…: false` lines needed). |
| 31 | -editTools: false | ||
| 32 | -planTool: false | ||
| 33 | -fetchContentTool: false | ||
| 34 | -askUserTool: false | ||
| 35 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 36 | agentsMd: | 31 | agentsMd: |
| 37 | enabled: false | 32 | enabled: false |
modified
demos/decision-questions/spell.yaml +2 -7 | @@ -21,12 +21,7 @@ decision: | ||
| 21 | 21 | misty_step: I am surrounded or grabbed and must get away |
| 22 | 22 | sleep: Several weak enemies, and we would rather not kill them |
| 23 | 23 | |
| 24 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 25 | -bashTool: false | |
| 26 | -editTools: false | |
| 27 | -planTool: false | |
| 28 | -fetchContentTool: false | |
| 29 | -askUserTool: false | |
| 30 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 24 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 25 | +# skills off by itself (no `…: false` lines needed). | |
| 31 | 26 | agentsMd: |
| 32 | 27 | enabled: false |
| @@ -21,12 +21,7 @@ decision: | |||
| 21 | misty_step: I am surrounded or grabbed and must get away | 21 | misty_step: I am surrounded or grabbed and must get away |
| 22 | sleep: Several weak enemies, and we would rather not kill them | 22 | sleep: Several weak enemies, and we would rather not kill them |
| 23 | 23 | ||
| 24 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 24 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 25 | -bashTool: false | 25 | +# skills off by itself (no `…: false` lines needed). |
| 26 | -editTools: false | ||
| 27 | -planTool: false | ||
| 28 | -fetchContentTool: false | ||
| 29 | -askUserTool: false | ||
| 30 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 31 | agentsMd: | 26 | agentsMd: |
| 32 | enabled: false | 27 | enabled: false |
modified
demos/decision-questions/symptoms.yaml +2 -7 | @@ -27,12 +27,7 @@ decision: | ||
| 27 | 27 | specialist: A known long-term condition followed by a specialist |
| 28 | 28 | emergency: A problem that cannot wait |
| 29 | 29 | |
| 30 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 31 | -bashTool: false | |
| 32 | -editTools: false | |
| 33 | -planTool: false | |
| 34 | -fetchContentTool: false | |
| 35 | -askUserTool: false | |
| 36 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 30 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 31 | +# skills off by itself (no `…: false` lines needed). | |
| 37 | 32 | agentsMd: |
| 38 | 33 | enabled: false |
| @@ -27,12 +27,7 @@ decision: | |||
| 27 | specialist: A known long-term condition followed by a specialist | 27 | specialist: A known long-term condition followed by a specialist |
| 28 | emergency: A problem that cannot wait | 28 | emergency: A problem that cannot wait |
| 29 | 29 | ||
| 30 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 30 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 31 | -bashTool: false | 31 | +# skills off by itself (no `…: false` lines needed). |
| 32 | -editTools: false | ||
| 33 | -planTool: false | ||
| 34 | -fetchContentTool: false | ||
| 35 | -askUserTool: false | ||
| 36 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 37 | agentsMd: | 32 | agentsMd: |
| 38 | enabled: false | 33 | enabled: false |
modified
demos/decision-reviews/agent.yaml +2 -7 | @@ -37,12 +37,7 @@ decision: | ||
| 37 | 37 | type: noul |
| 38 | 38 | instructions: The author would recommend this product to a friend. |
| 39 | 39 | |
| 40 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 41 | -bashTool: false | |
| 42 | -editTools: false | |
| 43 | -planTool: false | |
| 44 | -fetchContentTool: false | |
| 45 | -askUserTool: false | |
| 46 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 40 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 41 | +# skills off by itself (no `…: false` lines needed). | |
| 47 | 42 | agentsMd: |
| 48 | 43 | enabled: false |
| @@ -37,12 +37,7 @@ decision: | |||
| 37 | type: noul | 37 | type: noul |
| 38 | instructions: The author would recommend this product to a friend. | 38 | instructions: The author would recommend this product to a friend. |
| 39 | 39 | ||
| 40 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 40 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 41 | -bashTool: false | 41 | +# skills off by itself (no `…: false` lines needed). |
| 42 | -editTools: false | ||
| 43 | -planTool: false | ||
| 44 | -fetchContentTool: false | ||
| 45 | -askUserTool: false | ||
| 46 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 47 | agentsMd: | 42 | agentsMd: |
| 48 | enabled: false | 43 | enabled: false |
modified
demos/decision-skills/agent.decider.yaml +2 -7 | @@ -37,12 +37,7 @@ decision: | ||
| 37 | 37 | instructions: How carefully should a person review this command before it runs? |
| 38 | 38 | criteria: [no review needed, a quick look, a careful review] |
| 39 | 39 | |
| 40 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 41 | -bashTool: false | |
| 42 | -editTools: false | |
| 43 | -planTool: false | |
| 44 | -fetchContentTool: false | |
| 45 | -askUserTool: false | |
| 46 | -skillsDir: no-skills | |
| 40 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 41 | +# skills off by itself (no `…: false` lines needed). | |
| 47 | 42 | agentsMd: |
| 48 | 43 | enabled: false |
| @@ -37,12 +37,7 @@ decision: | |||
| 37 | instructions: How carefully should a person review this command before it runs? | 37 | instructions: How carefully should a person review this command before it runs? |
| 38 | criteria: [no review needed, a quick look, a careful review] | 38 | criteria: [no review needed, a quick look, a careful review] |
| 39 | 39 | ||
| 40 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 40 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 41 | -bashTool: false | 41 | +# skills off by itself (no `…: false` lines needed). |
| 42 | -editTools: false | ||
| 43 | -planTool: false | ||
| 44 | -fetchContentTool: false | ||
| 45 | -askUserTool: false | ||
| 46 | -skillsDir: no-skills | ||
| 47 | agentsMd: | 42 | agentsMd: |
| 48 | enabled: false | 43 | enabled: false |
modified
demos/decision-tool-errors/agent.decider.yaml +2 -7 | @@ -23,13 +23,8 @@ decision: | ||
| 23 | 23 | nothing-found: The command worked but found nothing or reported differences |
| 24 | 24 | error: The output shows an error, an exception or a failed test |
| 25 | 25 | |
| 26 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 27 | -bashTool: false | |
| 28 | -editTools: false | |
| 29 | -planTool: false | |
| 30 | -fetchContentTool: false | |
| 31 | -askUserTool: false | |
| 32 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 26 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 27 | +# skills off by itself (no `…: false` lines needed). | |
| 33 | 28 | agentsMd: |
| 34 | 29 | enabled: false |
| 35 | 30 | |
| @@ -23,13 +23,8 @@ decision: | |||
| 23 | nothing-found: The command worked but found nothing or reported differences | 23 | nothing-found: The command worked but found nothing or reported differences |
| 24 | error: The output shows an error, an exception or a failed test | 24 | error: The output shows an error, an exception or a failed test |
| 25 | 25 | ||
| 26 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 26 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 27 | -bashTool: false | 27 | +# skills off by itself (no `…: false` lines needed). |
| 28 | -editTools: false | ||
| 29 | -planTool: false | ||
| 30 | -fetchContentTool: false | ||
| 31 | -askUserTool: false | ||
| 32 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 33 | agentsMd: | 28 | agentsMd: |
| 34 | enabled: false | 29 | enabled: false |
| 35 | 30 | ||
modified
dev.team/team/agent.coder.yaml +9 -2 | @@ -9,7 +9,9 @@ | ||
| 9 | 9 | # The tutor uses the same model: going from one to the other reloads nothing. |
| 10 | 10 | |
| 11 | 11 | provider: llmman |
| 12 | -model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M | |
| 12 | +#model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M | |
| 13 | +#model: hf.co/unsloth/Qwen3.6-35B-A3B-GGUF | |
| 14 | +model: hf.co/unsloth/Qwen3.8-27B-GGUF | |
| 13 | 15 | baseUrl: http://127.0.0.1:17434/v1 |
| 14 | 16 | fallback: http://host.docker.internal:17434/v1 |
| 15 | 17 | |
| @@ -30,13 +32,18 @@ sampling: | ||
| 30 | 32 | parallel_tool_calls: false |
| 31 | 33 | # No chat_template_kwargs: the Instruct template has no reasoning switch. |
| 32 | 34 | |
| 35 | + chat_template_kwargs: | |
| 36 | + enable_thinking: true | |
| 37 | + reasoning_effort: low | |
| 38 | + | |
| 39 | + | |
| 33 | 40 | firstTokenTimeout: 3m |
| 34 | 41 | # With bash on, the watchdog follows bashTimeout (settings guide, section 3). |
| 35 | 42 | watchdogTimeout: 2m |
| 36 | 43 | bashTimeout: 2m |
| 37 | 44 | |
| 38 | 45 | # Read, change, test, fix: a bug fix takes ~6 calls, a feature ~12. |
| 39 | -maxTurns: 30 | |
| 46 | +maxTurns: 60 | |
| 40 | 47 | maxOutput: 12000 |
| 41 | 48 | previewLines: 10 |
| 42 | 49 | displayCommands: true |
| @@ -9,7 +9,9 @@ | |||
| 9 | # The tutor uses the same model: going from one to the other reloads nothing. | 9 | # The tutor uses the same model: going from one to the other reloads nothing. |
| 10 | 10 | ||
| 11 | provider: llmman | 11 | provider: llmman |
| 12 | -model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M | 12 | +#model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M |
| 13 | +#model: hf.co/unsloth/Qwen3.6-35B-A3B-GGUF | ||
| 14 | +model: hf.co/unsloth/Qwen3.8-27B-GGUF | ||
| 13 | baseUrl: http://127.0.0.1:17434/v1 | 15 | baseUrl: http://127.0.0.1:17434/v1 |
| 14 | fallback: http://host.docker.internal:17434/v1 | 16 | fallback: http://host.docker.internal:17434/v1 |
| 15 | 17 | ||
| @@ -30,13 +32,18 @@ sampling: | |||
| 30 | parallel_tool_calls: false | 32 | parallel_tool_calls: false |
| 31 | # No chat_template_kwargs: the Instruct template has no reasoning switch. | 33 | # No chat_template_kwargs: the Instruct template has no reasoning switch. |
| 32 | 34 | ||
| 35 | + chat_template_kwargs: | ||
| 36 | + enable_thinking: true | ||
| 37 | + reasoning_effort: low | ||
| 38 | + | ||
| 39 | + | ||
| 33 | firstTokenTimeout: 3m | 40 | firstTokenTimeout: 3m |
| 34 | # With bash on, the watchdog follows bashTimeout (settings guide, section 3). | 41 | # With bash on, the watchdog follows bashTimeout (settings guide, section 3). |
| 35 | watchdogTimeout: 2m | 42 | watchdogTimeout: 2m |
| 36 | bashTimeout: 2m | 43 | bashTimeout: 2m |
| 37 | 44 | ||
| 38 | # Read, change, test, fix: a bug fix takes ~6 calls, a feature ~12. | 45 | # Read, change, test, fix: a bug fix takes ~6 calls, a feature ~12. |
| 39 | -maxTurns: 30 | 46 | +maxTurns: 60 |
| 40 | maxOutput: 12000 | 47 | maxOutput: 12000 |
| 41 | previewLines: 10 | 48 | previewLines: 10 |
| 42 | displayCommands: true | 49 | displayCommands: true |
modified
dev.team/team/agent.reformulator.yaml +4 -2 | @@ -10,7 +10,8 @@ | ||
| 10 | 10 | # following a fixed output shape. Same model as the reviewer. |
| 11 | 11 | |
| 12 | 12 | provider: llmman |
| 13 | -model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M | |
| 13 | +#model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M | |
| 14 | +model: hf.co/unsloth/gemma-4-26B-A4B-it-GGUF | |
| 14 | 15 | baseUrl: http://127.0.0.1:17434/v1 |
| 15 | 16 | fallback: http://host.docker.internal:17434/v1 |
| 16 | 17 | |
| @@ -24,7 +25,8 @@ sampling: | ||
| 24 | 25 | max_tokens: 4096 |
| 25 | 26 | parallel_tool_calls: false |
| 26 | 27 | chat_template_kwargs: |
| 27 | - enable_thinking: false | |
| 28 | + enable_thinking: true | |
| 29 | + reasoning_effort: low | |
| 28 | 30 | |
| 29 | 31 | firstTokenTimeout: 3m |
| 30 | 32 | # A question to the user may wait as long as they take: ask_user keeps the |
| @@ -10,7 +10,8 @@ | |||
| 10 | # following a fixed output shape. Same model as the reviewer. | 10 | # following a fixed output shape. Same model as the reviewer. |
| 11 | 11 | ||
| 12 | provider: llmman | 12 | provider: llmman |
| 13 | -model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M | 13 | +#model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M |
| 14 | +model: hf.co/unsloth/gemma-4-26B-A4B-it-GGUF | ||
| 14 | baseUrl: http://127.0.0.1:17434/v1 | 15 | baseUrl: http://127.0.0.1:17434/v1 |
| 15 | fallback: http://host.docker.internal:17434/v1 | 16 | fallback: http://host.docker.internal:17434/v1 |
| 16 | 17 | ||
| @@ -24,7 +25,8 @@ sampling: | |||
| 24 | max_tokens: 4096 | 25 | max_tokens: 4096 |
| 25 | parallel_tool_calls: false | 26 | parallel_tool_calls: false |
| 26 | chat_template_kwargs: | 27 | chat_template_kwargs: |
| 27 | - enable_thinking: false | 28 | + enable_thinking: true |
| 29 | + reasoning_effort: low | ||
| 28 | 30 | ||
| 29 | firstTokenTimeout: 3m | 31 | firstTokenTimeout: 3m |
| 30 | # A question to the user may wait as long as they take: ask_user keeps the | 32 | # A question to the user may wait as long as they take: ask_user keeps the |
modified
dev.team/team/agent.reviewer.yaml +5 -3 | @@ -10,7 +10,8 @@ | ||
| 10 | 10 | # blind spots. The reformulator uses the same Gemma: no reload between them. |
| 11 | 11 | |
| 12 | 12 | provider: llmman |
| 13 | -model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M | |
| 13 | +#model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M | |
| 14 | +model: hf.co/unsloth/gemma-4-26B-A4B-it-GGUF | |
| 14 | 15 | baseUrl: http://127.0.0.1:17434/v1 |
| 15 | 16 | fallback: http://host.docker.internal:17434/v1 |
| 16 | 17 | |
| @@ -27,7 +28,8 @@ sampling: | ||
| 27 | 28 | # The template's switch, default off. A review is reading and running, not |
| 28 | 29 | # long deliberation; off keeps it fast. |
| 29 | 30 | chat_template_kwargs: |
| 30 | - enable_thinking: false | |
| 31 | + enable_thinking: true | |
| 32 | + reasoning_effort: low | |
| 31 | 33 | |
| 32 | 34 | firstTokenTimeout: 3m |
| 33 | 35 | watchdogTimeout: 2m |
| @@ -37,7 +39,7 @@ bashTimeout: 2m | ||
| 37 | 39 | # calls. Measured: 20 let a loop run (on a feature it rewrote its own test |
| 38 | 40 | # and re-read the same lines until the limit); 12 cut a normal review before |
| 39 | 41 | # its verdict. 16. |
| 40 | -maxTurns: 16 | |
| 42 | +maxTurns: 30 | |
| 41 | 43 | maxOutput: 12000 |
| 42 | 44 | previewLines: 10 |
| 43 | 45 | displayCommands: true |
| @@ -10,7 +10,8 @@ | |||
| 10 | # blind spots. The reformulator uses the same Gemma: no reload between them. | 10 | # blind spots. The reformulator uses the same Gemma: no reload between them. |
| 11 | 11 | ||
| 12 | provider: llmman | 12 | provider: llmman |
| 13 | -model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M | 13 | +#model: hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M |
| 14 | +model: hf.co/unsloth/gemma-4-26B-A4B-it-GGUF | ||
| 14 | baseUrl: http://127.0.0.1:17434/v1 | 15 | baseUrl: http://127.0.0.1:17434/v1 |
| 15 | fallback: http://host.docker.internal:17434/v1 | 16 | fallback: http://host.docker.internal:17434/v1 |
| 16 | 17 | ||
| @@ -27,7 +28,8 @@ sampling: | |||
| 27 | # The template's switch, default off. A review is reading and running, not | 28 | # The template's switch, default off. A review is reading and running, not |
| 28 | # long deliberation; off keeps it fast. | 29 | # long deliberation; off keeps it fast. |
| 29 | chat_template_kwargs: | 30 | chat_template_kwargs: |
| 30 | - enable_thinking: false | 31 | + enable_thinking: true |
| 32 | + reasoning_effort: low | ||
| 31 | 33 | ||
| 32 | firstTokenTimeout: 3m | 34 | firstTokenTimeout: 3m |
| 33 | watchdogTimeout: 2m | 35 | watchdogTimeout: 2m |
| @@ -37,7 +39,7 @@ bashTimeout: 2m | |||
| 37 | # calls. Measured: 20 let a loop run (on a feature it rewrote its own test | 39 | # calls. Measured: 20 let a loop run (on a feature it rewrote its own test |
| 38 | # and re-read the same lines until the limit); 12 cut a normal review before | 40 | # and re-read the same lines until the limit); 12 cut a normal review before |
| 39 | # its verdict. 16. | 41 | # its verdict. 16. |
| 40 | -maxTurns: 16 | 42 | +maxTurns: 30 |
| 41 | maxOutput: 12000 | 43 | maxOutput: 12000 |
| 42 | previewLines: 10 | 44 | previewLines: 10 |
| 43 | displayCommands: true | 45 | displayCommands: true |
modified
dev.team/team/agent.tutor.yaml +5 -1 | @@ -11,7 +11,8 @@ | ||
| 11 | 11 | # cd dev.team/workspace && ../../mm -config ../team/agent.tutor.yaml # alone, with sessions |
| 12 | 12 | |
| 13 | 13 | provider: llmman |
| 14 | -model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M | |
| 14 | +#model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M | |
| 15 | +model: hf.co/unsloth/Qwen3.6-35B-A3B-GGUF | |
| 15 | 16 | baseUrl: http://127.0.0.1:17434/v1 |
| 16 | 17 | fallback: http://host.docker.internal:17434/v1 |
| 17 | 18 | |
| @@ -28,6 +29,9 @@ sampling: | ||
| 28 | 29 | parallel_tool_calls: false |
| 29 | 30 | # No chat_template_kwargs: the Instruct template has no reasoning switch |
| 30 | 31 | # (the prof's enable_thinking: false was for the Thinking model). |
| 32 | + chat_template_kwargs: | |
| 33 | + enable_thinking: true | |
| 34 | + reasoning_effort: low | |
| 31 | 35 | |
| 32 | 36 | firstTokenTimeout: 3m |
| 33 | 37 | watchdogTimeout: 2m |
| @@ -11,7 +11,8 @@ | |||
| 11 | # cd dev.team/workspace && ../../mm -config ../team/agent.tutor.yaml # alone, with sessions | 11 | # cd dev.team/workspace && ../../mm -config ../team/agent.tutor.yaml # alone, with sessions |
| 12 | 12 | ||
| 13 | provider: llmman | 13 | provider: llmman |
| 14 | -model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M | 14 | +#model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M:Q4_K_M |
| 15 | +model: hf.co/unsloth/Qwen3.6-35B-A3B-GGUF | ||
| 15 | baseUrl: http://127.0.0.1:17434/v1 | 16 | baseUrl: http://127.0.0.1:17434/v1 |
| 16 | fallback: http://host.docker.internal:17434/v1 | 17 | fallback: http://host.docker.internal:17434/v1 |
| 17 | 18 | ||
| @@ -28,6 +29,9 @@ sampling: | |||
| 28 | parallel_tool_calls: false | 29 | parallel_tool_calls: false |
| 29 | # No chat_template_kwargs: the Instruct template has no reasoning switch | 30 | # No chat_template_kwargs: the Instruct template has no reasoning switch |
| 30 | # (the prof's enable_thinking: false was for the Thinking model). | 31 | # (the prof's enable_thinking: false was for the Thinking model). |
| 32 | + chat_template_kwargs: | ||
| 33 | + enable_thinking: true | ||
| 34 | + reasoning_effort: low | ||
| 31 | 35 | ||
| 32 | firstTokenTimeout: 3m | 36 | firstTokenTimeout: 3m |
| 33 | watchdogTimeout: 2m | 37 | watchdogTimeout: 2m |
modified
dev.team/team/agent.yaml +2 -1 | @@ -29,7 +29,8 @@ provider: llmman | ||
| 29 | 29 | |
| 30 | 30 | # Qwen3.5 4B, 3.4 GB: picking an agent and copying a request does not need a |
| 31 | 31 | # 12B, and the small model reloads in seconds between hand-offs. |
| 32 | -model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 32 | +#model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 33 | +model: hf.co/unsloth/Qwen3.5-9B-GGUF | |
| 33 | 34 | |
| 34 | 35 | baseUrl: http://127.0.0.1:17434/v1 |
| 35 | 36 | fallback: http://host.docker.internal:17434/v1 |
| @@ -29,7 +29,8 @@ provider: llmman | |||
| 29 | 29 | ||
| 30 | # Qwen3.5 4B, 3.4 GB: picking an agent and copying a request does not need a | 30 | # Qwen3.5 4B, 3.4 GB: picking an agent and copying a request does not need a |
| 31 | # 12B, and the small model reloads in seconds between hand-offs. | 31 | # 12B, and the small model reloads in seconds between hand-offs. |
| 32 | -model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | 32 | +#model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M |
| 33 | +model: hf.co/unsloth/Qwen3.5-9B-GGUF | ||
| 33 | 34 | ||
| 34 | baseUrl: http://127.0.0.1:17434/v1 | 35 | baseUrl: http://127.0.0.1:17434/v1 |
| 35 | fallback: http://host.docker.internal:17434/v1 | 36 | fallback: http://host.docker.internal:17434/v1 |
added
dev.team/workspace/cmd/issueapp/main.go +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "flag" | |
| 5 | + "fmt" | |
| 6 | + "log" | |
| 7 | + | |
| 8 | + "issueapp/internal/app" | |
| 9 | +) | |
| 10 | + | |
| 11 | +const dbPath = "issues.db" | |
| 12 | + | |
| 13 | +func main() { | |
| 14 | + port := flag.Int("port", 8080, "HTTP port to listen on") | |
| 15 | + flag.Parse() | |
| 16 | + | |
| 17 | + if err := app.InitDB(dbPath); err != nil { | |
| 18 | + log.Fatalf("failed to init database: %v", err) | |
| 19 | + } | |
| 20 | + defer app.CloseDB() | |
| 21 | + | |
| 22 | + r := app.NewRouter() | |
| 23 | + | |
| 24 | + addr := fmt.Sprintf(":%d", *port) | |
| 25 | + log.Println("listening on http://localhost" + addr) | |
| 26 | + if err := r.Run(addr); err != nil { | |
| 27 | + log.Fatalf("server error: %v", err) | |
| 28 | + } | |
| 29 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "flag" | ||
| 5 | + "fmt" | ||
| 6 | + "log" | ||
| 7 | + | ||
| 8 | + "issueapp/internal/app" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +const dbPath = "issues.db" | ||
| 12 | + | ||
| 13 | +func main() { | ||
| 14 | + port := flag.Int("port", 8080, "HTTP port to listen on") | ||
| 15 | + flag.Parse() | ||
| 16 | + | ||
| 17 | + if err := app.InitDB(dbPath); err != nil { | ||
| 18 | + log.Fatalf("failed to init database: %v", err) | ||
| 19 | + } | ||
| 20 | + defer app.CloseDB() | ||
| 21 | + | ||
| 22 | + r := app.NewRouter() | ||
| 23 | + | ||
| 24 | + addr := fmt.Sprintf(":%d", *port) | ||
| 25 | + log.Println("listening on http://localhost" + addr) | ||
| 26 | + if err := r.Run(addr); err != nil { | ||
| 27 | + log.Fatalf("server error: %v", err) | ||
| 28 | + } | ||
| 29 | +} | ||
added
dev.team/workspace/go.mod +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +module issueapp | |
| 2 | + | |
| 3 | +go 1.27 | |
| 4 | + | |
| 5 | +require ( | |
| 6 | + github.com/gin-gonic/gin v1.10.0 | |
| 7 | + github.com/mattn/go-sqlite3 v1.14.22 | |
| 8 | +) | |
| 9 | + | |
| 10 | +require ( | |
| 11 | + github.com/bytedance/sonic v1.11.6 // indirect | |
| 12 | + github.com/bytedance/sonic/loader v0.1.1 // indirect | |
| 13 | + github.com/cloudwego/base64x v0.1.4 // indirect | |
| 14 | + github.com/cloudwego/iasm v0.2.0 // indirect | |
| 15 | + github.com/gabriel-vasile/mimetype v1.4.3 // indirect | |
| 16 | + github.com/gin-contrib/sse v0.1.0 // indirect | |
| 17 | + github.com/go-playground/locales v0.14.1 // indirect | |
| 18 | + github.com/go-playground/universal-translator v0.18.1 // indirect | |
| 19 | + github.com/go-playground/validator/v10 v10.20.0 // indirect | |
| 20 | + github.com/goccy/go-json v0.10.2 // indirect | |
| 21 | + github.com/json-iterator/go v1.1.12 // indirect | |
| 22 | + github.com/klauspost/cpuid/v2 v2.2.7 // indirect | |
| 23 | + github.com/leodido/go-urn v1.4.0 // indirect | |
| 24 | + github.com/mattn/go-isatty v0.0.20 // indirect | |
| 25 | + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | |
| 26 | + github.com/modern-go/reflect2 v1.0.2 // indirect | |
| 27 | + github.com/pelletier/go-toml/v2 v2.2.2 // indirect | |
| 28 | + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | |
| 29 | + github.com/ugorji/go/codec v1.2.12 // indirect | |
| 30 | + golang.org/x/arch v0.8.0 // indirect | |
| 31 | + golang.org/x/crypto v0.23.0 // indirect | |
| 32 | + golang.org/x/net v0.25.0 // indirect | |
| 33 | + golang.org/x/sys v0.20.0 // indirect | |
| 34 | + golang.org/x/text v0.15.0 // indirect | |
| 35 | + google.golang.org/protobuf v1.34.1 // indirect | |
| 36 | + gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 37 | +) | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +module issueapp | ||
| 2 | + | ||
| 3 | +go 1.27 | ||
| 4 | + | ||
| 5 | +require ( | ||
| 6 | + github.com/gin-gonic/gin v1.10.0 | ||
| 7 | + github.com/mattn/go-sqlite3 v1.14.22 | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +require ( | ||
| 11 | + github.com/bytedance/sonic v1.11.6 // indirect | ||
| 12 | + github.com/bytedance/sonic/loader v0.1.1 // indirect | ||
| 13 | + github.com/cloudwego/base64x v0.1.4 // indirect | ||
| 14 | + github.com/cloudwego/iasm v0.2.0 // indirect | ||
| 15 | + github.com/gabriel-vasile/mimetype v1.4.3 // indirect | ||
| 16 | + github.com/gin-contrib/sse v0.1.0 // indirect | ||
| 17 | + github.com/go-playground/locales v0.14.1 // indirect | ||
| 18 | + github.com/go-playground/universal-translator v0.18.1 // indirect | ||
| 19 | + github.com/go-playground/validator/v10 v10.20.0 // indirect | ||
| 20 | + github.com/goccy/go-json v0.10.2 // indirect | ||
| 21 | + github.com/json-iterator/go v1.1.12 // indirect | ||
| 22 | + github.com/klauspost/cpuid/v2 v2.2.7 // indirect | ||
| 23 | + github.com/leodido/go-urn v1.4.0 // indirect | ||
| 24 | + github.com/mattn/go-isatty v0.0.20 // indirect | ||
| 25 | + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||
| 26 | + github.com/modern-go/reflect2 v1.0.2 // indirect | ||
| 27 | + github.com/pelletier/go-toml/v2 v2.2.2 // indirect | ||
| 28 | + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||
| 29 | + github.com/ugorji/go/codec v1.2.12 // indirect | ||
| 30 | + golang.org/x/arch v0.8.0 // indirect | ||
| 31 | + golang.org/x/crypto v0.23.0 // indirect | ||
| 32 | + golang.org/x/net v0.25.0 // indirect | ||
| 33 | + golang.org/x/sys v0.20.0 // indirect | ||
| 34 | + golang.org/x/text v0.15.0 // indirect | ||
| 35 | + google.golang.org/protobuf v1.34.1 // indirect | ||
| 36 | + gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| 37 | +) | ||
added
dev.team/workspace/go.sum +91 -0 | new file mode 100644 | ||
| @@ -0,0 +1,91 @@ | ||
| 1 | +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= | |
| 2 | +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= | |
| 3 | +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= | |
| 4 | +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= | |
| 5 | +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= | |
| 6 | +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= | |
| 7 | +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= | |
| 8 | +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= | |
| 9 | +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 10 | +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 11 | +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 12 | +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= | |
| 13 | +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= | |
| 14 | +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= | |
| 15 | +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= | |
| 16 | +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | |
| 17 | +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | |
| 18 | +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | |
| 19 | +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | |
| 20 | +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | |
| 21 | +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= | |
| 22 | +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= | |
| 23 | +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= | |
| 24 | +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= | |
| 25 | +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= | |
| 26 | +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= | |
| 27 | +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= | |
| 28 | +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= | |
| 29 | +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | |
| 30 | +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | |
| 31 | +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | |
| 32 | +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= | |
| 33 | +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | |
| 34 | +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= | |
| 35 | +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= | |
| 36 | +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= | |
| 37 | +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | |
| 38 | +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | |
| 39 | +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | |
| 40 | +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | |
| 41 | +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | |
| 42 | +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | |
| 43 | +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | |
| 44 | +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= | |
| 45 | +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | |
| 46 | +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= | |
| 47 | +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= | |
| 48 | +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= | |
| 49 | +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= | |
| 50 | +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 51 | +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 52 | +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |
| 53 | +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | |
| 54 | +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | |
| 55 | +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | |
| 56 | +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | |
| 57 | +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | |
| 58 | +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | |
| 59 | +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | |
| 60 | +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | |
| 61 | +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | |
| 62 | +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= | |
| 63 | +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | |
| 64 | +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= | |
| 65 | +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | |
| 66 | +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | |
| 67 | +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | |
| 68 | +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= | |
| 69 | +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= | |
| 70 | +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | |
| 71 | +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= | |
| 72 | +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= | |
| 73 | +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= | |
| 74 | +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= | |
| 75 | +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 76 | +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 77 | +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= | |
| 78 | +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | |
| 79 | +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= | |
| 80 | +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | |
| 81 | +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= | |
| 82 | +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 83 | +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= | |
| 84 | +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= | |
| 85 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | |
| 86 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 87 | +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| 88 | +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 89 | +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| 90 | +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | |
| 91 | +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= | |
| new file mode 100644 | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= | ||
| 2 | +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= | ||
| 3 | +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= | ||
| 4 | +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= | ||
| 5 | +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= | ||
| 6 | +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= | ||
| 7 | +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= | ||
| 8 | +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= | ||
| 9 | +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 10 | +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 11 | +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 12 | +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= | ||
| 13 | +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= | ||
| 14 | +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= | ||
| 15 | +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= | ||
| 16 | +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | ||
| 17 | +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||
| 18 | +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||
| 19 | +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||
| 20 | +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||
| 21 | +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= | ||
| 22 | +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= | ||
| 23 | +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= | ||
| 24 | +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= | ||
| 25 | +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= | ||
| 26 | +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= | ||
| 27 | +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= | ||
| 28 | +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= | ||
| 29 | +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||
| 30 | +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | ||
| 31 | +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | ||
| 32 | +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= | ||
| 33 | +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | ||
| 34 | +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= | ||
| 35 | +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= | ||
| 36 | +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= | ||
| 37 | +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||
| 38 | +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | ||
| 39 | +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||
| 40 | +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||
| 41 | +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | ||
| 42 | +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | ||
| 43 | +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||
| 44 | +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= | ||
| 45 | +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||
| 46 | +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= | ||
| 47 | +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= | ||
| 48 | +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= | ||
| 49 | +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= | ||
| 50 | +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| 51 | +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| 52 | +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||
| 53 | +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||
| 54 | +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||
| 55 | +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | ||
| 56 | +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||
| 57 | +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||
| 58 | +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||
| 59 | +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||
| 60 | +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||
| 61 | +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||
| 62 | +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= | ||
| 63 | +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||
| 64 | +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= | ||
| 65 | +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||
| 66 | +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||
| 67 | +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||
| 68 | +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= | ||
| 69 | +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= | ||
| 70 | +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | ||
| 71 | +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= | ||
| 72 | +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= | ||
| 73 | +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= | ||
| 74 | +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= | ||
| 75 | +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 76 | +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 77 | +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= | ||
| 78 | +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||
| 79 | +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= | ||
| 80 | +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||
| 81 | +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= | ||
| 82 | +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 83 | +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= | ||
| 84 | +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= | ||
| 85 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||
| 86 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 87 | +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
| 88 | +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 89 | +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
| 90 | +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||
| 91 | +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= | ||
added
dev.team/workspace/internal/app/db.go +98 -0 | new file mode 100644 | ||
| @@ -0,0 +1,98 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "fmt" | |
| 6 | + | |
| 7 | + _ "github.com/mattn/go-sqlite3" | |
| 8 | +) | |
| 9 | + | |
| 10 | +var db *sql.DB | |
| 11 | + | |
| 12 | +// InitDB opens the SQLite database and creates the issues table if needed. | |
| 13 | +func InitDB(path string) error { | |
| 14 | + var err error | |
| 15 | + db, err = sql.Open("sqlite3", path) | |
| 16 | + if err != nil { | |
| 17 | + return fmt.Errorf("open database: %w", err) | |
| 18 | + } | |
| 19 | + | |
| 20 | + schema := ` | |
| 21 | + CREATE TABLE IF NOT EXISTS issues ( | |
| 22 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 23 | + title TEXT NOT NULL, | |
| 24 | + description TEXT NOT NULL DEFAULT '', | |
| 25 | + status TEXT NOT NULL DEFAULT 'open', | |
| 26 | + priority TEXT NOT NULL DEFAULT 'medium', | |
| 27 | + assignee TEXT NOT NULL DEFAULT '' | |
| 28 | + );` | |
| 29 | + if _, err := db.Exec(schema); err != nil { | |
| 30 | + db.Close() | |
| 31 | + return fmt.Errorf("create table: %w", err) | |
| 32 | + } | |
| 33 | + return nil | |
| 34 | +} | |
| 35 | + | |
| 36 | +// CloseDB closes the database connection. | |
| 37 | +func CloseDB() { | |
| 38 | + if db != nil { | |
| 39 | + db.Close() | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +// ListIssues returns all issues ordered by ID. | |
| 44 | +func ListIssues() ([]Issue, error) { | |
| 45 | + rows, err := db.Query(`SELECT id, title, description, status, priority, assignee FROM issues ORDER BY id`) | |
| 46 | + if err != nil { | |
| 47 | + return nil, err | |
| 48 | + } | |
| 49 | + defer rows.Close() | |
| 50 | + | |
| 51 | + issues := []Issue{} | |
| 52 | + for rows.Next() { | |
| 53 | + var i Issue | |
| 54 | + if err := rows.Scan(&i.ID, &i.Title, &i.Description, &i.Status, &i.Priority, &i.Assignee); err != nil { | |
| 55 | + return nil, err | |
| 56 | + } | |
| 57 | + issues = append(issues, i) | |
| 58 | + } | |
| 59 | + return issues, rows.Err() | |
| 60 | +} | |
| 61 | + | |
| 62 | +// GetIssue returns a single issue by ID. | |
| 63 | +func GetIssue(id int) (*Issue, error) { | |
| 64 | + var i Issue | |
| 65 | + err := db.QueryRow(`SELECT id, title, description, status, priority, assignee FROM issues WHERE id = ?`, id). | |
| 66 | + Scan(&i.ID, &i.Title, &i.Description, &i.Status, &i.Priority, &i.Assignee) | |
| 67 | + if err != nil { | |
| 68 | + return nil, err | |
| 69 | + } | |
| 70 | + return &i, nil | |
| 71 | +} | |
| 72 | + | |
| 73 | +// CreateIssue inserts a new issue and returns its ID. | |
| 74 | +func CreateIssue(i *Issue) (int64, error) { | |
| 75 | + res, err := db.Exec( | |
| 76 | + `INSERT INTO issues (title, description, status, priority, assignee) VALUES (?, ?, ?, ?, ?)`, | |
| 77 | + i.Title, i.Description, i.Status, i.Priority, i.Assignee, | |
| 78 | + ) | |
| 79 | + if err != nil { | |
| 80 | + return 0, err | |
| 81 | + } | |
| 82 | + return res.LastInsertId() | |
| 83 | +} | |
| 84 | + | |
| 85 | +// UpdateIssue updates an existing issue. | |
| 86 | +func UpdateIssue(i *Issue) error { | |
| 87 | + _, err := db.Exec( | |
| 88 | + `UPDATE issues SET title = ?, description = ?, status = ?, priority = ?, assignee = ? WHERE id = ?`, | |
| 89 | + i.Title, i.Description, i.Status, i.Priority, i.Assignee, i.ID, | |
| 90 | + ) | |
| 91 | + return err | |
| 92 | +} | |
| 93 | + | |
| 94 | +// DeleteIssue removes an issue by ID. | |
| 95 | +func DeleteIssue(id int) error { | |
| 96 | + _, err := db.Exec(`DELETE FROM issues WHERE id = ?`, id) | |
| 97 | + return err | |
| 98 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,98 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "database/sql" | ||
| 5 | + "fmt" | ||
| 6 | + | ||
| 7 | + _ "github.com/mattn/go-sqlite3" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +var db *sql.DB | ||
| 11 | + | ||
| 12 | +// InitDB opens the SQLite database and creates the issues table if needed. | ||
| 13 | +func InitDB(path string) error { | ||
| 14 | + var err error | ||
| 15 | + db, err = sql.Open("sqlite3", path) | ||
| 16 | + if err != nil { | ||
| 17 | + return fmt.Errorf("open database: %w", err) | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + schema := ` | ||
| 21 | + CREATE TABLE IF NOT EXISTS issues ( | ||
| 22 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 23 | + title TEXT NOT NULL, | ||
| 24 | + description TEXT NOT NULL DEFAULT '', | ||
| 25 | + status TEXT NOT NULL DEFAULT 'open', | ||
| 26 | + priority TEXT NOT NULL DEFAULT 'medium', | ||
| 27 | + assignee TEXT NOT NULL DEFAULT '' | ||
| 28 | + );` | ||
| 29 | + if _, err := db.Exec(schema); err != nil { | ||
| 30 | + db.Close() | ||
| 31 | + return fmt.Errorf("create table: %w", err) | ||
| 32 | + } | ||
| 33 | + return nil | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// CloseDB closes the database connection. | ||
| 37 | +func CloseDB() { | ||
| 38 | + if db != nil { | ||
| 39 | + db.Close() | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +// ListIssues returns all issues ordered by ID. | ||
| 44 | +func ListIssues() ([]Issue, error) { | ||
| 45 | + rows, err := db.Query(`SELECT id, title, description, status, priority, assignee FROM issues ORDER BY id`) | ||
| 46 | + if err != nil { | ||
| 47 | + return nil, err | ||
| 48 | + } | ||
| 49 | + defer rows.Close() | ||
| 50 | + | ||
| 51 | + issues := []Issue{} | ||
| 52 | + for rows.Next() { | ||
| 53 | + var i Issue | ||
| 54 | + if err := rows.Scan(&i.ID, &i.Title, &i.Description, &i.Status, &i.Priority, &i.Assignee); err != nil { | ||
| 55 | + return nil, err | ||
| 56 | + } | ||
| 57 | + issues = append(issues, i) | ||
| 58 | + } | ||
| 59 | + return issues, rows.Err() | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// GetIssue returns a single issue by ID. | ||
| 63 | +func GetIssue(id int) (*Issue, error) { | ||
| 64 | + var i Issue | ||
| 65 | + err := db.QueryRow(`SELECT id, title, description, status, priority, assignee FROM issues WHERE id = ?`, id). | ||
| 66 | + Scan(&i.ID, &i.Title, &i.Description, &i.Status, &i.Priority, &i.Assignee) | ||
| 67 | + if err != nil { | ||
| 68 | + return nil, err | ||
| 69 | + } | ||
| 70 | + return &i, nil | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +// CreateIssue inserts a new issue and returns its ID. | ||
| 74 | +func CreateIssue(i *Issue) (int64, error) { | ||
| 75 | + res, err := db.Exec( | ||
| 76 | + `INSERT INTO issues (title, description, status, priority, assignee) VALUES (?, ?, ?, ?, ?)`, | ||
| 77 | + i.Title, i.Description, i.Status, i.Priority, i.Assignee, | ||
| 78 | + ) | ||
| 79 | + if err != nil { | ||
| 80 | + return 0, err | ||
| 81 | + } | ||
| 82 | + return res.LastInsertId() | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +// UpdateIssue updates an existing issue. | ||
| 86 | +func UpdateIssue(i *Issue) error { | ||
| 87 | + _, err := db.Exec( | ||
| 88 | + `UPDATE issues SET title = ?, description = ?, status = ?, priority = ?, assignee = ? WHERE id = ?`, | ||
| 89 | + i.Title, i.Description, i.Status, i.Priority, i.Assignee, i.ID, | ||
| 90 | + ) | ||
| 91 | + return err | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// DeleteIssue removes an issue by ID. | ||
| 95 | +func DeleteIssue(id int) error { | ||
| 96 | + _, err := db.Exec(`DELETE FROM issues WHERE id = ?`, id) | ||
| 97 | + return err | ||
| 98 | +} | ||
added
dev.team/workspace/internal/app/handlers.go +190 -0 | new file mode 100644 | ||
| @@ -0,0 +1,190 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "html/template" | |
| 5 | + "net/http" | |
| 6 | + "strconv" | |
| 7 | + | |
| 8 | + "github.com/gin-gonic/gin" | |
| 9 | +) | |
| 10 | + | |
| 11 | +var templates map[string]*template.Template | |
| 12 | + | |
| 13 | +// NewRouter creates and configures the gin engine with all routes. | |
| 14 | +func NewRouter() *gin.Engine { | |
| 15 | + if err := loadTemplates(); err != nil { | |
| 16 | + panic("failed to load templates: " + err.Error()) | |
| 17 | + } | |
| 18 | + | |
| 19 | + templates = map[string]*template.Template{ | |
| 20 | + "list": listTmpl, | |
| 21 | + "form": formTmpl, | |
| 22 | + } | |
| 23 | + | |
| 24 | + r := gin.Default() | |
| 25 | + | |
| 26 | + r.GET("/", func(c *gin.Context) { | |
| 27 | + c.Redirect(http.StatusFound, "/issues") | |
| 28 | + }) | |
| 29 | + | |
| 30 | + issues := r.Group("/issues") | |
| 31 | + { | |
| 32 | + issues.GET("", listIssues) | |
| 33 | + issues.GET("/new", showCreateForm) | |
| 34 | + issues.POST("", createIssue) | |
| 35 | + issues.GET("/:id", showIssue) | |
| 36 | + issues.GET("/:id/edit", showEditForm) | |
| 37 | + issues.POST("/:id", updateIssue) | |
| 38 | + issues.POST("/:id/delete", deleteIssue) | |
| 39 | + } | |
| 40 | + | |
| 41 | + return r | |
| 42 | +} | |
| 43 | + | |
| 44 | +// render executes the named template with the given data and writes it. | |
| 45 | +func render(c *gin.Context, status int, name string, data gin.H) { | |
| 46 | + data["CSS"] = cssContent | |
| 47 | + | |
| 48 | + tmpl, ok := templates[name] | |
| 49 | + if !ok { | |
| 50 | + c.String(http.StatusInternalServerError, "template not found: %s", name) | |
| 51 | + return | |
| 52 | + } | |
| 53 | + | |
| 54 | + c.Status(status) | |
| 55 | + if err := tmpl.Execute(c.Writer, data); err != nil { | |
| 56 | + c.String(http.StatusInternalServerError, "render error: %v", err) | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +// parseIssue reads the issue form fields from the request. | |
| 61 | +func parseIssue(c *gin.Context) Issue { | |
| 62 | + return Issue{ | |
| 63 | + Title: c.PostForm("title"), | |
| 64 | + Description: c.PostForm("description"), | |
| 65 | + Status: c.PostForm("status"), | |
| 66 | + Priority: c.PostForm("priority"), | |
| 67 | + Assignee: c.PostForm("assignee"), | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +func listIssues(c *gin.Context) { | |
| 72 | + issues, err := ListIssues() | |
| 73 | + if err != nil { | |
| 74 | + c.String(http.StatusInternalServerError, "failed to list issues: %v", err) | |
| 75 | + return | |
| 76 | + } | |
| 77 | + render(c, http.StatusOK, "list", gin.H{ | |
| 78 | + "Issues": issues, | |
| 79 | + "Statuses": Statuses, | |
| 80 | + "Priorities": Priorities, | |
| 81 | + }) | |
| 82 | +} | |
| 83 | + | |
| 84 | +func showCreateForm(c *gin.Context) { | |
| 85 | + render(c, http.StatusOK, "form", gin.H{ | |
| 86 | + "Issue": Issue{}, | |
| 87 | + "Statuses": Statuses, | |
| 88 | + "Priorities": Priorities, | |
| 89 | + "Action": "/issues", | |
| 90 | + "Title": "New Issue", | |
| 91 | + }) | |
| 92 | +} | |
| 93 | + | |
| 94 | +func createIssue(c *gin.Context) { | |
| 95 | + issue := parseIssue(c) | |
| 96 | + if issue.Title == "" { | |
| 97 | + render(c, http.StatusBadRequest, "form", gin.H{ | |
| 98 | + "Issue": issue, | |
| 99 | + "Statuses": Statuses, | |
| 100 | + "Priorities": Priorities, | |
| 101 | + "Action": "/issues", | |
| 102 | + "Title": "New Issue", | |
| 103 | + "Error": "Title is required", | |
| 104 | + }) | |
| 105 | + return | |
| 106 | + } | |
| 107 | + id, err := CreateIssue(&issue) | |
| 108 | + if err != nil { | |
| 109 | + c.String(http.StatusInternalServerError, "failed to create issue: %v", err) | |
| 110 | + return | |
| 111 | + } | |
| 112 | + c.Redirect(http.StatusFound, "/issues/"+strconv.FormatInt(id, 10)) | |
| 113 | +} | |
| 114 | + | |
| 115 | +func showIssue(c *gin.Context) { | |
| 116 | + id, err := strconv.Atoi(c.Param("id")) | |
| 117 | + if err != nil { | |
| 118 | + c.String(http.StatusBadRequest, "invalid issue id") | |
| 119 | + return | |
| 120 | + } | |
| 121 | + issue, err := GetIssue(id) | |
| 122 | + if err != nil { | |
| 123 | + c.String(http.StatusNotFound, "issue not found") | |
| 124 | + return | |
| 125 | + } | |
| 126 | + render(c, http.StatusOK, "list", gin.H{ | |
| 127 | + "Current": issue, | |
| 128 | + "Statuses": Statuses, | |
| 129 | + "Priorities": Priorities, | |
| 130 | + }) | |
| 131 | +} | |
| 132 | + | |
| 133 | +func showEditForm(c *gin.Context) { | |
| 134 | + id, err := strconv.Atoi(c.Param("id")) | |
| 135 | + if err != nil { | |
| 136 | + c.String(http.StatusBadRequest, "invalid issue id") | |
| 137 | + return | |
| 138 | + } | |
| 139 | + issue, err := GetIssue(id) | |
| 140 | + if err != nil { | |
| 141 | + c.String(http.StatusNotFound, "issue not found") | |
| 142 | + return | |
| 143 | + } | |
| 144 | + render(c, http.StatusOK, "form", gin.H{ | |
| 145 | + "Issue": issue, | |
| 146 | + "Statuses": Statuses, | |
| 147 | + "Priorities": Priorities, | |
| 148 | + "Action": "/issues/" + strconv.Itoa(id), | |
| 149 | + "Title": "Edit Issue", | |
| 150 | + }) | |
| 151 | +} | |
| 152 | + | |
| 153 | +func updateIssue(c *gin.Context) { | |
| 154 | + id, err := strconv.Atoi(c.Param("id")) | |
| 155 | + if err != nil { | |
| 156 | + c.String(http.StatusBadRequest, "invalid issue id") | |
| 157 | + return | |
| 158 | + } | |
| 159 | + issue := parseIssue(c) | |
| 160 | + issue.ID = id | |
| 161 | + if issue.Title == "" { | |
| 162 | + render(c, http.StatusBadRequest, "form", gin.H{ | |
| 163 | + "Issue": issue, | |
| 164 | + "Statuses": Statuses, | |
| 165 | + "Priorities": Priorities, | |
| 166 | + "Action": "/issues/" + strconv.Itoa(id), | |
| 167 | + "Title": "Edit Issue", | |
| 168 | + "Error": "Title is required", | |
| 169 | + }) | |
| 170 | + return | |
| 171 | + } | |
| 172 | + if err := UpdateIssue(&issue); err != nil { | |
| 173 | + c.String(http.StatusInternalServerError, "failed to update issue: %v", err) | |
| 174 | + return | |
| 175 | + } | |
| 176 | + c.Redirect(http.StatusFound, "/issues/"+strconv.Itoa(id)) | |
| 177 | +} | |
| 178 | + | |
| 179 | +func deleteIssue(c *gin.Context) { | |
| 180 | + id, err := strconv.Atoi(c.Param("id")) | |
| 181 | + if err != nil { | |
| 182 | + c.String(http.StatusBadRequest, "invalid issue id") | |
| 183 | + return | |
| 184 | + } | |
| 185 | + if err := DeleteIssue(id); err != nil { | |
| 186 | + c.String(http.StatusInternalServerError, "failed to delete issue: %v", err) | |
| 187 | + return | |
| 188 | + } | |
| 189 | + c.Redirect(http.StatusFound, "/issues") | |
| 190 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,190 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "html/template" | ||
| 5 | + "net/http" | ||
| 6 | + "strconv" | ||
| 7 | + | ||
| 8 | + "github.com/gin-gonic/gin" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +var templates map[string]*template.Template | ||
| 12 | + | ||
| 13 | +// NewRouter creates and configures the gin engine with all routes. | ||
| 14 | +func NewRouter() *gin.Engine { | ||
| 15 | + if err := loadTemplates(); err != nil { | ||
| 16 | + panic("failed to load templates: " + err.Error()) | ||
| 17 | + } | ||
| 18 | + | ||
| 19 | + templates = map[string]*template.Template{ | ||
| 20 | + "list": listTmpl, | ||
| 21 | + "form": formTmpl, | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + r := gin.Default() | ||
| 25 | + | ||
| 26 | + r.GET("/", func(c *gin.Context) { | ||
| 27 | + c.Redirect(http.StatusFound, "/issues") | ||
| 28 | + }) | ||
| 29 | + | ||
| 30 | + issues := r.Group("/issues") | ||
| 31 | + { | ||
| 32 | + issues.GET("", listIssues) | ||
| 33 | + issues.GET("/new", showCreateForm) | ||
| 34 | + issues.POST("", createIssue) | ||
| 35 | + issues.GET("/:id", showIssue) | ||
| 36 | + issues.GET("/:id/edit", showEditForm) | ||
| 37 | + issues.POST("/:id", updateIssue) | ||
| 38 | + issues.POST("/:id/delete", deleteIssue) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + return r | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// render executes the named template with the given data and writes it. | ||
| 45 | +func render(c *gin.Context, status int, name string, data gin.H) { | ||
| 46 | + data["CSS"] = cssContent | ||
| 47 | + | ||
| 48 | + tmpl, ok := templates[name] | ||
| 49 | + if !ok { | ||
| 50 | + c.String(http.StatusInternalServerError, "template not found: %s", name) | ||
| 51 | + return | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + c.Status(status) | ||
| 55 | + if err := tmpl.Execute(c.Writer, data); err != nil { | ||
| 56 | + c.String(http.StatusInternalServerError, "render error: %v", err) | ||
| 57 | + } | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// parseIssue reads the issue form fields from the request. | ||
| 61 | +func parseIssue(c *gin.Context) Issue { | ||
| 62 | + return Issue{ | ||
| 63 | + Title: c.PostForm("title"), | ||
| 64 | + Description: c.PostForm("description"), | ||
| 65 | + Status: c.PostForm("status"), | ||
| 66 | + Priority: c.PostForm("priority"), | ||
| 67 | + Assignee: c.PostForm("assignee"), | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +func listIssues(c *gin.Context) { | ||
| 72 | + issues, err := ListIssues() | ||
| 73 | + if err != nil { | ||
| 74 | + c.String(http.StatusInternalServerError, "failed to list issues: %v", err) | ||
| 75 | + return | ||
| 76 | + } | ||
| 77 | + render(c, http.StatusOK, "list", gin.H{ | ||
| 78 | + "Issues": issues, | ||
| 79 | + "Statuses": Statuses, | ||
| 80 | + "Priorities": Priorities, | ||
| 81 | + }) | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +func showCreateForm(c *gin.Context) { | ||
| 85 | + render(c, http.StatusOK, "form", gin.H{ | ||
| 86 | + "Issue": Issue{}, | ||
| 87 | + "Statuses": Statuses, | ||
| 88 | + "Priorities": Priorities, | ||
| 89 | + "Action": "/issues", | ||
| 90 | + "Title": "New Issue", | ||
| 91 | + }) | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func createIssue(c *gin.Context) { | ||
| 95 | + issue := parseIssue(c) | ||
| 96 | + if issue.Title == "" { | ||
| 97 | + render(c, http.StatusBadRequest, "form", gin.H{ | ||
| 98 | + "Issue": issue, | ||
| 99 | + "Statuses": Statuses, | ||
| 100 | + "Priorities": Priorities, | ||
| 101 | + "Action": "/issues", | ||
| 102 | + "Title": "New Issue", | ||
| 103 | + "Error": "Title is required", | ||
| 104 | + }) | ||
| 105 | + return | ||
| 106 | + } | ||
| 107 | + id, err := CreateIssue(&issue) | ||
| 108 | + if err != nil { | ||
| 109 | + c.String(http.StatusInternalServerError, "failed to create issue: %v", err) | ||
| 110 | + return | ||
| 111 | + } | ||
| 112 | + c.Redirect(http.StatusFound, "/issues/"+strconv.FormatInt(id, 10)) | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +func showIssue(c *gin.Context) { | ||
| 116 | + id, err := strconv.Atoi(c.Param("id")) | ||
| 117 | + if err != nil { | ||
| 118 | + c.String(http.StatusBadRequest, "invalid issue id") | ||
| 119 | + return | ||
| 120 | + } | ||
| 121 | + issue, err := GetIssue(id) | ||
| 122 | + if err != nil { | ||
| 123 | + c.String(http.StatusNotFound, "issue not found") | ||
| 124 | + return | ||
| 125 | + } | ||
| 126 | + render(c, http.StatusOK, "list", gin.H{ | ||
| 127 | + "Current": issue, | ||
| 128 | + "Statuses": Statuses, | ||
| 129 | + "Priorities": Priorities, | ||
| 130 | + }) | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +func showEditForm(c *gin.Context) { | ||
| 134 | + id, err := strconv.Atoi(c.Param("id")) | ||
| 135 | + if err != nil { | ||
| 136 | + c.String(http.StatusBadRequest, "invalid issue id") | ||
| 137 | + return | ||
| 138 | + } | ||
| 139 | + issue, err := GetIssue(id) | ||
| 140 | + if err != nil { | ||
| 141 | + c.String(http.StatusNotFound, "issue not found") | ||
| 142 | + return | ||
| 143 | + } | ||
| 144 | + render(c, http.StatusOK, "form", gin.H{ | ||
| 145 | + "Issue": issue, | ||
| 146 | + "Statuses": Statuses, | ||
| 147 | + "Priorities": Priorities, | ||
| 148 | + "Action": "/issues/" + strconv.Itoa(id), | ||
| 149 | + "Title": "Edit Issue", | ||
| 150 | + }) | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +func updateIssue(c *gin.Context) { | ||
| 154 | + id, err := strconv.Atoi(c.Param("id")) | ||
| 155 | + if err != nil { | ||
| 156 | + c.String(http.StatusBadRequest, "invalid issue id") | ||
| 157 | + return | ||
| 158 | + } | ||
| 159 | + issue := parseIssue(c) | ||
| 160 | + issue.ID = id | ||
| 161 | + if issue.Title == "" { | ||
| 162 | + render(c, http.StatusBadRequest, "form", gin.H{ | ||
| 163 | + "Issue": issue, | ||
| 164 | + "Statuses": Statuses, | ||
| 165 | + "Priorities": Priorities, | ||
| 166 | + "Action": "/issues/" + strconv.Itoa(id), | ||
| 167 | + "Title": "Edit Issue", | ||
| 168 | + "Error": "Title is required", | ||
| 169 | + }) | ||
| 170 | + return | ||
| 171 | + } | ||
| 172 | + if err := UpdateIssue(&issue); err != nil { | ||
| 173 | + c.String(http.StatusInternalServerError, "failed to update issue: %v", err) | ||
| 174 | + return | ||
| 175 | + } | ||
| 176 | + c.Redirect(http.StatusFound, "/issues/"+strconv.Itoa(id)) | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +func deleteIssue(c *gin.Context) { | ||
| 180 | + id, err := strconv.Atoi(c.Param("id")) | ||
| 181 | + if err != nil { | ||
| 182 | + c.String(http.StatusBadRequest, "invalid issue id") | ||
| 183 | + return | ||
| 184 | + } | ||
| 185 | + if err := DeleteIssue(id); err != nil { | ||
| 186 | + c.String(http.StatusInternalServerError, "failed to delete issue: %v", err) | ||
| 187 | + return | ||
| 188 | + } | ||
| 189 | + c.Redirect(http.StatusFound, "/issues") | ||
| 190 | +} | ||
added
dev.team/workspace/internal/app/models.go +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +// Issue represents a project issue tracked in the application. | |
| 4 | +type Issue struct { | |
| 5 | + ID int `json:"id"` | |
| 6 | + Title string `json:"title"` | |
| 7 | + Description string `json:"description"` | |
| 8 | + Status string `json:"status"` | |
| 9 | + Priority string `json:"priority"` | |
| 10 | + Assignee string `json:"assignee"` | |
| 11 | +} | |
| 12 | + | |
| 13 | +// Valid values for the Status and Priority fields. | |
| 14 | +var ( | |
| 15 | + Statuses = []string{"open", "in_progress", "done", "closed"} | |
| 16 | + Priorities = []string{"low", "medium", "high", "critical"} | |
| 17 | +) | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +// Issue represents a project issue tracked in the application. | ||
| 4 | +type Issue struct { | ||
| 5 | + ID int `json:"id"` | ||
| 6 | + Title string `json:"title"` | ||
| 7 | + Description string `json:"description"` | ||
| 8 | + Status string `json:"status"` | ||
| 9 | + Priority string `json:"priority"` | ||
| 10 | + Assignee string `json:"assignee"` | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +// Valid values for the Status and Priority fields. | ||
| 14 | +var ( | ||
| 15 | + Statuses = []string{"open", "in_progress", "done", "closed"} | ||
| 16 | + Priorities = []string{"low", "medium", "high", "critical"} | ||
| 17 | +) | ||
added
dev.team/workspace/internal/app/review_test.go +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "net/http/httptest" | |
| 6 | + "path/filepath" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "github.com/gin-gonic/gin" | |
| 10 | +) | |
| 11 | + | |
| 12 | +func TestTemplates(t *testing.T) { | |
| 13 | + gin.SetMode(gin.TestMode) | |
| 14 | + if err := InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { | |
| 15 | + t.Fatalf("failed to init db: %v", err) | |
| 16 | + } | |
| 17 | + defer CloseDB() | |
| 18 | + templatesDir = "../../templates" | |
| 19 | + router := NewRouter() | |
| 20 | + | |
| 21 | + tests := []struct { | |
| 22 | + name string | |
| 23 | + url string | |
| 24 | + method string | |
| 25 | + expectedStatus int | |
| 26 | + expectedBody string // partial match | |
| 27 | + }{ | |
| 28 | + { | |
| 29 | + name: "List Issues", | |
| 30 | + url: "/issues", | |
| 31 | + method: "GET", | |
| 32 | + expectedStatus: http.StatusOK, | |
| 33 | + expectedBody: "Issues", | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + name: "Show Create Form", | |
| 37 | + url: "/issues/new", | |
| 38 | + method: "GET", | |
| 39 | + expectedStatus: http.StatusOK, | |
| 40 | + expectedBody: "New Issue", | |
| 41 | + }, | |
| 42 | + { | |
| 43 | + name: "Show Issue Detail (Non-existent)", | |
| 44 | + url: "/issues/99999", | |
| 45 | + method: "GET", | |
| 46 | + expectedStatus: http.StatusNotFound, | |
| 47 | + expectedBody: "issue not found", | |
| 48 | + }, | |
| 49 | + } | |
| 50 | + | |
| 51 | + for _, tt := range tests { | |
| 52 | + t.Run(tt.name, func(t *testing.T) { | |
| 53 | + w := httptest.NewRecorder() | |
| 54 | + req, _ := http.NewRequest(tt.method, tt.url, nil) | |
| 55 | + router.ServeHTTP(w, req) | |
| 56 | + | |
| 57 | + if w.Code != tt.expectedStatus { | |
| 58 | + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) | |
| 59 | + } | |
| 60 | + | |
| 61 | + if tt.expectedBody != "" && !contains(w.Body.String(), tt.expectedBody) { | |
| 62 | + t.Errorf("expected body to contain %q, got %q", tt.expectedBody, w.Body.String()) | |
| 63 | + } | |
| 64 | + }) | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +func contains(s, substr string) bool { | |
| 69 | + return len(s) >= len(substr) && (s == substr || func() bool { | |
| 70 | + for i := 0; i <= len(s)-len(substr); i++ { | |
| 71 | + if s[i:i+len(substr)] == substr { | |
| 72 | + return true | |
| 73 | + } | |
| 74 | + } | |
| 75 | + return false | |
| 76 | + }()) | |
| 77 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + "net/http/httptest" | ||
| 6 | + "path/filepath" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "github.com/gin-gonic/gin" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +func TestTemplates(t *testing.T) { | ||
| 13 | + gin.SetMode(gin.TestMode) | ||
| 14 | + if err := InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { | ||
| 15 | + t.Fatalf("failed to init db: %v", err) | ||
| 16 | + } | ||
| 17 | + defer CloseDB() | ||
| 18 | + templatesDir = "../../templates" | ||
| 19 | + router := NewRouter() | ||
| 20 | + | ||
| 21 | + tests := []struct { | ||
| 22 | + name string | ||
| 23 | + url string | ||
| 24 | + method string | ||
| 25 | + expectedStatus int | ||
| 26 | + expectedBody string // partial match | ||
| 27 | + }{ | ||
| 28 | + { | ||
| 29 | + name: "List Issues", | ||
| 30 | + url: "/issues", | ||
| 31 | + method: "GET", | ||
| 32 | + expectedStatus: http.StatusOK, | ||
| 33 | + expectedBody: "Issues", | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + name: "Show Create Form", | ||
| 37 | + url: "/issues/new", | ||
| 38 | + method: "GET", | ||
| 39 | + expectedStatus: http.StatusOK, | ||
| 40 | + expectedBody: "New Issue", | ||
| 41 | + }, | ||
| 42 | + { | ||
| 43 | + name: "Show Issue Detail (Non-existent)", | ||
| 44 | + url: "/issues/99999", | ||
| 45 | + method: "GET", | ||
| 46 | + expectedStatus: http.StatusNotFound, | ||
| 47 | + expectedBody: "issue not found", | ||
| 48 | + }, | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + for _, tt := range tests { | ||
| 52 | + t.Run(tt.name, func(t *testing.T) { | ||
| 53 | + w := httptest.NewRecorder() | ||
| 54 | + req, _ := http.NewRequest(tt.method, tt.url, nil) | ||
| 55 | + router.ServeHTTP(w, req) | ||
| 56 | + | ||
| 57 | + if w.Code != tt.expectedStatus { | ||
| 58 | + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + if tt.expectedBody != "" && !contains(w.Body.String(), tt.expectedBody) { | ||
| 62 | + t.Errorf("expected body to contain %q, got %q", tt.expectedBody, w.Body.String()) | ||
| 63 | + } | ||
| 64 | + }) | ||
| 65 | + } | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +func contains(s, substr string) bool { | ||
| 69 | + return len(s) >= len(substr) && (s == substr || func() bool { | ||
| 70 | + for i := 0; i <= len(s)-len(substr); i++ { | ||
| 71 | + if s[i:i+len(substr)] == substr { | ||
| 72 | + return true | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + return false | ||
| 76 | + }()) | ||
| 77 | +} | ||
added
dev.team/workspace/internal/app/templates.go +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "html/template" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | +) | |
| 8 | + | |
| 9 | +var ( | |
| 10 | + listTmpl *template.Template | |
| 11 | + formTmpl *template.Template | |
| 12 | + cssContent template.CSS | |
| 13 | +) | |
| 14 | + | |
| 15 | +// templatesDir is the directory that holds the CSS and HTML templates. | |
| 16 | +// It defaults to "templates" and can be overridden (e.g. in tests). | |
| 17 | +var templatesDir = "templates" | |
| 18 | + | |
| 19 | +// loadTemplates reads the CSS and HTML templates from the templates/ directory. | |
| 20 | +func loadTemplates() error { | |
| 21 | + cssBytes, err := os.ReadFile(filepath.Join(templatesDir, "style.css")) | |
| 22 | + if err != nil { | |
| 23 | + return err | |
| 24 | + } | |
| 25 | + cssContent = template.CSS(cssBytes) | |
| 26 | + | |
| 27 | + listTmpl, err = template.ParseFiles(filepath.Join(templatesDir, "layout.html"), filepath.Join(templatesDir, "list.html")) | |
| 28 | + if err != nil { | |
| 29 | + return err | |
| 30 | + } | |
| 31 | + | |
| 32 | + formTmpl, err = template.ParseFiles(filepath.Join(templatesDir, "layout.html"), filepath.Join(templatesDir, "form.html")) | |
| 33 | + if err != nil { | |
| 34 | + return err | |
| 35 | + } | |
| 36 | + | |
| 37 | + return nil | |
| 38 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "html/template" | ||
| 5 | + "os" | ||
| 6 | + "path/filepath" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +var ( | ||
| 10 | + listTmpl *template.Template | ||
| 11 | + formTmpl *template.Template | ||
| 12 | + cssContent template.CSS | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// templatesDir is the directory that holds the CSS and HTML templates. | ||
| 16 | +// It defaults to "templates" and can be overridden (e.g. in tests). | ||
| 17 | +var templatesDir = "templates" | ||
| 18 | + | ||
| 19 | +// loadTemplates reads the CSS and HTML templates from the templates/ directory. | ||
| 20 | +func loadTemplates() error { | ||
| 21 | + cssBytes, err := os.ReadFile(filepath.Join(templatesDir, "style.css")) | ||
| 22 | + if err != nil { | ||
| 23 | + return err | ||
| 24 | + } | ||
| 25 | + cssContent = template.CSS(cssBytes) | ||
| 26 | + | ||
| 27 | + listTmpl, err = template.ParseFiles(filepath.Join(templatesDir, "layout.html"), filepath.Join(templatesDir, "list.html")) | ||
| 28 | + if err != nil { | ||
| 29 | + return err | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + formTmpl, err = template.ParseFiles(filepath.Join(templatesDir, "layout.html"), filepath.Join(templatesDir, "form.html")) | ||
| 33 | + if err != nil { | ||
| 34 | + return err | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + return nil | ||
| 38 | +} | ||
added
dev.team/workspace/issueapp +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/dev.team/workspace/issueapp differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/dev.team/workspace/issueapp differ | Binary files /dev/null and b/dev.team/workspace/issueapp differ | ||
added
dev.team/workspace/issues.db +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/dev.team/workspace/issues.db differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/dev.team/workspace/issues.db differ | Binary files /dev/null and b/dev.team/workspace/issues.db differ | ||
added
dev.team/workspace/review_test.go +78 -0 | new file mode 100644 | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +package issueapp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "net/http/httptest" | |
| 6 | + "path/filepath" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "github.com/gin-gonic/gin" | |
| 10 | + | |
| 11 | + "issueapp/internal/app" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func TestTemplates(t *testing.T) { | |
| 15 | + gin.SetMode(gin.TestMode) | |
| 16 | + if err := app.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { | |
| 17 | + t.Fatalf("failed to init db: %v", err) | |
| 18 | + } | |
| 19 | + defer app.CloseDB() | |
| 20 | + router := app.NewRouter() | |
| 21 | + | |
| 22 | + tests := []struct { | |
| 23 | + name string | |
| 24 | + url string | |
| 25 | + method string | |
| 26 | + expectedStatus int | |
| 27 | + expectedBody string // partial match | |
| 28 | + }{ | |
| 29 | + { | |
| 30 | + name: "List Issues", | |
| 31 | + url: "/issues", | |
| 32 | + method: "GET", | |
| 33 | + expectedStatus: http.StatusOK, | |
| 34 | + expectedBody: "Issues", | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + name: "Show Create Form", | |
| 38 | + url: "/issues/new", | |
| 39 | + method: "GET", | |
| 40 | + expectedStatus: http.StatusOK, | |
| 41 | + expectedBody: "New Issue", | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + name: "Show Issue Detail (Non-existent)", | |
| 45 | + url: "/issues/99999", | |
| 46 | + method: "GET", | |
| 47 | + expectedStatus: http.StatusNotFound, | |
| 48 | + expectedBody: "issue not found", | |
| 49 | + }, | |
| 50 | + } | |
| 51 | + | |
| 52 | + for _, tt := range tests { | |
| 53 | + t.Run(tt.name, func(t *testing.T) { | |
| 54 | + w := httptest.NewRecorder() | |
| 55 | + req, _ := http.NewRequest(tt.method, tt.url, nil) | |
| 56 | + router.ServeHTTP(w, req) | |
| 57 | + | |
| 58 | + if w.Code != tt.expectedStatus { | |
| 59 | + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) | |
| 60 | + } | |
| 61 | + | |
| 62 | + if tt.expectedBody != "" && !contains(w.Body.String(), tt.expectedBody) { | |
| 63 | + t.Errorf("expected body to contain %q, got %q", tt.expectedBody, w.Body.String()) | |
| 64 | + } | |
| 65 | + }) | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +func contains(s, substr string) bool { | |
| 70 | + return len(s) >= len(substr) && (s == substr || func() bool { | |
| 71 | + for i := 0; i <= len(s)-len(substr); i++ { | |
| 72 | + if s[i:i+len(substr)] == substr { | |
| 73 | + return true | |
| 74 | + } | |
| 75 | + } | |
| 76 | + return false | |
| 77 | + }()) | |
| 78 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | +package issueapp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + "net/http/httptest" | ||
| 6 | + "path/filepath" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "github.com/gin-gonic/gin" | ||
| 10 | + | ||
| 11 | + "issueapp/internal/app" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +func TestTemplates(t *testing.T) { | ||
| 15 | + gin.SetMode(gin.TestMode) | ||
| 16 | + if err := app.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { | ||
| 17 | + t.Fatalf("failed to init db: %v", err) | ||
| 18 | + } | ||
| 19 | + defer app.CloseDB() | ||
| 20 | + router := app.NewRouter() | ||
| 21 | + | ||
| 22 | + tests := []struct { | ||
| 23 | + name string | ||
| 24 | + url string | ||
| 25 | + method string | ||
| 26 | + expectedStatus int | ||
| 27 | + expectedBody string // partial match | ||
| 28 | + }{ | ||
| 29 | + { | ||
| 30 | + name: "List Issues", | ||
| 31 | + url: "/issues", | ||
| 32 | + method: "GET", | ||
| 33 | + expectedStatus: http.StatusOK, | ||
| 34 | + expectedBody: "Issues", | ||
| 35 | + }, | ||
| 36 | + { | ||
| 37 | + name: "Show Create Form", | ||
| 38 | + url: "/issues/new", | ||
| 39 | + method: "GET", | ||
| 40 | + expectedStatus: http.StatusOK, | ||
| 41 | + expectedBody: "New Issue", | ||
| 42 | + }, | ||
| 43 | + { | ||
| 44 | + name: "Show Issue Detail (Non-existent)", | ||
| 45 | + url: "/issues/99999", | ||
| 46 | + method: "GET", | ||
| 47 | + expectedStatus: http.StatusNotFound, | ||
| 48 | + expectedBody: "issue not found", | ||
| 49 | + }, | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + for _, tt := range tests { | ||
| 53 | + t.Run(tt.name, func(t *testing.T) { | ||
| 54 | + w := httptest.NewRecorder() | ||
| 55 | + req, _ := http.NewRequest(tt.method, tt.url, nil) | ||
| 56 | + router.ServeHTTP(w, req) | ||
| 57 | + | ||
| 58 | + if w.Code != tt.expectedStatus { | ||
| 59 | + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + if tt.expectedBody != "" && !contains(w.Body.String(), tt.expectedBody) { | ||
| 63 | + t.Errorf("expected body to contain %q, got %q", tt.expectedBody, w.Body.String()) | ||
| 64 | + } | ||
| 65 | + }) | ||
| 66 | + } | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +func contains(s, substr string) bool { | ||
| 70 | + return len(s) >= len(substr) && (s == substr || func() bool { | ||
| 71 | + for i := 0; i <= len(s)-len(substr); i++ { | ||
| 72 | + if s[i:i+len(substr)] == substr { | ||
| 73 | + return true | ||
| 74 | + } | ||
| 75 | + } | ||
| 76 | + return false | ||
| 77 | + }()) | ||
| 78 | +} | ||
added
dev.team/workspace/templates/form.html +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="page-header"> | |
| 3 | + <h1>{{.Title}}</h1> | |
| 4 | +</div> | |
| 5 | + | |
| 6 | +{{if .Error}} | |
| 7 | +<div class="alert alert-error">{{.Error}}</div> | |
| 8 | +{{end}} | |
| 9 | + | |
| 10 | +<form method="POST" action="{{.Action}}" class="issue-form"> | |
| 11 | + <div class="form-group"> | |
| 12 | + <label for="title">Title</label> | |
| 13 | + <input type="text" id="title" name="title" value="{{.Issue.Title}}" required> | |
| 14 | + </div> | |
| 15 | + | |
| 16 | + <div class="form-group"> | |
| 17 | + <label for="description">Description</label> | |
| 18 | + <textarea id="description" name="description" rows="5">{{.Issue.Description}}</textarea> | |
| 19 | + </div> | |
| 20 | + | |
| 21 | + <div class="form-row"> | |
| 22 | + <div class="form-group"> | |
| 23 | + <label for="status">Status</label> | |
| 24 | + <select id="status" name="status"> | |
| 25 | + {{range .Statuses}} | |
| 26 | + <option value="{{.}}" {{if eq . $.Issue.Status}}selected{{end}}>{{.}}</option> | |
| 27 | + {{end}} | |
| 28 | + </select> | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + <div class="form-group"> | |
| 32 | + <label for="priority">Priority</label> | |
| 33 | + <select id="priority" name="priority"> | |
| 34 | + {{range .Priorities}} | |
| 35 | + <option value="{{.}}" {{if eq . $.Issue.Priority}}selected{{end}}>{{.}}</option> | |
| 36 | + {{end}} | |
| 37 | + </select> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <div class="form-group"> | |
| 42 | + <label for="assignee">Assignee</label> | |
| 43 | + <input type="text" id="assignee" name="assignee" value="{{.Issue.Assignee}}"> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <div class="actions"> | |
| 47 | + <button type="submit" class="btn btn-primary">Save</button> | |
| 48 | + <a href="/issues" class="btn">Cancel</a> | |
| 49 | + </div> | |
| 50 | +</form>{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +{{define "content"}} | ||
| 2 | +<div class="page-header"> | ||
| 3 | + <h1>{{.Title}}</h1> | ||
| 4 | +</div> | ||
| 5 | + | ||
| 6 | +{{if .Error}} | ||
| 7 | +<div class="alert alert-error">{{.Error}}</div> | ||
| 8 | +{{end}} | ||
| 9 | + | ||
| 10 | +<form method="POST" action="{{.Action}}" class="issue-form"> | ||
| 11 | + <div class="form-group"> | ||
| 12 | + <label for="title">Title</label> | ||
| 13 | + <input type="text" id="title" name="title" value="{{.Issue.Title}}" required> | ||
| 14 | + </div> | ||
| 15 | + | ||
| 16 | + <div class="form-group"> | ||
| 17 | + <label for="description">Description</label> | ||
| 18 | + <textarea id="description" name="description" rows="5">{{.Issue.Description}}</textarea> | ||
| 19 | + </div> | ||
| 20 | + | ||
| 21 | + <div class="form-row"> | ||
| 22 | + <div class="form-group"> | ||
| 23 | + <label for="status">Status</label> | ||
| 24 | + <select id="status" name="status"> | ||
| 25 | + {{range .Statuses}} | ||
| 26 | + <option value="{{.}}" {{if eq . $.Issue.Status}}selected{{end}}>{{.}}</option> | ||
| 27 | + {{end}} | ||
| 28 | + </select> | ||
| 29 | + </div> | ||
| 30 | + | ||
| 31 | + <div class="form-group"> | ||
| 32 | + <label for="priority">Priority</label> | ||
| 33 | + <select id="priority" name="priority"> | ||
| 34 | + {{range .Priorities}} | ||
| 35 | + <option value="{{.}}" {{if eq . $.Issue.Priority}}selected{{end}}>{{.}}</option> | ||
| 36 | + {{end}} | ||
| 37 | + </select> | ||
| 38 | + </div> | ||
| 39 | + </div> | ||
| 40 | + | ||
| 41 | + <div class="form-group"> | ||
| 42 | + <label for="assignee">Assignee</label> | ||
| 43 | + <input type="text" id="assignee" name="assignee" value="{{.Issue.Assignee}}"> | ||
| 44 | + </div> | ||
| 45 | + | ||
| 46 | + <div class="actions"> | ||
| 47 | + <button type="submit" class="btn btn-primary">Save</button> | ||
| 48 | + <a href="/issues" class="btn">Cancel</a> | ||
| 49 | + </div> | ||
| 50 | +</form>{{end}} | ||
added
dev.team/workspace/templates/layout.html +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>Issue Tracker</title> | |
| 7 | + <style>{{.CSS}}</style> | |
| 8 | +</head> | |
| 9 | +<body> | |
| 10 | + <header> | |
| 11 | + <nav> | |
| 12 | + <a href="/issues" class="brand">Issue Tracker</a> | |
| 13 | + <a href="/issues/new" class="btn btn-primary">+ New Issue</a> | |
| 14 | + </nav> | |
| 15 | + </header> | |
| 16 | + <main> | |
| 17 | +{{template "content" .}} | |
| 18 | + </main> | |
| 19 | +</body> | |
| 20 | +</html> | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +<!DOCTYPE html> | ||
| 2 | +<html lang="en"> | ||
| 3 | +<head> | ||
| 4 | + <meta charset="UTF-8"> | ||
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | + <title>Issue Tracker</title> | ||
| 7 | + <style>{{.CSS}}</style> | ||
| 8 | +</head> | ||
| 9 | +<body> | ||
| 10 | + <header> | ||
| 11 | + <nav> | ||
| 12 | + <a href="/issues" class="brand">Issue Tracker</a> | ||
| 13 | + <a href="/issues/new" class="btn btn-primary">+ New Issue</a> | ||
| 14 | + </nav> | ||
| 15 | + </header> | ||
| 16 | + <main> | ||
| 17 | +{{template "content" .}} | ||
| 18 | + </main> | ||
| 19 | +</body> | ||
| 20 | +</html> | ||
added
dev.team/workspace/templates/list.html +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +<div class="page-header"> | |
| 3 | + <h1>Issues</h1> | |
| 4 | +</div> | |
| 5 | + | |
| 6 | +{{if .Current}} | |
| 7 | +<div class="issue-detail"> | |
| 8 | + <h2>#{{.Current.ID}} {{.Current.Title}}</h2> | |
| 9 | + <p class="description">{{.Current.Description}}</p> | |
| 10 | + <table class="meta"> | |
| 11 | + <tr><td>Status</td><td><span class="badge badge-{{.Current.Status}}">{{.Current.Status}}</span></td></tr> | |
| 12 | + <tr><td>Priority</td><td><span class="badge badge-{{.Current.Priority}}">{{.Current.Priority}}</span></td></tr> | |
| 13 | + <tr><td>Assignee</td><td>{{.Current.Assignee}}</td></tr> | |
| 14 | + </table> | |
| 15 | + <div class="actions"> | |
| 16 | + <a href="/issues/{{.Current.ID}}/edit" class="btn">Edit</a> | |
| 17 | + <form method="POST" action="/issues/{{.Current.ID}}/delete" onsubmit="return confirm('Delete this issue?');"> | |
| 18 | + <button type="submit" class="btn btn-danger">Delete</button> | |
| 19 | + </form> | |
| 20 | + </div> | |
| 21 | +</div> | |
| 22 | +{{else}} | |
| 23 | +{{if .Issues}} | |
| 24 | +<table class="issues-table"> | |
| 25 | + <thead> | |
| 26 | + <tr> | |
| 27 | + <th>ID</th> | |
| 28 | + <th>Title</th> | |
| 29 | + <th>Status</th> | |
| 30 | + <th>Priority</th> | |
| 31 | + <th>Assignee</th> | |
| 32 | + <th></th> | |
| 33 | + </tr> | |
| 34 | + </thead> | |
| 35 | + <tbody> | |
| 36 | + {{range .Issues}} | |
| 37 | + <tr> | |
| 38 | + <td>{{.ID}}</td> | |
| 39 | + <td><a href="/issues/{{.ID}}">{{.Title}}</a></td> | |
| 40 | + <td><span class="badge badge-{{.Status}}">{{.Status}}</span></td> | |
| 41 | + <td><span class="badge badge-{{.Priority}}">{{.Priority}}</span></td> | |
| 42 | + <td>{{.Assignee}}</td> | |
| 43 | + <td class="row-actions"> | |
| 44 | + <a href="/issues/{{.ID}}/edit" class="btn btn-sm">Edit</a> | |
| 45 | + <form method="POST" action="/issues/{{.ID}}/delete" onsubmit="return confirm('Delete this issue?');" style="display:inline;"> | |
| 46 | + <button type="submit" class="btn btn-sm btn-danger">Delete</button> | |
| 47 | + </form> | |
| 48 | + </td> | |
| 49 | + </tr> | |
| 50 | + {{end}} | |
| 51 | + </tbody> | |
| 52 | +</table> | |
| 53 | +{{else}} | |
| 54 | +<p class="empty">No issues yet. <a href="/issues/new">Create one</a>.</p> | |
| 55 | +{{end}} | |
| 56 | +{{end}}{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +{{define "content"}} | ||
| 2 | +<div class="page-header"> | ||
| 3 | + <h1>Issues</h1> | ||
| 4 | +</div> | ||
| 5 | + | ||
| 6 | +{{if .Current}} | ||
| 7 | +<div class="issue-detail"> | ||
| 8 | + <h2>#{{.Current.ID}} {{.Current.Title}}</h2> | ||
| 9 | + <p class="description">{{.Current.Description}}</p> | ||
| 10 | + <table class="meta"> | ||
| 11 | + <tr><td>Status</td><td><span class="badge badge-{{.Current.Status}}">{{.Current.Status}}</span></td></tr> | ||
| 12 | + <tr><td>Priority</td><td><span class="badge badge-{{.Current.Priority}}">{{.Current.Priority}}</span></td></tr> | ||
| 13 | + <tr><td>Assignee</td><td>{{.Current.Assignee}}</td></tr> | ||
| 14 | + </table> | ||
| 15 | + <div class="actions"> | ||
| 16 | + <a href="/issues/{{.Current.ID}}/edit" class="btn">Edit</a> | ||
| 17 | + <form method="POST" action="/issues/{{.Current.ID}}/delete" onsubmit="return confirm('Delete this issue?');"> | ||
| 18 | + <button type="submit" class="btn btn-danger">Delete</button> | ||
| 19 | + </form> | ||
| 20 | + </div> | ||
| 21 | +</div> | ||
| 22 | +{{else}} | ||
| 23 | +{{if .Issues}} | ||
| 24 | +<table class="issues-table"> | ||
| 25 | + <thead> | ||
| 26 | + <tr> | ||
| 27 | + <th>ID</th> | ||
| 28 | + <th>Title</th> | ||
| 29 | + <th>Status</th> | ||
| 30 | + <th>Priority</th> | ||
| 31 | + <th>Assignee</th> | ||
| 32 | + <th></th> | ||
| 33 | + </tr> | ||
| 34 | + </thead> | ||
| 35 | + <tbody> | ||
| 36 | + {{range .Issues}} | ||
| 37 | + <tr> | ||
| 38 | + <td>{{.ID}}</td> | ||
| 39 | + <td><a href="/issues/{{.ID}}">{{.Title}}</a></td> | ||
| 40 | + <td><span class="badge badge-{{.Status}}">{{.Status}}</span></td> | ||
| 41 | + <td><span class="badge badge-{{.Priority}}">{{.Priority}}</span></td> | ||
| 42 | + <td>{{.Assignee}}</td> | ||
| 43 | + <td class="row-actions"> | ||
| 44 | + <a href="/issues/{{.ID}}/edit" class="btn btn-sm">Edit</a> | ||
| 45 | + <form method="POST" action="/issues/{{.ID}}/delete" onsubmit="return confirm('Delete this issue?');" style="display:inline;"> | ||
| 46 | + <button type="submit" class="btn btn-sm btn-danger">Delete</button> | ||
| 47 | + </form> | ||
| 48 | + </td> | ||
| 49 | + </tr> | ||
| 50 | + {{end}} | ||
| 51 | + </tbody> | ||
| 52 | +</table> | ||
| 53 | +{{else}} | ||
| 54 | +<p class="empty">No issues yet. <a href="/issues/new">Create one</a>.</p> | ||
| 55 | +{{end}} | ||
| 56 | +{{end}}{{end}} | ||
added
dev.team/workspace/templates/style.css +70 -0 | new file mode 100644 | ||
| @@ -0,0 +1,70 @@ | ||
| 1 | +* { box-sizing: border-box; } | |
| 2 | +body { | |
| 3 | + margin: 0; | |
| 4 | + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| 5 | + background: #f5f6f8; | |
| 6 | + color: #1f2933; | |
| 7 | + line-height: 1.5; | |
| 8 | +} | |
| 9 | +header nav { | |
| 10 | + display: flex; | |
| 11 | + align-items: center; | |
| 12 | + justify-content: space-between; | |
| 13 | + padding: 14px 24px; | |
| 14 | + background: #1f2933; | |
| 15 | +} | |
| 16 | +.brand { | |
| 17 | + color: #fff; | |
| 18 | + font-weight: 700; | |
| 19 | + font-size: 18px; | |
| 20 | + text-decoration: none; | |
| 21 | +} | |
| 22 | +main { max-width: 900px; margin: 0 auto; padding: 24px; } | |
| 23 | +.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } | |
| 24 | +.page-header h1 { margin: 0; font-size: 24px; } | |
| 25 | +.btn { | |
| 26 | + display: inline-block; | |
| 27 | + padding: 8px 14px; | |
| 28 | + border: 1px solid #cbd2d9; | |
| 29 | + border-radius: 6px; | |
| 30 | + background: #fff; | |
| 31 | + color: #1f2933; | |
| 32 | + font-size: 14px; | |
| 33 | + cursor: pointer; | |
| 34 | + text-decoration: none; | |
| 35 | +} | |
| 36 | +.btn-primary { background: #2563eb; border-color: #2563eb; color: #fff; } | |
| 37 | +.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } | |
| 38 | +.btn-sm { padding: 4px 10px; font-size: 13px; } | |
| 39 | +.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; } | |
| 40 | +.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; } | |
| 41 | +.issues-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } | |
| 42 | +.issues-table th, .issues-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #e4e7eb; } | |
| 43 | +.issues-table th { background: #f0f2f5; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: #52606d; } | |
| 44 | +.issues-table a { color: #2563eb; text-decoration: none; } | |
| 45 | +.row-actions { display: flex; gap: 6px; align-items: center; } | |
| 46 | +.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; text-transform: capitalize; } | |
| 47 | +.badge-open { background: #dbeafe; color: #1e40af; } | |
| 48 | +.badge-in_progress { background: #fef3c7; color: #92400e; } | |
| 49 | +.badge-done { background: #d1fae5; color: #065f46; } | |
| 50 | +.badge-closed { background: #e5e7eb; color: #374151; } | |
| 51 | +.badge-low { background: #e0f2fe; color: #075985; } | |
| 52 | +.badge-medium { background: #fef3c7; color: #92400e; } | |
| 53 | +.badge-high { background: #ffedd5; color: #9a3412; } | |
| 54 | +.badge-critical { background: #fee2e2; color: #991b1b; } | |
| 55 | +.issue-form { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } | |
| 56 | +.form-group { margin-bottom: 16px; } | |
| 57 | +.form-group label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 14px; } | |
| 58 | +.form-group input, .form-group textarea, .form-group select { | |
| 59 | + width: 100%; padding: 9px 12px; border: 1px solid #cbd2d9; border-radius: 6px; font-size: 14px; font-family: inherit; | |
| 60 | +} | |
| 61 | +.form-row { display: flex; gap: 16px; } | |
| 62 | +.form-row .form-group { flex: 1; } | |
| 63 | +.actions { display: flex; gap: 10px; margin-top: 8px; } | |
| 64 | +.issue-detail { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } | |
| 65 | +.issue-detail h2 { margin-top: 0; } | |
| 66 | +.issue-detail .description { white-space: pre-wrap; color: #3e4c59; } | |
| 67 | +.meta { width: 100%; border-collapse: collapse; margin: 16px 0; } | |
| 68 | +.meta td { padding: 8px 10px; border-bottom: 1px solid #e4e7eb; } | |
| 69 | +.meta td:first-child { font-weight: 600; width: 120px; color: #52606d; } | |
| 70 | +.empty { color: #52606d; } | |
| new file mode 100644 | |||
| @@ -0,0 +1,70 @@ | |||
| 1 | +* { box-sizing: border-box; } | ||
| 2 | +body { | ||
| 3 | + margin: 0; | ||
| 4 | + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | ||
| 5 | + background: #f5f6f8; | ||
| 6 | + color: #1f2933; | ||
| 7 | + line-height: 1.5; | ||
| 8 | +} | ||
| 9 | +header nav { | ||
| 10 | + display: flex; | ||
| 11 | + align-items: center; | ||
| 12 | + justify-content: space-between; | ||
| 13 | + padding: 14px 24px; | ||
| 14 | + background: #1f2933; | ||
| 15 | +} | ||
| 16 | +.brand { | ||
| 17 | + color: #fff; | ||
| 18 | + font-weight: 700; | ||
| 19 | + font-size: 18px; | ||
| 20 | + text-decoration: none; | ||
| 21 | +} | ||
| 22 | +main { max-width: 900px; margin: 0 auto; padding: 24px; } | ||
| 23 | +.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } | ||
| 24 | +.page-header h1 { margin: 0; font-size: 24px; } | ||
| 25 | +.btn { | ||
| 26 | + display: inline-block; | ||
| 27 | + padding: 8px 14px; | ||
| 28 | + border: 1px solid #cbd2d9; | ||
| 29 | + border-radius: 6px; | ||
| 30 | + background: #fff; | ||
| 31 | + color: #1f2933; | ||
| 32 | + font-size: 14px; | ||
| 33 | + cursor: pointer; | ||
| 34 | + text-decoration: none; | ||
| 35 | +} | ||
| 36 | +.btn-primary { background: #2563eb; border-color: #2563eb; color: #fff; } | ||
| 37 | +.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } | ||
| 38 | +.btn-sm { padding: 4px 10px; font-size: 13px; } | ||
| 39 | +.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; } | ||
| 40 | +.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; } | ||
| 41 | +.issues-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } | ||
| 42 | +.issues-table th, .issues-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #e4e7eb; } | ||
| 43 | +.issues-table th { background: #f0f2f5; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: #52606d; } | ||
| 44 | +.issues-table a { color: #2563eb; text-decoration: none; } | ||
| 45 | +.row-actions { display: flex; gap: 6px; align-items: center; } | ||
| 46 | +.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; text-transform: capitalize; } | ||
| 47 | +.badge-open { background: #dbeafe; color: #1e40af; } | ||
| 48 | +.badge-in_progress { background: #fef3c7; color: #92400e; } | ||
| 49 | +.badge-done { background: #d1fae5; color: #065f46; } | ||
| 50 | +.badge-closed { background: #e5e7eb; color: #374151; } | ||
| 51 | +.badge-low { background: #e0f2fe; color: #075985; } | ||
| 52 | +.badge-medium { background: #fef3c7; color: #92400e; } | ||
| 53 | +.badge-high { background: #ffedd5; color: #9a3412; } | ||
| 54 | +.badge-critical { background: #fee2e2; color: #991b1b; } | ||
| 55 | +.issue-form { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } | ||
| 56 | +.form-group { margin-bottom: 16px; } | ||
| 57 | +.form-group label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 14px; } | ||
| 58 | +.form-group input, .form-group textarea, .form-group select { | ||
| 59 | + width: 100%; padding: 9px 12px; border: 1px solid #cbd2d9; border-radius: 6px; font-size: 14px; font-family: inherit; | ||
| 60 | +} | ||
| 61 | +.form-row { display: flex; gap: 16px; } | ||
| 62 | +.form-row .form-group { flex: 1; } | ||
| 63 | +.actions { display: flex; gap: 10px; margin-top: 8px; } | ||
| 64 | +.issue-detail { background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } | ||
| 65 | +.issue-detail h2 { margin-top: 0; } | ||
| 66 | +.issue-detail .description { white-space: pre-wrap; color: #3e4c59; } | ||
| 67 | +.meta { width: 100%; border-collapse: collapse; margin: 16px 0; } | ||
| 68 | +.meta td { padding: 8px 10px; border-bottom: 1px solid #e4e7eb; } | ||
| 69 | +.meta td:first-child { font-weight: 600; width: 120px; color: #52606d; } | ||
| 70 | +.empty { color: #52606d; } | ||
modified
docs/en/explanation/decision-models.md +1 -1 | @@ -13,7 +13,7 @@ The first use people want is the one Granite Guardian already showed in `demos/g | ||
| 13 | 13 | Two consequences follow, both deliberate: |
| 14 | 14 | |
| 15 | 15 | - **The state is the message alone.** No history, no system prompt, no hooks: a decider judges what it is given, and the previous message is not part of what it judges. That is also what a 2,048-token context leaves room for. |
| 16 | -- **No tools.** A decision model never calls one, so a decider declaring tools is refused at start-up rather than shown with a misleading tools column. | |
| 16 | +- **No tools.** A decision model never calls one, so mm turns the built-in tools off for a decider by itself: a decider file needs no `bashTool: false` lines, and a default `skillsDir` cannot make it refuse to start in a project that has skills. What only a deliberate entry brings — MCP, language servers, `mmConfigs`, declared `tools:` — is still refused at start-up: ignoring it silently would hide a mistake in the file. | |
| 17 | 17 | |
| 18 | 18 | `-p` prints the server's JSON untouched — a field a server adds later still reaches the script. The other front ends show one line per question followed by the same JSON, because the person reads the lines and an asking agent may parse the block. |
| 19 | 19 | |
| @@ -13,7 +13,7 @@ The first use people want is the one Granite Guardian already showed in `demos/g | |||
| 13 | Two consequences follow, both deliberate: | 13 | Two consequences follow, both deliberate: |
| 14 | 14 | ||
| 15 | - **The state is the message alone.** No history, no system prompt, no hooks: a decider judges what it is given, and the previous message is not part of what it judges. That is also what a 2,048-token context leaves room for. | 15 | - **The state is the message alone.** No history, no system prompt, no hooks: a decider judges what it is given, and the previous message is not part of what it judges. That is also what a 2,048-token context leaves room for. |
| 16 | -- **No tools.** A decision model never calls one, so a decider declaring tools is refused at start-up rather than shown with a misleading tools column. | 16 | +- **No tools.** A decision model never calls one, so mm turns the built-in tools off for a decider by itself: a decider file needs no `bashTool: false` lines, and a default `skillsDir` cannot make it refuse to start in a project that has skills. What only a deliberate entry brings — MCP, language servers, `mmConfigs`, declared `tools:` — is still refused at start-up: ignoring it silently would hide a mistake in the file. |
| 17 | 17 | ||
| 18 | `-p` prints the server's JSON untouched — a field a server adds later still reaches the script. The other front ends show one line per question followed by the same JSON, because the person reads the lines and an asking agent may parse the block. | 18 | `-p` prints the server's JSON untouched — a field a server adds later still reaches the script. The other front ends show one line per question followed by the same JSON, because the person reads the lines and an asking agent may parse the block. |
| 19 | 19 | ||
modified
docs/en/how-to/ask-a-decision-model.md +2 -8 | @@ -39,13 +39,7 @@ decision: | ||
| 39 | 39 | instructions: How carefully should a person review this command before it runs? |
| 40 | 40 | criteria: [no review needed, a quick look, a careful review] |
| 41 | 41 | |
| 42 | -# A decider declares no tool: mm refuses to start otherwise. | |
| 43 | -bashTool: false | |
| 44 | -editTools: false | |
| 45 | -planTool: false | |
| 46 | -fetchContentTool: false | |
| 47 | -askUserTool: false | |
| 48 | -skillsDir: no-skills | |
| 42 | +# A decider gets no tool: mm turns them off by itself. | |
| 49 | 43 | agentsMd: |
| 50 | 44 | enabled: false |
| 51 | 45 | ``` |
| @@ -100,7 +94,7 @@ urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 | ||
| 100 | 94 | |---------|-------|-----| |
| 101 | 95 | | `HTTP 501: This model is not a decision model` (llmman, llama-server) or `HTTP 400: model "…" is not a llama.cpp decision model` (Ollama) | the server does not treat the model as a decision model (evalengine's `decision-4b` is refused) | use a model the route accepts, such as LiquidAI d1 | |
| 102 | 96 | | `decision: dmr: no /v1/systemone route` at start-up | Docker Model Runner has no such route | serve the model with llama-server, llmman or Ollama | |
| 103 | -| `decision cannot be used with tools — this agent declares: …` | a tool is still on | turn off the keys the message lists | | |
| 97 | +| `decision cannot be used with tools — this agent declares: …` | the decider file sets `mcp`, `lsp.servers`, `mmConfigs` or `tools:` | remove the entries the message lists | | |
| 104 | 98 | | the first answer takes ~13 s | the server loads the model on the first call | nothing: later calls take 0.2–0.5 s | |
| 105 | 99 | |
| 106 | 100 | ## See also |
| @@ -39,13 +39,7 @@ decision: | |||
| 39 | instructions: How carefully should a person review this command before it runs? | 39 | instructions: How carefully should a person review this command before it runs? |
| 40 | criteria: [no review needed, a quick look, a careful review] | 40 | criteria: [no review needed, a quick look, a careful review] |
| 41 | 41 | ||
| 42 | -# A decider declares no tool: mm refuses to start otherwise. | 42 | +# A decider gets no tool: mm turns them off by itself. |
| 43 | -bashTool: false | ||
| 44 | -editTools: false | ||
| 45 | -planTool: false | ||
| 46 | -fetchContentTool: false | ||
| 47 | -askUserTool: false | ||
| 48 | -skillsDir: no-skills | ||
| 49 | agentsMd: | 43 | agentsMd: |
| 50 | enabled: false | 44 | enabled: false |
| 51 | ``` | 45 | ``` |
| @@ -100,7 +94,7 @@ urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 | |||
| 100 | |---------|-------|-----| | 94 | |---------|-------|-----| |
| 101 | | `HTTP 501: This model is not a decision model` (llmman, llama-server) or `HTTP 400: model "…" is not a llama.cpp decision model` (Ollama) | the server does not treat the model as a decision model (evalengine's `decision-4b` is refused) | use a model the route accepts, such as LiquidAI d1 | | 95 | | `HTTP 501: This model is not a decision model` (llmman, llama-server) or `HTTP 400: model "…" is not a llama.cpp decision model` (Ollama) | the server does not treat the model as a decision model (evalengine's `decision-4b` is refused) | use a model the route accepts, such as LiquidAI d1 | |
| 102 | | `decision: dmr: no /v1/systemone route` at start-up | Docker Model Runner has no such route | serve the model with llama-server, llmman or Ollama | | 96 | | `decision: dmr: no /v1/systemone route` at start-up | Docker Model Runner has no such route | serve the model with llama-server, llmman or Ollama | |
| 103 | -| `decision cannot be used with tools — this agent declares: …` | a tool is still on | turn off the keys the message lists | | 97 | +| `decision cannot be used with tools — this agent declares: …` | the decider file sets `mcp`, `lsp.servers`, `mmConfigs` or `tools:` | remove the entries the message lists | |
| 104 | | the first answer takes ~13 s | the server loads the model on the first call | nothing: later calls take 0.2–0.5 s | | 98 | | the first answer takes ~13 s | the server loads the model on the first call | nothing: later calls take 0.2–0.5 s | |
| 105 | 99 | ||
| 106 | ## See also | 100 | ## See also |
modified
docs/en/how-to/check-outputs-with-a-decision-model.md +0 -1 | @@ -83,7 +83,6 @@ The check is about relevance, not truth: a wrong answer on the right subject pas | ||
| 83 | 83 | - **Thresholds**: the demos read them from `INJECTION_THRESHOLD` (0.8), `FAILURE_THRESHOLD` (0.95) and `ON_TOPIC_THRESHOLD` (0.4). Measure on your own texts before you move them. |
| 84 | 84 | - **mm is not on the PATH**: set `MM_BIN` to the binary's path. |
| 85 | 85 | - **An asked agent that reasons**: turn its reasoning off (`chat_template_kwargs: {enable_thinking: false}` for Qwen3.5). Otherwise its reasoning can end up in the answer, and the check judges the reasoning — which talks about the question — instead of the answer. |
| 86 | -- **A decider started from a project with skills**: give the decider `skillsDir: no-skills`; with `skillsDir: ""` it reads `./skills` of the directory it runs in, declares `read_skill`, and refuses to start. | |
| 87 | 86 | |
| 88 | 87 | ## See also |
| 89 | 88 | |
| @@ -83,7 +83,6 @@ The check is about relevance, not truth: a wrong answer on the right subject pas | |||
| 83 | - **Thresholds**: the demos read them from `INJECTION_THRESHOLD` (0.8), `FAILURE_THRESHOLD` (0.95) and `ON_TOPIC_THRESHOLD` (0.4). Measure on your own texts before you move them. | 83 | - **Thresholds**: the demos read them from `INJECTION_THRESHOLD` (0.8), `FAILURE_THRESHOLD` (0.95) and `ON_TOPIC_THRESHOLD` (0.4). Measure on your own texts before you move them. |
| 84 | - **mm is not on the PATH**: set `MM_BIN` to the binary's path. | 84 | - **mm is not on the PATH**: set `MM_BIN` to the binary's path. |
| 85 | - **An asked agent that reasons**: turn its reasoning off (`chat_template_kwargs: {enable_thinking: false}` for Qwen3.5). Otherwise its reasoning can end up in the answer, and the check judges the reasoning — which talks about the question — instead of the answer. | 85 | - **An asked agent that reasons**: turn its reasoning off (`chat_template_kwargs: {enable_thinking: false}` for Qwen3.5). Otherwise its reasoning can end up in the answer, and the check judges the reasoning — which talks about the question — instead of the answer. |
| 86 | -- **A decider started from a project with skills**: give the decider `skillsDir: no-skills`; with `skillsDir: ""` it reads `./skills` of the directory it runs in, declares `read_skill`, and refuses to start. | ||
| 87 | 86 | ||
| 88 | ## See also | 87 | ## See also |
| 89 | 88 | ||
modified
docs/en/how-to/triage-commands-with-a-decision-model.md +0 -1 | @@ -77,7 +77,6 @@ This guide shows how to let a decision model answer the confirmation question fo | ||
| 77 | 77 | - **Stricter or looser**: the demo's thresholds are environment variables, `TRIAGE_ALLOW_CONFIDENCE` (0.8) and `TRIAGE_ALLOW_DESTRUCTIVE` (0.3). Measure on your own commands before you loosen them. |
| 78 | 78 | - **A cold decider**: the first call loads the model (2 to 13 s). Past the hook's `timeout` the hook fails, `onError: warn` reports it, and you are asked as usual. |
| 79 | 79 | - **mm is not on the PATH**: set `MM_BIN` to the binary's path in the demo's hook. |
| 80 | -- **Every command asks, even `ls`**: the hook runs the decider in your project's directory. With `skillsDir: ""` the decider reads `./skills` there, declares `read_skill`, and refuses to start — so the hook has no verdict. Give the decider `skillsDir: no-skills`. | |
| 81 | 80 | |
| 82 | 81 | ## See also |
| 83 | 82 | |
| @@ -77,7 +77,6 @@ This guide shows how to let a decision model answer the confirmation question fo | |||
| 77 | - **Stricter or looser**: the demo's thresholds are environment variables, `TRIAGE_ALLOW_CONFIDENCE` (0.8) and `TRIAGE_ALLOW_DESTRUCTIVE` (0.3). Measure on your own commands before you loosen them. | 77 | - **Stricter or looser**: the demo's thresholds are environment variables, `TRIAGE_ALLOW_CONFIDENCE` (0.8) and `TRIAGE_ALLOW_DESTRUCTIVE` (0.3). Measure on your own commands before you loosen them. |
| 78 | - **A cold decider**: the first call loads the model (2 to 13 s). Past the hook's `timeout` the hook fails, `onError: warn` reports it, and you are asked as usual. | 78 | - **A cold decider**: the first call loads the model (2 to 13 s). Past the hook's `timeout` the hook fails, `onError: warn` reports it, and you are asked as usual. |
| 79 | - **mm is not on the PATH**: set `MM_BIN` to the binary's path in the demo's hook. | 79 | - **mm is not on the PATH**: set `MM_BIN` to the binary's path in the demo's hook. |
| 80 | -- **Every command asks, even `ls`**: the hook runs the decider in your project's directory. With `skillsDir: ""` the decider reads `./skills` there, declares `read_skill`, and refuses to start — so the hook has no verdict. Give the decider `skillsDir: no-skills`. | ||
| 81 | 80 | ||
| 82 | ## See also | 81 | ## See also |
| 83 | 82 | ||
modified
docs/en/how-to/write-decision-questions.md +8 -28 | @@ -119,13 +119,8 @@ decision: | ||
| 119 | 119 | specialist: A known long-term condition followed by a specialist |
| 120 | 120 | emergency: A problem that cannot wait |
| 121 | 121 | |
| 122 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 123 | -bashTool: false | |
| 124 | -editTools: false | |
| 125 | -planTool: false | |
| 126 | -fetchContentTool: false | |
| 127 | -askUserTool: false | |
| 128 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 122 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 123 | +# skills off by itself (no `…: false` lines needed). | |
| 129 | 124 | agentsMd: |
| 130 | 125 | enabled: false |
| 131 | 126 | ``` |
| @@ -165,13 +160,8 @@ decision: | ||
| 165 | 160 | two: Two dice are missing |
| 166 | 161 | all: All three are missing |
| 167 | 162 | |
| 168 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 169 | -bashTool: false | |
| 170 | -editTools: false | |
| 171 | -planTool: false | |
| 172 | -fetchContentTool: false | |
| 173 | -askUserTool: false | |
| 174 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 163 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 164 | +# skills off by itself (no `…: false` lines needed). | |
| 175 | 165 | agentsMd: |
| 176 | 166 | enabled: false |
| 177 | 167 | ``` |
| @@ -214,13 +204,8 @@ decision: | ||
| 214 | 204 | misty_step: I am surrounded or grabbed and must get away |
| 215 | 205 | sleep: Several weak enemies, and we would rather not kill them |
| 216 | 206 | |
| 217 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 218 | -bashTool: false | |
| 219 | -editTools: false | |
| 220 | -planTool: false | |
| 221 | -fetchContentTool: false | |
| 222 | -askUserTool: false | |
| 223 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 207 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 208 | +# skills off by itself (no `…: false` lines needed). | |
| 224 | 209 | agentsMd: |
| 225 | 210 | enabled: false |
| 226 | 211 | ``` |
| @@ -267,13 +252,8 @@ decision: | ||
| 267 | 252 | type: noul |
| 268 | 253 | instructions: My character is badly hurt. |
| 269 | 254 | |
| 270 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 271 | -bashTool: false | |
| 272 | -editTools: false | |
| 273 | -planTool: false | |
| 274 | -fetchContentTool: false | |
| 275 | -askUserTool: false | |
| 276 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 255 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 256 | +# skills off by itself (no `…: false` lines needed). | |
| 277 | 257 | agentsMd: |
| 278 | 258 | enabled: false |
| 279 | 259 | ``` |
| @@ -119,13 +119,8 @@ decision: | |||
| 119 | specialist: A known long-term condition followed by a specialist | 119 | specialist: A known long-term condition followed by a specialist |
| 120 | emergency: A problem that cannot wait | 120 | emergency: A problem that cannot wait |
| 121 | 121 | ||
| 122 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 122 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 123 | -bashTool: false | 123 | +# skills off by itself (no `…: false` lines needed). |
| 124 | -editTools: false | ||
| 125 | -planTool: false | ||
| 126 | -fetchContentTool: false | ||
| 127 | -askUserTool: false | ||
| 128 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 129 | agentsMd: | 124 | agentsMd: |
| 130 | enabled: false | 125 | enabled: false |
| 131 | ``` | 126 | ``` |
| @@ -165,13 +160,8 @@ decision: | |||
| 165 | two: Two dice are missing | 160 | two: Two dice are missing |
| 166 | all: All three are missing | 161 | all: All three are missing |
| 167 | 162 | ||
| 168 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 163 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 169 | -bashTool: false | 164 | +# skills off by itself (no `…: false` lines needed). |
| 170 | -editTools: false | ||
| 171 | -planTool: false | ||
| 172 | -fetchContentTool: false | ||
| 173 | -askUserTool: false | ||
| 174 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 175 | agentsMd: | 165 | agentsMd: |
| 176 | enabled: false | 166 | enabled: false |
| 177 | ``` | 167 | ``` |
| @@ -214,13 +204,8 @@ decision: | |||
| 214 | misty_step: I am surrounded or grabbed and must get away | 204 | misty_step: I am surrounded or grabbed and must get away |
| 215 | sleep: Several weak enemies, and we would rather not kill them | 205 | sleep: Several weak enemies, and we would rather not kill them |
| 216 | 206 | ||
| 217 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 207 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 218 | -bashTool: false | 208 | +# skills off by itself (no `…: false` lines needed). |
| 219 | -editTools: false | ||
| 220 | -planTool: false | ||
| 221 | -fetchContentTool: false | ||
| 222 | -askUserTool: false | ||
| 223 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 224 | agentsMd: | 209 | agentsMd: |
| 225 | enabled: false | 210 | enabled: false |
| 226 | ``` | 211 | ``` |
| @@ -267,13 +252,8 @@ decision: | |||
| 267 | type: noul | 252 | type: noul |
| 268 | instructions: My character is badly hurt. | 253 | instructions: My character is badly hurt. |
| 269 | 254 | ||
| 270 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 255 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 271 | -bashTool: false | 256 | +# skills off by itself (no `…: false` lines needed). |
| 272 | -editTools: false | ||
| 273 | -planTool: false | ||
| 274 | -fetchContentTool: false | ||
| 275 | -askUserTool: false | ||
| 276 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 277 | agentsMd: | 257 | agentsMd: |
| 278 | enabled: false | 258 | enabled: false |
| 279 | ``` | 259 | ``` |
modified
docs/en/reference/configuration.md +3 -7 | @@ -278,7 +278,8 @@ When the block is present: | ||
| 278 | 278 | urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 · a quick look 0.06 · no review needed 0.03 |
| 279 | 279 | ``` |
| 280 | 280 | |
| 281 | -- no tool may be declared (`unload_model` is not declared; `/unload` remains); | |
| 281 | +- no tool is declared, whatever `bashTool`, `editTools`, `planTool`, `askUserTool`, `fetchContentTool`, `saveAnswerTool`, `unloadModels`, `skillsDir`, `docSearch` and `sessions` say: the file needs no `…: false` line (`/unload` remains); | |
| 282 | +- `mcp.enabled: true`, `lsp.servers`, `mmConfigs` or `tools:` stop the start-up with an error, before any server is started; | |
| 282 | 283 | - the banner shows `tools: none (decision model: <n> question(s))` and no "no skills" / "no tool" warning. |
| 283 | 284 | |
| 284 | 285 | Example: |
| @@ -303,11 +304,6 @@ decision: | ||
| 303 | 304 | type: score |
| 304 | 305 | instructions: How carefully should a person review this command before it runs? |
| 305 | 306 | criteria: [no review needed, a quick look, a careful review] |
| 306 | -bashTool: false | |
| 307 | -editTools: false | |
| 308 | -planTool: false | |
| 309 | -askUserTool: false | |
| 310 | -skillsDir: no-skills | |
| 311 | 307 | ``` |
| 312 | 308 | |
| 313 | 309 | ### Servers |
| @@ -448,7 +444,7 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | ||
| 448 | 444 | | `line <n>: question "<name>": a choice needs at least 2 options, got <n>` / `a score needs 2 to 10 levels, got <n>` / `a noul question takes no criteria` | `criteria` of the wrong shape or size | |
| 449 | 445 | | `decision: dmr: no /v1/systemone route (…)` | `decision` with `provider: dmr` | |
| 450 | 446 | | `decision and structuredOutput cannot be used together: …` | both in one file | |
| 451 | -| `decision cannot be used with tools — this agent declares: <tools> (…)` | a decider with a declared tool; reported after the tools are assembled | | |
| 447 | +| `decision cannot be used with tools — this agent declares: <sources> (…)` | a decider whose file sets `mcp.enabled: true`, `lsp.servers`, `mmConfigs` or `tools:`; `<sources>` lists them (`mcp`, `lsp servers`, `mmConfigs`, `tools`); reported before the tools are assembled | | |
| 452 | 448 | | `skillRouting.decider: …` | the decider file is missing, has no `provider` or `model`, or names `dmr` | |
| 453 | 449 | | `skillRouting.minConfidence must be between 0 and 1` / `skillRouting.timeout must be > 0` | out of range | |
| 454 | 450 | | `toolRouting.decider: …` | the decider file is missing, has no `provider` or `model`, or names `dmr` | |
| @@ -278,7 +278,8 @@ When the block is present: | |||
| 278 | urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 · a quick look 0.06 · no review needed 0.03 | 278 | urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 · a quick look 0.06 · no review needed 0.03 |
| 279 | ``` | 279 | ``` |
| 280 | 280 | ||
| 281 | -- no tool may be declared (`unload_model` is not declared; `/unload` remains); | 281 | +- no tool is declared, whatever `bashTool`, `editTools`, `planTool`, `askUserTool`, `fetchContentTool`, `saveAnswerTool`, `unloadModels`, `skillsDir`, `docSearch` and `sessions` say: the file needs no `…: false` line (`/unload` remains); |
| 282 | +- `mcp.enabled: true`, `lsp.servers`, `mmConfigs` or `tools:` stop the start-up with an error, before any server is started; | ||
| 282 | - the banner shows `tools: none (decision model: <n> question(s))` and no "no skills" / "no tool" warning. | 283 | - the banner shows `tools: none (decision model: <n> question(s))` and no "no skills" / "no tool" warning. |
| 283 | 284 | ||
| 284 | Example: | 285 | Example: |
| @@ -303,11 +304,6 @@ decision: | |||
| 303 | type: score | 304 | type: score |
| 304 | instructions: How carefully should a person review this command before it runs? | 305 | instructions: How carefully should a person review this command before it runs? |
| 305 | criteria: [no review needed, a quick look, a careful review] | 306 | criteria: [no review needed, a quick look, a careful review] |
| 306 | -bashTool: false | ||
| 307 | -editTools: false | ||
| 308 | -planTool: false | ||
| 309 | -askUserTool: false | ||
| 310 | -skillsDir: no-skills | ||
| 311 | ``` | 307 | ``` |
| 312 | 308 | ||
| 313 | ### Servers | 309 | ### Servers |
| @@ -448,7 +444,7 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | |||
| 448 | | `line <n>: question "<name>": a choice needs at least 2 options, got <n>` / `a score needs 2 to 10 levels, got <n>` / `a noul question takes no criteria` | `criteria` of the wrong shape or size | | 444 | | `line <n>: question "<name>": a choice needs at least 2 options, got <n>` / `a score needs 2 to 10 levels, got <n>` / `a noul question takes no criteria` | `criteria` of the wrong shape or size | |
| 449 | | `decision: dmr: no /v1/systemone route (…)` | `decision` with `provider: dmr` | | 445 | | `decision: dmr: no /v1/systemone route (…)` | `decision` with `provider: dmr` | |
| 450 | | `decision and structuredOutput cannot be used together: …` | both in one file | | 446 | | `decision and structuredOutput cannot be used together: …` | both in one file | |
| 451 | -| `decision cannot be used with tools — this agent declares: <tools> (…)` | a decider with a declared tool; reported after the tools are assembled | | 447 | +| `decision cannot be used with tools — this agent declares: <sources> (…)` | a decider whose file sets `mcp.enabled: true`, `lsp.servers`, `mmConfigs` or `tools:`; `<sources>` lists them (`mcp`, `lsp servers`, `mmConfigs`, `tools`); reported before the tools are assembled | |
| 452 | | `skillRouting.decider: …` | the decider file is missing, has no `provider` or `model`, or names `dmr` | | 448 | | `skillRouting.decider: …` | the decider file is missing, has no `provider` or `model`, or names `dmr` | |
| 453 | | `skillRouting.minConfidence must be between 0 and 1` / `skillRouting.timeout must be > 0` | out of range | | 449 | | `skillRouting.minConfidence must be between 0 and 1` / `skillRouting.timeout must be > 0` | out of range | |
| 454 | | `toolRouting.decider: …` | the decider file is missing, has no `provider` or `model`, or names `dmr` | | 450 | | `toolRouting.decider: …` | the decider file is missing, has no `provider` or `model`, or names `dmr` | |
modified
docs/fr/explanation/decision-models.md +1 -1 | @@ -13,7 +13,7 @@ Le premier usage voulu est celui que Granite Guardian a déjà montré dans `dem | ||
| 13 | 13 | Deux conséquences en découlent, toutes deux voulues : |
| 14 | 14 | |
| 15 | 15 | - **L'état, c'est le message seul.** Ni historique, ni prompt système, ni hooks : un décideur juge ce qu'on lui donne, et le message précédent ne fait pas partie de ce qu'il juge. C'est aussi ce que laisse la place d'un contexte de 2 048 tokens. |
| 16 | -- **Pas d'outils.** Un modèle de décision n'en appelle jamais : un décideur qui déclare des outils est refusé au démarrage plutôt qu'affiché avec une colonne d'outils trompeuse. | |
| 16 | +- **Pas d'outils.** Un modèle de décision n'en appelle jamais : mm désactive donc lui-même les outils intégrés d'un décideur. Le fichier n'a besoin d'aucune ligne `bashTool: false`, et un `skillsDir` par défaut ne peut plus l'empêcher de démarrer dans un projet qui a des skills. Ce que seule une entrée délibérée apporte — MCP, serveurs de langage, `mmConfigs`, `tools:` déclarés — reste refusé au démarrage : l'ignorer en silence cacherait une erreur dans le fichier. | |
| 17 | 17 | |
| 18 | 18 | `-p` imprime le JSON du serveur tel quel — un champ qu'un serveur ajoutera plus tard arrive encore au script. Les autres front ends affichent une ligne par question suivie du même JSON, parce que la personne lit les lignes et qu'un agent qui interroge peut lire le bloc. |
| 19 | 19 | |
| @@ -13,7 +13,7 @@ Le premier usage voulu est celui que Granite Guardian a déjà montré dans `dem | |||
| 13 | Deux conséquences en découlent, toutes deux voulues : | 13 | Deux conséquences en découlent, toutes deux voulues : |
| 14 | 14 | ||
| 15 | - **L'état, c'est le message seul.** Ni historique, ni prompt système, ni hooks : un décideur juge ce qu'on lui donne, et le message précédent ne fait pas partie de ce qu'il juge. C'est aussi ce que laisse la place d'un contexte de 2 048 tokens. | 15 | - **L'état, c'est le message seul.** Ni historique, ni prompt système, ni hooks : un décideur juge ce qu'on lui donne, et le message précédent ne fait pas partie de ce qu'il juge. C'est aussi ce que laisse la place d'un contexte de 2 048 tokens. |
| 16 | -- **Pas d'outils.** Un modèle de décision n'en appelle jamais : un décideur qui déclare des outils est refusé au démarrage plutôt qu'affiché avec une colonne d'outils trompeuse. | 16 | +- **Pas d'outils.** Un modèle de décision n'en appelle jamais : mm désactive donc lui-même les outils intégrés d'un décideur. Le fichier n'a besoin d'aucune ligne `bashTool: false`, et un `skillsDir` par défaut ne peut plus l'empêcher de démarrer dans un projet qui a des skills. Ce que seule une entrée délibérée apporte — MCP, serveurs de langage, `mmConfigs`, `tools:` déclarés — reste refusé au démarrage : l'ignorer en silence cacherait une erreur dans le fichier. |
| 17 | 17 | ||
| 18 | `-p` imprime le JSON du serveur tel quel — un champ qu'un serveur ajoutera plus tard arrive encore au script. Les autres front ends affichent une ligne par question suivie du même JSON, parce que la personne lit les lignes et qu'un agent qui interroge peut lire le bloc. | 18 | `-p` imprime le JSON du serveur tel quel — un champ qu'un serveur ajoutera plus tard arrive encore au script. Les autres front ends affichent une ligne par question suivie du même JSON, parce que la personne lit les lignes et qu'un agent qui interroge peut lire le bloc. |
| 19 | 19 | ||
modified
docs/fr/how-to/ask-a-decision-model.md +2 -8 | @@ -39,13 +39,7 @@ decision: | ||
| 39 | 39 | instructions: How carefully should a person review this command before it runs? |
| 40 | 40 | criteria: [no review needed, a quick look, a careful review] |
| 41 | 41 | |
| 42 | -# Un décideur ne déclare aucun outil : sinon mm refuse de démarrer. | |
| 43 | -bashTool: false | |
| 44 | -editTools: false | |
| 45 | -planTool: false | |
| 46 | -fetchContentTool: false | |
| 47 | -askUserTool: false | |
| 48 | -skillsDir: no-skills | |
| 42 | +# Un décideur n'a aucun outil : mm les désactive lui-même. | |
| 49 | 43 | agentsMd: |
| 50 | 44 | enabled: false |
| 51 | 45 | ``` |
| @@ -100,7 +94,7 @@ urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 | ||
| 100 | 94 | |----------|-------|------------| |
| 101 | 95 | | `HTTP 501: This model is not a decision model` (llmman, llama-server) ou `HTTP 400: model "…" is not a llama.cpp decision model` (Ollama) | le serveur ne traite pas le modèle comme un modèle de décision (`decision-4b` d'evalengine est refusé) | utilisez un modèle que la route accepte, comme LiquidAI d1 | |
| 102 | 96 | | `decision: dmr: no /v1/systemone route` au démarrage | Docker Model Runner n'a pas cette route | servez le modèle avec llama-server, llmman ou Ollama | |
| 103 | -| `decision cannot be used with tools — this agent declares: …` | un outil est encore actif | désactivez les clés que le message liste | | |
| 97 | +| `decision cannot be used with tools — this agent declares: …` | le fichier du décideur pose `mcp`, `lsp.servers`, `mmConfigs` ou `tools:` | retirez les entrées que le message liste | | |
| 104 | 98 | | la première réponse prend ~13 s | le serveur charge le modèle au premier appel | rien : les appels suivants prennent 0,2 à 0,5 s | |
| 105 | 99 | |
| 106 | 100 | ## Voir aussi |
| @@ -39,13 +39,7 @@ decision: | |||
| 39 | instructions: How carefully should a person review this command before it runs? | 39 | instructions: How carefully should a person review this command before it runs? |
| 40 | criteria: [no review needed, a quick look, a careful review] | 40 | criteria: [no review needed, a quick look, a careful review] |
| 41 | 41 | ||
| 42 | -# Un décideur ne déclare aucun outil : sinon mm refuse de démarrer. | 42 | +# Un décideur n'a aucun outil : mm les désactive lui-même. |
| 43 | -bashTool: false | ||
| 44 | -editTools: false | ||
| 45 | -planTool: false | ||
| 46 | -fetchContentTool: false | ||
| 47 | -askUserTool: false | ||
| 48 | -skillsDir: no-skills | ||
| 49 | agentsMd: | 43 | agentsMd: |
| 50 | enabled: false | 44 | enabled: false |
| 51 | ``` | 45 | ``` |
| @@ -100,7 +94,7 @@ urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 | |||
| 100 | |----------|-------|------------| | 94 | |----------|-------|------------| |
| 101 | | `HTTP 501: This model is not a decision model` (llmman, llama-server) ou `HTTP 400: model "…" is not a llama.cpp decision model` (Ollama) | le serveur ne traite pas le modèle comme un modèle de décision (`decision-4b` d'evalengine est refusé) | utilisez un modèle que la route accepte, comme LiquidAI d1 | | 95 | | `HTTP 501: This model is not a decision model` (llmman, llama-server) ou `HTTP 400: model "…" is not a llama.cpp decision model` (Ollama) | le serveur ne traite pas le modèle comme un modèle de décision (`decision-4b` d'evalengine est refusé) | utilisez un modèle que la route accepte, comme LiquidAI d1 | |
| 102 | | `decision: dmr: no /v1/systemone route` au démarrage | Docker Model Runner n'a pas cette route | servez le modèle avec llama-server, llmman ou Ollama | | 96 | | `decision: dmr: no /v1/systemone route` au démarrage | Docker Model Runner n'a pas cette route | servez le modèle avec llama-server, llmman ou Ollama | |
| 103 | -| `decision cannot be used with tools — this agent declares: …` | un outil est encore actif | désactivez les clés que le message liste | | 97 | +| `decision cannot be used with tools — this agent declares: …` | le fichier du décideur pose `mcp`, `lsp.servers`, `mmConfigs` ou `tools:` | retirez les entrées que le message liste | |
| 104 | | la première réponse prend ~13 s | le serveur charge le modèle au premier appel | rien : les appels suivants prennent 0,2 à 0,5 s | | 98 | | la première réponse prend ~13 s | le serveur charge le modèle au premier appel | rien : les appels suivants prennent 0,2 à 0,5 s | |
| 105 | 99 | ||
| 106 | ## Voir aussi | 100 | ## Voir aussi |
modified
docs/fr/how-to/check-outputs-with-a-decision-model.md +0 -1 | @@ -83,7 +83,6 @@ Le contrôle porte sur la pertinence, pas sur la vérité : une réponse fausse | ||
| 83 | 83 | - **Les seuils** : les démos les lisent dans `INJECTION_THRESHOLD` (0,8), `FAILURE_THRESHOLD` (0,95) et `ON_TOPIC_THRESHOLD` (0,4). Mesurez sur vos propres textes avant de les déplacer. |
| 84 | 84 | - **mm n'est pas dans le PATH** : donnez le chemin du binaire dans `MM_BIN`. |
| 85 | 85 | - **Un agent interrogé qui raisonne** : coupez son raisonnement (`chat_template_kwargs: {enable_thinking: false}` pour Qwen3.5). Sinon son raisonnement peut se retrouver dans la réponse, et le contrôle juge le raisonnement, qui parle de la question, au lieu de la réponse. |
| 86 | -- **Un décideur lancé depuis un projet qui a des skills** : donnez au décideur `skillsDir: no-skills` ; avec `skillsDir: ""`, il lit `./skills` du dossier où il tourne, déclare `read_skill`, et refuse de démarrer. | |
| 87 | 86 | |
| 88 | 87 | ## Voir aussi |
| 89 | 88 | |
| @@ -83,7 +83,6 @@ Le contrôle porte sur la pertinence, pas sur la vérité : une réponse fausse | |||
| 83 | - **Les seuils** : les démos les lisent dans `INJECTION_THRESHOLD` (0,8), `FAILURE_THRESHOLD` (0,95) et `ON_TOPIC_THRESHOLD` (0,4). Mesurez sur vos propres textes avant de les déplacer. | 83 | - **Les seuils** : les démos les lisent dans `INJECTION_THRESHOLD` (0,8), `FAILURE_THRESHOLD` (0,95) et `ON_TOPIC_THRESHOLD` (0,4). Mesurez sur vos propres textes avant de les déplacer. |
| 84 | - **mm n'est pas dans le PATH** : donnez le chemin du binaire dans `MM_BIN`. | 84 | - **mm n'est pas dans le PATH** : donnez le chemin du binaire dans `MM_BIN`. |
| 85 | - **Un agent interrogé qui raisonne** : coupez son raisonnement (`chat_template_kwargs: {enable_thinking: false}` pour Qwen3.5). Sinon son raisonnement peut se retrouver dans la réponse, et le contrôle juge le raisonnement, qui parle de la question, au lieu de la réponse. | 85 | - **Un agent interrogé qui raisonne** : coupez son raisonnement (`chat_template_kwargs: {enable_thinking: false}` pour Qwen3.5). Sinon son raisonnement peut se retrouver dans la réponse, et le contrôle juge le raisonnement, qui parle de la question, au lieu de la réponse. |
| 86 | -- **Un décideur lancé depuis un projet qui a des skills** : donnez au décideur `skillsDir: no-skills` ; avec `skillsDir: ""`, il lit `./skills` du dossier où il tourne, déclare `read_skill`, et refuse de démarrer. | ||
| 87 | 86 | ||
| 88 | ## Voir aussi | 87 | ## Voir aussi |
| 89 | 88 | ||
modified
docs/fr/how-to/triage-commands-with-a-decision-model.md +0 -1 | @@ -77,7 +77,6 @@ Ce guide montre comment laisser un modèle de décision répondre à votre place | ||
| 77 | 77 | - **Plus strict ou plus souple** : les seuils de la démo sont des variables d'environnement, `TRIAGE_ALLOW_CONFIDENCE` (0,8) et `TRIAGE_ALLOW_DESTRUCTIVE` (0,3). Mesurez sur vos propres commandes avant de les assouplir. |
| 78 | 78 | - **Un décideur froid** : le premier appel charge le modèle (2 à 13 s). Au-delà du `timeout` du hook, le hook échoue, `onError: warn` le signale, et la question vous est posée comme d'habitude. |
| 79 | 79 | - **mm n'est pas dans le PATH** : donnez le chemin du binaire dans `MM_BIN` pour le hook de la démo. |
| 80 | -- **Toutes les commandes demandent, même `ls`** : le hook lance le décideur dans le dossier de votre projet. Avec `skillsDir: ""`, le décideur y lit `./skills`, déclare `read_skill` et refuse de démarrer — le hook n'a donc pas de verdict. Donnez au décideur `skillsDir: no-skills`. | |
| 81 | 80 | |
| 82 | 81 | ## Voir aussi |
| 83 | 82 | |
| @@ -77,7 +77,6 @@ Ce guide montre comment laisser un modèle de décision répondre à votre place | |||
| 77 | - **Plus strict ou plus souple** : les seuils de la démo sont des variables d'environnement, `TRIAGE_ALLOW_CONFIDENCE` (0,8) et `TRIAGE_ALLOW_DESTRUCTIVE` (0,3). Mesurez sur vos propres commandes avant de les assouplir. | 77 | - **Plus strict ou plus souple** : les seuils de la démo sont des variables d'environnement, `TRIAGE_ALLOW_CONFIDENCE` (0,8) et `TRIAGE_ALLOW_DESTRUCTIVE` (0,3). Mesurez sur vos propres commandes avant de les assouplir. |
| 78 | - **Un décideur froid** : le premier appel charge le modèle (2 à 13 s). Au-delà du `timeout` du hook, le hook échoue, `onError: warn` le signale, et la question vous est posée comme d'habitude. | 78 | - **Un décideur froid** : le premier appel charge le modèle (2 à 13 s). Au-delà du `timeout` du hook, le hook échoue, `onError: warn` le signale, et la question vous est posée comme d'habitude. |
| 79 | - **mm n'est pas dans le PATH** : donnez le chemin du binaire dans `MM_BIN` pour le hook de la démo. | 79 | - **mm n'est pas dans le PATH** : donnez le chemin du binaire dans `MM_BIN` pour le hook de la démo. |
| 80 | -- **Toutes les commandes demandent, même `ls`** : le hook lance le décideur dans le dossier de votre projet. Avec `skillsDir: ""`, le décideur y lit `./skills`, déclare `read_skill` et refuse de démarrer — le hook n'a donc pas de verdict. Donnez au décideur `skillsDir: no-skills`. | ||
| 81 | 80 | ||
| 82 | ## Voir aussi | 81 | ## Voir aussi |
| 83 | 82 | ||
modified
docs/fr/how-to/write-decision-questions.md +8 -28 | @@ -121,13 +121,8 @@ decision: | ||
| 121 | 121 | specialist: A known long-term condition followed by a specialist |
| 122 | 122 | emergency: A problem that cannot wait |
| 123 | 123 | |
| 124 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 125 | -bashTool: false | |
| 126 | -editTools: false | |
| 127 | -planTool: false | |
| 128 | -fetchContentTool: false | |
| 129 | -askUserTool: false | |
| 130 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 124 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 125 | +# skills off by itself (no `…: false` lines needed). | |
| 131 | 126 | agentsMd: |
| 132 | 127 | enabled: false |
| 133 | 128 | ``` |
| @@ -167,13 +162,8 @@ decision: | ||
| 167 | 162 | two: Two dice are missing |
| 168 | 163 | all: All three are missing |
| 169 | 164 | |
| 170 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 171 | -bashTool: false | |
| 172 | -editTools: false | |
| 173 | -planTool: false | |
| 174 | -fetchContentTool: false | |
| 175 | -askUserTool: false | |
| 176 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 165 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 166 | +# skills off by itself (no `…: false` lines needed). | |
| 177 | 167 | agentsMd: |
| 178 | 168 | enabled: false |
| 179 | 169 | ``` |
| @@ -216,13 +206,8 @@ decision: | ||
| 216 | 206 | misty_step: I am surrounded or grabbed and must get away |
| 217 | 207 | sleep: Several weak enemies, and we would rather not kill them |
| 218 | 208 | |
| 219 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 220 | -bashTool: false | |
| 221 | -editTools: false | |
| 222 | -planTool: false | |
| 223 | -fetchContentTool: false | |
| 224 | -askUserTool: false | |
| 225 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 209 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 210 | +# skills off by itself (no `…: false` lines needed). | |
| 226 | 211 | agentsMd: |
| 227 | 212 | enabled: false |
| 228 | 213 | ``` |
| @@ -269,13 +254,8 @@ decision: | ||
| 269 | 254 | type: noul |
| 270 | 255 | instructions: My character is badly hurt. |
| 271 | 256 | |
| 272 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | |
| 273 | -bashTool: false | |
| 274 | -editTools: false | |
| 275 | -planTool: false | |
| 276 | -fetchContentTool: false | |
| 277 | -askUserTool: false | |
| 278 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | |
| 257 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and | |
| 258 | +# skills off by itself (no `…: false` lines needed). | |
| 279 | 259 | agentsMd: |
| 280 | 260 | enabled: false |
| 281 | 261 | ``` |
| @@ -121,13 +121,8 @@ decision: | |||
| 121 | specialist: A known long-term condition followed by a specialist | 121 | specialist: A known long-term condition followed by a specialist |
| 122 | emergency: A problem that cannot wait | 122 | emergency: A problem that cannot wait |
| 123 | 123 | ||
| 124 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 124 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 125 | -bashTool: false | 125 | +# skills off by itself (no `…: false` lines needed). |
| 126 | -editTools: false | ||
| 127 | -planTool: false | ||
| 128 | -fetchContentTool: false | ||
| 129 | -askUserTool: false | ||
| 130 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 131 | agentsMd: | 126 | agentsMd: |
| 132 | enabled: false | 127 | enabled: false |
| 133 | ``` | 128 | ``` |
| @@ -167,13 +162,8 @@ decision: | |||
| 167 | two: Two dice are missing | 162 | two: Two dice are missing |
| 168 | all: All three are missing | 163 | all: All three are missing |
| 169 | 164 | ||
| 170 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 165 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 171 | -bashTool: false | 166 | +# skills off by itself (no `…: false` lines needed). |
| 172 | -editTools: false | ||
| 173 | -planTool: false | ||
| 174 | -fetchContentTool: false | ||
| 175 | -askUserTool: false | ||
| 176 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 177 | agentsMd: | 167 | agentsMd: |
| 178 | enabled: false | 168 | enabled: false |
| 179 | ``` | 169 | ``` |
| @@ -216,13 +206,8 @@ decision: | |||
| 216 | misty_step: I am surrounded or grabbed and must get away | 206 | misty_step: I am surrounded or grabbed and must get away |
| 217 | sleep: Several weak enemies, and we would rather not kill them | 207 | sleep: Several weak enemies, and we would rather not kill them |
| 218 | 208 | ||
| 219 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 209 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 220 | -bashTool: false | 210 | +# skills off by itself (no `…: false` lines needed). |
| 221 | -editTools: false | ||
| 222 | -planTool: false | ||
| 223 | -fetchContentTool: false | ||
| 224 | -askUserTool: false | ||
| 225 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 226 | agentsMd: | 211 | agentsMd: |
| 227 | enabled: false | 212 | enabled: false |
| 228 | ``` | 213 | ``` |
| @@ -269,13 +254,8 @@ decision: | |||
| 269 | type: noul | 254 | type: noul |
| 270 | instructions: My character is badly hurt. | 255 | instructions: My character is badly hurt. |
| 271 | 256 | ||
| 272 | -# A decider declares no tool: its model never calls one (mm refuses to start otherwise). | 257 | +# A decider gets no tool: mm turns bash, the file tools, plan, ask_user and |
| 273 | -bashTool: false | 258 | +# skills off by itself (no `…: false` lines needed). |
| 274 | -editTools: false | ||
| 275 | -planTool: false | ||
| 276 | -fetchContentTool: false | ||
| 277 | -askUserTool: false | ||
| 278 | -skillsDir: no-skills # "" would read ./skills of wherever mm runs, and a decider refuses tools | ||
| 279 | agentsMd: | 259 | agentsMd: |
| 280 | enabled: false | 260 | enabled: false |
| 281 | ``` | 261 | ``` |
modified
docs/fr/reference/configuration.md +3 -7 | @@ -278,7 +278,8 @@ Quand le bloc est présent : | ||
| 278 | 278 | urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 · a quick look 0.06 · no review needed 0.03 |
| 279 | 279 | ``` |
| 280 | 280 | |
| 281 | -- aucun outil ne peut être déclaré (`unload_model` n'est pas déclaré ; `/unload` reste) ; | |
| 281 | +- aucun outil n'est déclaré, quoi que disent `bashTool`, `editTools`, `planTool`, `askUserTool`, `fetchContentTool`, `saveAnswerTool`, `unloadModels`, `skillsDir`, `docSearch` et `sessions` : le fichier n'a besoin d'aucune ligne `…: false` (`/unload` reste) ; | |
| 282 | +- `mcp.enabled: true`, `lsp.servers`, `mmConfigs` ou `tools:` arrêtent le démarrage avec une erreur, avant qu'aucun serveur ne soit lancé ; | |
| 282 | 283 | - la bannière affiche `tools: none (decision model: <n> question(s))` et aucun avertissement « no skills » / « no tool ». |
| 283 | 284 | |
| 284 | 285 | Exemple : |
| @@ -303,11 +304,6 @@ decision: | ||
| 303 | 304 | type: score |
| 304 | 305 | instructions: How carefully should a person review this command before it runs? |
| 305 | 306 | criteria: [no review needed, a quick look, a careful review] |
| 306 | -bashTool: false | |
| 307 | -editTools: false | |
| 308 | -planTool: false | |
| 309 | -askUserTool: false | |
| 310 | -skillsDir: no-skills | |
| 311 | 307 | ``` |
| 312 | 308 | |
| 313 | 309 | ### Serveurs |
| @@ -448,7 +444,7 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | ||
| 448 | 444 | | `line <n>: question "<nom>": a choice needs at least 2 options, got <n>` / `a score needs 2 to 10 levels, got <n>` / `a noul question takes no criteria` | `criteria` de mauvaise forme ou taille | |
| 449 | 445 | | `decision: dmr: no /v1/systemone route (…)` | `decision` avec `provider: dmr` | |
| 450 | 446 | | `decision and structuredOutput cannot be used together: …` | les deux dans un même fichier | |
| 451 | -| `decision cannot be used with tools — this agent declares: <outils> (…)` | un décideur avec un outil déclaré ; signalé après l'assemblage des outils | | |
| 447 | +| `decision cannot be used with tools — this agent declares: <sources> (…)` | un décideur dont le fichier pose `mcp.enabled: true`, `lsp.servers`, `mmConfigs` ou `tools:` ; `<sources>` les liste (`mcp`, `lsp servers`, `mmConfigs`, `tools`) ; signalé avant l'assemblage des outils | | |
| 452 | 448 | | `skillRouting.decider: …` | le fichier du décideur manque, n'a pas de `provider` ou de `model`, ou nomme `dmr` | |
| 453 | 449 | | `skillRouting.minConfidence must be between 0 and 1` / `skillRouting.timeout must be > 0` | hors limites | |
| 454 | 450 | | `toolRouting.decider: …` | le fichier du décideur manque, n'a pas de `provider` ou de `model`, ou nomme `dmr` | |
| @@ -278,7 +278,8 @@ Quand le bloc est présent : | |||
| 278 | urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 · a quick look 0.06 · no review needed 0.03 | 278 | urgency: 1.89 → a careful review (confidence 0.83) — a careful review 0.92 · a quick look 0.06 · no review needed 0.03 |
| 279 | ``` | 279 | ``` |
| 280 | 280 | ||
| 281 | -- aucun outil ne peut être déclaré (`unload_model` n'est pas déclaré ; `/unload` reste) ; | 281 | +- aucun outil n'est déclaré, quoi que disent `bashTool`, `editTools`, `planTool`, `askUserTool`, `fetchContentTool`, `saveAnswerTool`, `unloadModels`, `skillsDir`, `docSearch` et `sessions` : le fichier n'a besoin d'aucune ligne `…: false` (`/unload` reste) ; |
| 282 | +- `mcp.enabled: true`, `lsp.servers`, `mmConfigs` ou `tools:` arrêtent le démarrage avec une erreur, avant qu'aucun serveur ne soit lancé ; | ||
| 282 | - la bannière affiche `tools: none (decision model: <n> question(s))` et aucun avertissement « no skills » / « no tool ». | 283 | - la bannière affiche `tools: none (decision model: <n> question(s))` et aucun avertissement « no skills » / « no tool ». |
| 283 | 284 | ||
| 284 | Exemple : | 285 | Exemple : |
| @@ -303,11 +304,6 @@ decision: | |||
| 303 | type: score | 304 | type: score |
| 304 | instructions: How carefully should a person review this command before it runs? | 305 | instructions: How carefully should a person review this command before it runs? |
| 305 | criteria: [no review needed, a quick look, a careful review] | 306 | criteria: [no review needed, a quick look, a careful review] |
| 306 | -bashTool: false | ||
| 307 | -editTools: false | ||
| 308 | -planTool: false | ||
| 309 | -askUserTool: false | ||
| 310 | -skillsDir: no-skills | ||
| 311 | ``` | 307 | ``` |
| 312 | 308 | ||
| 313 | ### Serveurs | 309 | ### Serveurs |
| @@ -448,7 +444,7 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | |||
| 448 | | `line <n>: question "<nom>": a choice needs at least 2 options, got <n>` / `a score needs 2 to 10 levels, got <n>` / `a noul question takes no criteria` | `criteria` de mauvaise forme ou taille | | 444 | | `line <n>: question "<nom>": a choice needs at least 2 options, got <n>` / `a score needs 2 to 10 levels, got <n>` / `a noul question takes no criteria` | `criteria` de mauvaise forme ou taille | |
| 449 | | `decision: dmr: no /v1/systemone route (…)` | `decision` avec `provider: dmr` | | 445 | | `decision: dmr: no /v1/systemone route (…)` | `decision` avec `provider: dmr` | |
| 450 | | `decision and structuredOutput cannot be used together: …` | les deux dans un même fichier | | 446 | | `decision and structuredOutput cannot be used together: …` | les deux dans un même fichier | |
| 451 | -| `decision cannot be used with tools — this agent declares: <outils> (…)` | un décideur avec un outil déclaré ; signalé après l'assemblage des outils | | 447 | +| `decision cannot be used with tools — this agent declares: <sources> (…)` | un décideur dont le fichier pose `mcp.enabled: true`, `lsp.servers`, `mmConfigs` ou `tools:` ; `<sources>` les liste (`mcp`, `lsp servers`, `mmConfigs`, `tools`) ; signalé avant l'assemblage des outils | |
| 452 | | `skillRouting.decider: …` | le fichier du décideur manque, n'a pas de `provider` ou de `model`, ou nomme `dmr` | | 448 | | `skillRouting.decider: …` | le fichier du décideur manque, n'a pas de `provider` ou de `model`, ou nomme `dmr` | |
| 453 | | `skillRouting.minConfidence must be between 0 and 1` / `skillRouting.timeout must be > 0` | hors limites | | 449 | | `skillRouting.minConfidence must be between 0 and 1` / `skillRouting.timeout must be > 0` | hors limites | |
| 454 | | `toolRouting.decider: …` | le fichier du décideur manque, n'a pas de `provider` ou de `model`, ou nomme `dmr` | | 450 | | `toolRouting.decider: …` | le fichier du décideur manque, n'a pas de `provider` ou de `model`, ou nomme `dmr` | |
modified
internal/config/decision.go +36 -9 | @@ -183,20 +183,47 @@ func (c Config) validateDecision() error { | ||
| 183 | 183 | } |
| 184 | 184 | |
| 185 | 185 | // DecisionConflict returns the error main stops on when a decider agent |
| 186 | -// declares tools: its model never calls one, and a banner listing them would | |
| 187 | -// say otherwise. tools are the names of the declared tools. | |
| 186 | +// declares a tool source by hand: its model never calls a tool (ticket 141). | |
| 187 | +// The built-in tools (bash, the file tools, plan, ask_user, read_skill, …) | |
| 188 | +// are not checked: main turns them off for a decider whatever the file says, | |
| 189 | +// so a decider file needs no `bashTool: false` lines. What is refused is what | |
| 190 | +// only a deliberate entry can bring — MCP, language servers, other agents, | |
| 191 | +// declared `tools:` — since silently ignoring it would hide a mistake. | |
| 192 | +// main calls it BEFORE assembling the tools, so no server is started. | |
| 188 | 193 | // |
| 189 | -// if err := config.DecisionConflict([]string{"bash"}); err != nil { | |
| 190 | -// // decision cannot be used with tools — this agent declares: bash (…) | |
| 194 | +// if err := config.DecisionConflict(); err != nil { | |
| 195 | +// // decision cannot be used with tools — this agent declares: mcp, mmConfigs (…) | |
| 191 | 196 | // } |
| 192 | -func DecisionConflict(tools []string) error { | |
| 193 | - if Cfg.Decision == nil || len(tools) == 0 { | |
| 197 | +func DecisionConflict() error { | |
| 198 | + if Cfg.Decision == nil { | |
| 199 | + return nil | |
| 200 | + } | |
| 201 | + declared := deciderToolSources() | |
| 202 | + if len(declared) == 0 { | |
| 194 | 203 | return nil |
| 195 | 204 | } |
| 196 | 205 | return fmt.Errorf("decision cannot be used with tools — this agent declares: %s "+ |
| 197 | - "(turn them off: bashTool: false, planTool: false, editTools: false, fetchContentTool: false, "+ | |
| 198 | - "askUserTool: false, no skillsDir with skills, no mmConfigs, no lsp servers, mcp disabled)", | |
| 199 | - strings.Join(tools, ", ")) | |
| 206 | + "(remove them: mcp disabled, no lsp servers, no mmConfigs, no tools)", | |
| 207 | + strings.Join(declared, ", ")) | |
| 208 | +} | |
| 209 | + | |
| 210 | +// deciderToolSources lists the tool sources this file declares by hand, in | |
| 211 | +// the order of the error message's advice. | |
| 212 | +func deciderToolSources() []string { | |
| 213 | + var declared []string | |
| 214 | + if Cfg.MCP.Enabled { | |
| 215 | + declared = append(declared, "mcp") | |
| 216 | + } | |
| 217 | + if len(Cfg.LSP.Servers) > 0 { | |
| 218 | + declared = append(declared, "lsp servers") | |
| 219 | + } | |
| 220 | + if len(Cfg.MMConfigs) > 0 { | |
| 221 | + declared = append(declared, "mmConfigs") | |
| 222 | + } | |
| 223 | + if len(Cfg.Tools) > 0 { | |
| 224 | + declared = append(declared, "tools") | |
| 225 | + } | |
| 226 | + return declared | |
| 200 | 227 | } |
| 201 | 228 | |
| 202 | 229 | // Skill-routing defaults: the confidence the first live runs of LiquidAI |
| @@ -183,20 +183,47 @@ func (c Config) validateDecision() error { | |||
| 183 | } | 183 | } |
| 184 | 184 | ||
| 185 | // DecisionConflict returns the error main stops on when a decider agent | 185 | // DecisionConflict returns the error main stops on when a decider agent |
| 186 | -// declares tools: its model never calls one, and a banner listing them would | 186 | +// declares a tool source by hand: its model never calls a tool (ticket 141). |
| 187 | -// say otherwise. tools are the names of the declared tools. | 187 | +// The built-in tools (bash, the file tools, plan, ask_user, read_skill, …) |
| 188 | +// are not checked: main turns them off for a decider whatever the file says, | ||
| 189 | +// so a decider file needs no `bashTool: false` lines. What is refused is what | ||
| 190 | +// only a deliberate entry can bring — MCP, language servers, other agents, | ||
| 191 | +// declared `tools:` — since silently ignoring it would hide a mistake. | ||
| 192 | +// main calls it BEFORE assembling the tools, so no server is started. | ||
| 188 | // | 193 | // |
| 189 | -// if err := config.DecisionConflict([]string{"bash"}); err != nil { | 194 | +// if err := config.DecisionConflict(); err != nil { |
| 190 | -// // decision cannot be used with tools — this agent declares: bash (…) | 195 | +// // decision cannot be used with tools — this agent declares: mcp, mmConfigs (…) |
| 191 | // } | 196 | // } |
| 192 | -func DecisionConflict(tools []string) error { | 197 | +func DecisionConflict() error { |
| 193 | - if Cfg.Decision == nil || len(tools) == 0 { | 198 | + if Cfg.Decision == nil { |
| 199 | + return nil | ||
| 200 | + } | ||
| 201 | + declared := deciderToolSources() | ||
| 202 | + if len(declared) == 0 { | ||
| 194 | return nil | 203 | return nil |
| 195 | } | 204 | } |
| 196 | return fmt.Errorf("decision cannot be used with tools — this agent declares: %s "+ | 205 | return fmt.Errorf("decision cannot be used with tools — this agent declares: %s "+ |
| 197 | - "(turn them off: bashTool: false, planTool: false, editTools: false, fetchContentTool: false, "+ | 206 | + "(remove them: mcp disabled, no lsp servers, no mmConfigs, no tools)", |
| 198 | - "askUserTool: false, no skillsDir with skills, no mmConfigs, no lsp servers, mcp disabled)", | 207 | + strings.Join(declared, ", ")) |
| 199 | - strings.Join(tools, ", ")) | 208 | +} |
| 209 | + | ||
| 210 | +// deciderToolSources lists the tool sources this file declares by hand, in | ||
| 211 | +// the order of the error message's advice. | ||
| 212 | +func deciderToolSources() []string { | ||
| 213 | + var declared []string | ||
| 214 | + if Cfg.MCP.Enabled { | ||
| 215 | + declared = append(declared, "mcp") | ||
| 216 | + } | ||
| 217 | + if len(Cfg.LSP.Servers) > 0 { | ||
| 218 | + declared = append(declared, "lsp servers") | ||
| 219 | + } | ||
| 220 | + if len(Cfg.MMConfigs) > 0 { | ||
| 221 | + declared = append(declared, "mmConfigs") | ||
| 222 | + } | ||
| 223 | + if len(Cfg.Tools) > 0 { | ||
| 224 | + declared = append(declared, "tools") | ||
| 225 | + } | ||
| 226 | + return declared | ||
| 200 | } | 227 | } |
| 201 | 228 | ||
| 202 | // Skill-routing defaults: the confidence the first live runs of LiquidAI | 229 | // Skill-routing defaults: the confidence the first live runs of LiquidAI |
modified
internal/config/decision_test.go +52 -4 | @@ -97,14 +97,62 @@ func TestInvalidDecisionBlocksAreConfigErrors(t *testing.T) { | ||
| 97 | 97 | } |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | -func TestDecisionConflictListsTheDeclaredTools(t *testing.T) { | |
| 100 | +// Ticket 141: a decider file that keeps the built-in defaults (bashTool, | |
| 101 | +// editTools, planTool, askUserTool on; skillsDir: skills) is accepted — | |
| 102 | +// main turns those tools off itself. | |
| 103 | +func TestDecisionConflictAcceptsTheBuiltInDefaults(t *testing.T) { | |
| 101 | 104 | if err := loadYAML(t, deciderYAML); err != nil { |
| 102 | 105 | t.Fatal(err) |
| 103 | 106 | } |
| 104 | - if err := DecisionConflict(nil); err != nil { | |
| 105 | - t.Errorf("no tools: %v", err) | |
| 107 | + if !Cfg.BashTool || !Cfg.EditTools || !Cfg.PlanTool || !Cfg.AskUserTool || Cfg.SkillsDir != "skills" { | |
| 108 | + t.Fatalf("the fixture should keep the built-in defaults: %+v", Cfg) | |
| 106 | 109 | } |
| 107 | - if err := DecisionConflict([]string{"bash", "plan"}); err == nil || !strings.Contains(err.Error(), "declares: bash, plan") { | |
| 110 | + if err := DecisionConflict(); err != nil { | |
| 111 | + t.Errorf("DecisionConflict = %v", err) | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +func TestDecisionConflictRefusesEachToolSourceDeclaredByHand(t *testing.T) { | |
| 116 | + for _, tc := range []struct { | |
| 117 | + name string | |
| 118 | + declare func() | |
| 119 | + }{ | |
| 120 | + {"mcp", func() { Cfg.MCP.Enabled = true }}, | |
| 121 | + {"lsp servers", func() { Cfg.LSP.Servers = []LSPServer{{Name: "gopls", Command: "gopls", Extensions: []string{".go"}}} }}, | |
| 122 | + {"mmConfigs", func() { Cfg.MMConfigs = map[string]AgentRef{"bob": {}} }}, | |
| 123 | + {"tools", func() { Cfg.Tools = []DeclaredTool{{Name: "release"}} }}, | |
| 124 | + } { | |
| 125 | + t.Run(tc.name, func(t *testing.T) { | |
| 126 | + if err := loadYAML(t, deciderYAML); err != nil { | |
| 127 | + t.Fatal(err) | |
| 128 | + } | |
| 129 | + tc.declare() | |
| 130 | + err := DecisionConflict() | |
| 131 | + if err == nil || !strings.Contains(err.Error(), "declares: "+tc.name+" ") { | |
| 132 | + t.Errorf("DecisionConflict = %v", err) | |
| 133 | + } | |
| 134 | + }) | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +func TestDecisionConflictListsEverySourceDeclared(t *testing.T) { | |
| 139 | + if err := loadYAML(t, deciderYAML); err != nil { | |
| 140 | + t.Fatal(err) | |
| 141 | + } | |
| 142 | + Cfg.MCP.Enabled = true | |
| 143 | + Cfg.Tools = []DeclaredTool{{Name: "release"}} | |
| 144 | + if err := DecisionConflict(); err == nil || !strings.Contains(err.Error(), "declares: mcp, tools ") { | |
| 145 | + t.Errorf("DecisionConflict = %v", err) | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +// A chat agent may declare anything: the check is for deciders only. | |
| 150 | +func TestDecisionConflictIgnoresAChatAgent(t *testing.T) { | |
| 151 | + if err := loadYAML(t, "model: m\n"); err != nil { | |
| 152 | + t.Fatal(err) | |
| 153 | + } | |
| 154 | + Cfg.MCP.Enabled = true | |
| 155 | + if err := DecisionConflict(); err != nil { | |
| 108 | 156 | t.Errorf("DecisionConflict = %v", err) |
| 109 | 157 | } |
| 110 | 158 | } |
| @@ -97,14 +97,62 @@ func TestInvalidDecisionBlocksAreConfigErrors(t *testing.T) { | |||
| 97 | } | 97 | } |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | -func TestDecisionConflictListsTheDeclaredTools(t *testing.T) { | 100 | +// Ticket 141: a decider file that keeps the built-in defaults (bashTool, |
| 101 | +// editTools, planTool, askUserTool on; skillsDir: skills) is accepted — | ||
| 102 | +// main turns those tools off itself. | ||
| 103 | +func TestDecisionConflictAcceptsTheBuiltInDefaults(t *testing.T) { | ||
| 101 | if err := loadYAML(t, deciderYAML); err != nil { | 104 | if err := loadYAML(t, deciderYAML); err != nil { |
| 102 | t.Fatal(err) | 105 | t.Fatal(err) |
| 103 | } | 106 | } |
| 104 | - if err := DecisionConflict(nil); err != nil { | 107 | + if !Cfg.BashTool || !Cfg.EditTools || !Cfg.PlanTool || !Cfg.AskUserTool || Cfg.SkillsDir != "skills" { |
| 105 | - t.Errorf("no tools: %v", err) | 108 | + t.Fatalf("the fixture should keep the built-in defaults: %+v", Cfg) |
| 106 | } | 109 | } |
| 107 | - if err := DecisionConflict([]string{"bash", "plan"}); err == nil || !strings.Contains(err.Error(), "declares: bash, plan") { | 110 | + if err := DecisionConflict(); err != nil { |
| 111 | + t.Errorf("DecisionConflict = %v", err) | ||
| 112 | + } | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +func TestDecisionConflictRefusesEachToolSourceDeclaredByHand(t *testing.T) { | ||
| 116 | + for _, tc := range []struct { | ||
| 117 | + name string | ||
| 118 | + declare func() | ||
| 119 | + }{ | ||
| 120 | + {"mcp", func() { Cfg.MCP.Enabled = true }}, | ||
| 121 | + {"lsp servers", func() { Cfg.LSP.Servers = []LSPServer{{Name: "gopls", Command: "gopls", Extensions: []string{".go"}}} }}, | ||
| 122 | + {"mmConfigs", func() { Cfg.MMConfigs = map[string]AgentRef{"bob": {}} }}, | ||
| 123 | + {"tools", func() { Cfg.Tools = []DeclaredTool{{Name: "release"}} }}, | ||
| 124 | + } { | ||
| 125 | + t.Run(tc.name, func(t *testing.T) { | ||
| 126 | + if err := loadYAML(t, deciderYAML); err != nil { | ||
| 127 | + t.Fatal(err) | ||
| 128 | + } | ||
| 129 | + tc.declare() | ||
| 130 | + err := DecisionConflict() | ||
| 131 | + if err == nil || !strings.Contains(err.Error(), "declares: "+tc.name+" ") { | ||
| 132 | + t.Errorf("DecisionConflict = %v", err) | ||
| 133 | + } | ||
| 134 | + }) | ||
| 135 | + } | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +func TestDecisionConflictListsEverySourceDeclared(t *testing.T) { | ||
| 139 | + if err := loadYAML(t, deciderYAML); err != nil { | ||
| 140 | + t.Fatal(err) | ||
| 141 | + } | ||
| 142 | + Cfg.MCP.Enabled = true | ||
| 143 | + Cfg.Tools = []DeclaredTool{{Name: "release"}} | ||
| 144 | + if err := DecisionConflict(); err == nil || !strings.Contains(err.Error(), "declares: mcp, tools ") { | ||
| 145 | + t.Errorf("DecisionConflict = %v", err) | ||
| 146 | + } | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +// A chat agent may declare anything: the check is for deciders only. | ||
| 150 | +func TestDecisionConflictIgnoresAChatAgent(t *testing.T) { | ||
| 151 | + if err := loadYAML(t, "model: m\n"); err != nil { | ||
| 152 | + t.Fatal(err) | ||
| 153 | + } | ||
| 154 | + Cfg.MCP.Enabled = true | ||
| 155 | + if err := DecisionConflict(); err != nil { | ||
| 108 | t.Errorf("DecisionConflict = %v", err) | 156 | t.Errorf("DecisionConflict = %v", err) |
| 109 | } | 157 | } |
| 110 | } | 158 | } |
added
learning-rust/.gitignore +0 -0 | new file mode 100644 |
| new file mode 100644 |
added
learning-rust/agent-config/agent.yaml +109 -0 | new file mode 100644 | ||
| @@ -0,0 +1,109 @@ | ||
| 1 | +# Tuto — an agent that answers with mini tutorials | |
| 2 | +provider: llmman | |
| 3 | +#model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M | |
| 4 | +model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q8_0:Q8_0 | |
| 5 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 6 | +fallback: http://host.docker.internal:17434/v1 | |
| 7 | + | |
| 8 | +# mm's budget for compression; llmman served 131072 during the measurements. | |
| 9 | +contextWindow: 32768 | |
| 10 | + | |
| 11 | +sampling: | |
| 12 | + # Explanations, not code edits: a little more freedom than a coder's 0.2, | |
| 13 | + # never 0.0 with reasoning on (thinking models loop when greedy). | |
| 14 | + temperature: 0.3 | |
| 15 | + top_p: 0.9 | |
| 16 | + # Measured in wip/: 16384 holds the reasoning plus a long answer; 24576 made | |
| 17 | + # things worse (the model reasons longer). | |
| 18 | + max_tokens: 16384 | |
| 19 | + parallel_tool_calls: false | |
| 20 | + # The template's only reasoning switch (no effort level — see wip/README). | |
| 21 | + chat_template_kwargs: | |
| 22 | + enable_thinking: false | |
| 23 | + | |
| 24 | +showThinking: true | |
| 25 | +firstTokenTimeout: 3m | |
| 26 | +watchdogTimeout: 2m | |
| 27 | + | |
| 28 | +# A mini tutorial is one answer, maybe after one fetch, maybe one save. | |
| 29 | +maxTurns: 8 | |
| 30 | +maxOutput: 8000 | |
| 31 | +previewLines: 10 | |
| 32 | + | |
| 33 | +# ─── Tools: the documentation, and saving on request ───────────────────────── | |
| 34 | +yolo: true | |
| 35 | +bashTool: true # change: 2026 10 21 | |
| 36 | +editTools: true # change: 2026 10 21 | |
| 37 | +planTool: false | |
| 38 | +askUserTool: false | |
| 39 | +fetchContentTool: true | |
| 40 | +saveAnswerTool: true | |
| 41 | +skillsDir: no-skills | |
| 42 | + | |
| 43 | +docSearch: | |
| 44 | + #searchDocs: true | |
| 45 | + autoSearchDocs: true | |
| 46 | + # A relative dir is resolved against the working directory (workspace/), | |
| 47 | + # not against this file: the docs live in agent-config/, mounted read-only | |
| 48 | + # at the same path as on the host (as AGENT_CONFIG assumes). | |
| 49 | + dirs: [/Users/k33g/kDrive/Rickub/bots-garden/mini-me/learning-rust/agent-config/docs] | |
| 50 | + extensions: [.md] | |
| 51 | + maxFileSize: 1MB | |
| 52 | + | |
| 53 | +# change: 2026 10 21 | |
| 54 | +lsp: | |
| 55 | + servers: | |
| 56 | + - name: rust | |
| 57 | + command: rust-analyzer | |
| 58 | + extensions: [".rs"] | |
| 59 | + | |
| 60 | +agentsMd: | |
| 61 | + enabled: false | |
| 62 | + | |
| 63 | +sessions: | |
| 64 | + enabled: true | |
| 65 | + dir: .mm/sessions | |
| 66 | + | |
| 67 | +context: | |
| 68 | + enabled: true | |
| 69 | + threshold: 60 | |
| 70 | + keepLastTurns: 2 | |
| 71 | + summaryMaxTokens: 1200 | |
| 72 | + | |
| 73 | +acp: | |
| 74 | + name: tuto | |
| 75 | + title: 📘 Tuto | |
| 76 | + | |
| 77 | +system: | | |
| 78 | + You are Tuto. You answer questions about programming languages, shells and | |
| 79 | + developer tools with a MINI TUTORIAL, in the language of the question. | |
| 80 | + | |
| 81 | + HOW TO ANSWER | |
| 82 | + 1. Decide whether you need the documentation. Fetch ONE page with | |
| 83 | + fetch_content only when the subject is a recent or niche tool (for | |
| 84 | + example Nushell, a young framework), when the user names a version, or | |
| 85 | + when you are not sure a command or an option exists. For well-known | |
| 86 | + topics (Go, Rust, Python, Kotlin, Java basics) answer from what you know. | |
| 87 | + 2. Write the mini tutorial directly in your reply, in Markdown: | |
| 88 | + - a title (#) | |
| 89 | + - one sentence: what the reader will be able to do | |
| 90 | + - Prerequisites: a short list | |
| 91 | + - 3 to 6 numbered steps, each with a code block and, when it prints | |
| 92 | + something, the expected output | |
| 93 | + - Pitfalls: 2 or 3 short points | |
| 94 | + - To go further: one or two lines | |
| 95 | + Between 300 and 600 words. Short snippets; a complete program only when | |
| 96 | + the user asks for one. | |
| 97 | + 3. A follow-up question ("and in Go?") refers to your previous answer: | |
| 98 | + answer the same need for the new language or tool. | |
| 99 | + | |
| 100 | + SAVING TO A FILE | |
| 101 | + - Never save unless the user asks to save, write or export to a file. | |
| 102 | + - When asked, call save_answer with the file name the user gave (or a short | |
| 103 | + name made from the subject, ending in .md). Do NOT write the tutorial | |
| 104 | + again: save_answer saves your previous answer by itself — call it alone, | |
| 105 | + with no text in your reply. | |
| 106 | + - When the request is "explain X and save it", write the tutorial in your | |
| 107 | + reply AND call save_answer in that same reply. | |
| 108 | + - If save_answer says the file exists, tell the user and ask for another | |
| 109 | + name. | |
| new file mode 100644 | |||
| @@ -0,0 +1,109 @@ | |||
| 1 | +# Tuto — an agent that answers with mini tutorials | ||
| 2 | +provider: llmman | ||
| 3 | +#model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M | ||
| 4 | +model: hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q8_0:Q8_0 | ||
| 5 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 6 | +fallback: http://host.docker.internal:17434/v1 | ||
| 7 | + | ||
| 8 | +# mm's budget for compression; llmman served 131072 during the measurements. | ||
| 9 | +contextWindow: 32768 | ||
| 10 | + | ||
| 11 | +sampling: | ||
| 12 | + # Explanations, not code edits: a little more freedom than a coder's 0.2, | ||
| 13 | + # never 0.0 with reasoning on (thinking models loop when greedy). | ||
| 14 | + temperature: 0.3 | ||
| 15 | + top_p: 0.9 | ||
| 16 | + # Measured in wip/: 16384 holds the reasoning plus a long answer; 24576 made | ||
| 17 | + # things worse (the model reasons longer). | ||
| 18 | + max_tokens: 16384 | ||
| 19 | + parallel_tool_calls: false | ||
| 20 | + # The template's only reasoning switch (no effort level — see wip/README). | ||
| 21 | + chat_template_kwargs: | ||
| 22 | + enable_thinking: false | ||
| 23 | + | ||
| 24 | +showThinking: true | ||
| 25 | +firstTokenTimeout: 3m | ||
| 26 | +watchdogTimeout: 2m | ||
| 27 | + | ||
| 28 | +# A mini tutorial is one answer, maybe after one fetch, maybe one save. | ||
| 29 | +maxTurns: 8 | ||
| 30 | +maxOutput: 8000 | ||
| 31 | +previewLines: 10 | ||
| 32 | + | ||
| 33 | +# ─── Tools: the documentation, and saving on request ───────────────────────── | ||
| 34 | +yolo: true | ||
| 35 | +bashTool: true # change: 2026 10 21 | ||
| 36 | +editTools: true # change: 2026 10 21 | ||
| 37 | +planTool: false | ||
| 38 | +askUserTool: false | ||
| 39 | +fetchContentTool: true | ||
| 40 | +saveAnswerTool: true | ||
| 41 | +skillsDir: no-skills | ||
| 42 | + | ||
| 43 | +docSearch: | ||
| 44 | + #searchDocs: true | ||
| 45 | + autoSearchDocs: true | ||
| 46 | + # A relative dir is resolved against the working directory (workspace/), | ||
| 47 | + # not against this file: the docs live in agent-config/, mounted read-only | ||
| 48 | + # at the same path as on the host (as AGENT_CONFIG assumes). | ||
| 49 | + dirs: [/Users/k33g/kDrive/Rickub/bots-garden/mini-me/learning-rust/agent-config/docs] | ||
| 50 | + extensions: [.md] | ||
| 51 | + maxFileSize: 1MB | ||
| 52 | + | ||
| 53 | +# change: 2026 10 21 | ||
| 54 | +lsp: | ||
| 55 | + servers: | ||
| 56 | + - name: rust | ||
| 57 | + command: rust-analyzer | ||
| 58 | + extensions: [".rs"] | ||
| 59 | + | ||
| 60 | +agentsMd: | ||
| 61 | + enabled: false | ||
| 62 | + | ||
| 63 | +sessions: | ||
| 64 | + enabled: true | ||
| 65 | + dir: .mm/sessions | ||
| 66 | + | ||
| 67 | +context: | ||
| 68 | + enabled: true | ||
| 69 | + threshold: 60 | ||
| 70 | + keepLastTurns: 2 | ||
| 71 | + summaryMaxTokens: 1200 | ||
| 72 | + | ||
| 73 | +acp: | ||
| 74 | + name: tuto | ||
| 75 | + title: 📘 Tuto | ||
| 76 | + | ||
| 77 | +system: | | ||
| 78 | + You are Tuto. You answer questions about programming languages, shells and | ||
| 79 | + developer tools with a MINI TUTORIAL, in the language of the question. | ||
| 80 | + | ||
| 81 | + HOW TO ANSWER | ||
| 82 | + 1. Decide whether you need the documentation. Fetch ONE page with | ||
| 83 | + fetch_content only when the subject is a recent or niche tool (for | ||
| 84 | + example Nushell, a young framework), when the user names a version, or | ||
| 85 | + when you are not sure a command or an option exists. For well-known | ||
| 86 | + topics (Go, Rust, Python, Kotlin, Java basics) answer from what you know. | ||
| 87 | + 2. Write the mini tutorial directly in your reply, in Markdown: | ||
| 88 | + - a title (#) | ||
| 89 | + - one sentence: what the reader will be able to do | ||
| 90 | + - Prerequisites: a short list | ||
| 91 | + - 3 to 6 numbered steps, each with a code block and, when it prints | ||
| 92 | + something, the expected output | ||
| 93 | + - Pitfalls: 2 or 3 short points | ||
| 94 | + - To go further: one or two lines | ||
| 95 | + Between 300 and 600 words. Short snippets; a complete program only when | ||
| 96 | + the user asks for one. | ||
| 97 | + 3. A follow-up question ("and in Go?") refers to your previous answer: | ||
| 98 | + answer the same need for the new language or tool. | ||
| 99 | + | ||
| 100 | + SAVING TO A FILE | ||
| 101 | + - Never save unless the user asks to save, write or export to a file. | ||
| 102 | + - When asked, call save_answer with the file name the user gave (or a short | ||
| 103 | + name made from the subject, ending in .md). Do NOT write the tutorial | ||
| 104 | + again: save_answer saves your previous answer by itself — call it alone, | ||
| 105 | + with no text in your reply. | ||
| 106 | + - When the request is "explain X and save it", write the tutorial in your | ||
| 107 | + reply AND call save_answer in that same reply. | ||
| 108 | + - If save_answer says the file exists, tell the user and ask for another | ||
| 109 | + name. | ||
added
learning-rust/agent-config/docs/01-getting-started.md +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +# Getting started with Ratatui | |
| 2 | + | |
| 3 | +Ratatui is a Rust library for building terminal user interfaces (TUIs). It draws widgets — blocks, paragraphs, lists, tables, gauges — into a buffer, and a backend (Crossterm by default) writes that buffer to the terminal. Ratatui does not read the keyboard itself: events come from the backend, here Crossterm, which Ratatui re-exports as `ratatui::crossterm`. | |
| 4 | + | |
| 5 | +## Create the project | |
| 6 | + | |
| 7 | +```sh | |
| 8 | +cargo new hello-tui | |
| 9 | +cd hello-tui | |
| 10 | +cargo add ratatui | |
| 11 | +``` | |
| 12 | + | |
| 13 | +`cargo add ratatui` adds the current version (0.30 at the time of writing). The Crossterm backend is enabled by default, so there is nothing else to add: use `ratatui::crossterm` rather than a separate `crossterm` dependency, and the two versions can never disagree. | |
| 14 | + | |
| 15 | +## The smallest app | |
| 16 | + | |
| 17 | +Replace `src/main.rs` with: | |
| 18 | + | |
| 19 | +```rust | |
| 20 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 21 | +use ratatui::DefaultTerminal; | |
| 22 | + | |
| 23 | +fn main() -> std::io::Result<()> { | |
| 24 | + let mut terminal = ratatui::init(); | |
| 25 | + let result = run(&mut terminal); | |
| 26 | + ratatui::restore(); | |
| 27 | + result | |
| 28 | +} | |
| 29 | + | |
| 30 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 31 | + loop { | |
| 32 | + terminal.draw(|frame| { | |
| 33 | + frame.render_widget("Hello, Ratatui! Press q to quit.", frame.area()); | |
| 34 | + })?; | |
| 35 | + if let Event::Key(key) = event::read()? { | |
| 36 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | |
| 37 | + return Ok(()); | |
| 38 | + } | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | +``` | |
| 43 | + | |
| 44 | +Run it with `cargo run`. The text fills the top-left of the terminal; `q` quits. | |
| 45 | + | |
| 46 | +## What init and restore do | |
| 47 | + | |
| 48 | +`ratatui::init()` switches the terminal to raw mode (keys arrive one by one, unechoed) and to the alternate screen (your shell's content comes back when the app ends), and installs a panic hook that restores the terminal if the app panics. `ratatui::restore()` undoes both. Always call `restore` before `main` returns, even on error — that is why the loop lives in its own `run` function whose result is returned after `restore`. | |
| 49 | + | |
| 50 | +## The draw loop | |
| 51 | + | |
| 52 | +`terminal.draw` takes a closure that receives a `Frame`. `frame.area()` is the whole terminal as a `Rect`; `frame.render_widget(widget, area)` draws a widget into a part of it. Ratatui redraws the whole UI every time: it keeps the previous buffer and only sends the cells that changed, so drawing everything each loop is cheap. A `&str` is itself a widget, which is why the smallest app can render a plain string. | |
| 53 | + | |
| 54 | +## Next steps | |
| 55 | + | |
| 56 | +- Split the screen into areas: see the layout tutorial. | |
| 57 | +- Draw borders, titles and wrapped text: see the blocks and paragraphs tutorial. | |
| 58 | +- Keep state and react to keys: see the app state and events tutorial. | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +# Getting started with Ratatui | ||
| 2 | + | ||
| 3 | +Ratatui is a Rust library for building terminal user interfaces (TUIs). It draws widgets — blocks, paragraphs, lists, tables, gauges — into a buffer, and a backend (Crossterm by default) writes that buffer to the terminal. Ratatui does not read the keyboard itself: events come from the backend, here Crossterm, which Ratatui re-exports as `ratatui::crossterm`. | ||
| 4 | + | ||
| 5 | +## Create the project | ||
| 6 | + | ||
| 7 | +```sh | ||
| 8 | +cargo new hello-tui | ||
| 9 | +cd hello-tui | ||
| 10 | +cargo add ratatui | ||
| 11 | +``` | ||
| 12 | + | ||
| 13 | +`cargo add ratatui` adds the current version (0.30 at the time of writing). The Crossterm backend is enabled by default, so there is nothing else to add: use `ratatui::crossterm` rather than a separate `crossterm` dependency, and the two versions can never disagree. | ||
| 14 | + | ||
| 15 | +## The smallest app | ||
| 16 | + | ||
| 17 | +Replace `src/main.rs` with: | ||
| 18 | + | ||
| 19 | +```rust | ||
| 20 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 21 | +use ratatui::DefaultTerminal; | ||
| 22 | + | ||
| 23 | +fn main() -> std::io::Result<()> { | ||
| 24 | + let mut terminal = ratatui::init(); | ||
| 25 | + let result = run(&mut terminal); | ||
| 26 | + ratatui::restore(); | ||
| 27 | + result | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 31 | + loop { | ||
| 32 | + terminal.draw(|frame| { | ||
| 33 | + frame.render_widget("Hello, Ratatui! Press q to quit.", frame.area()); | ||
| 34 | + })?; | ||
| 35 | + if let Event::Key(key) = event::read()? { | ||
| 36 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | ||
| 37 | + return Ok(()); | ||
| 38 | + } | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +Run it with `cargo run`. The text fills the top-left of the terminal; `q` quits. | ||
| 45 | + | ||
| 46 | +## What init and restore do | ||
| 47 | + | ||
| 48 | +`ratatui::init()` switches the terminal to raw mode (keys arrive one by one, unechoed) and to the alternate screen (your shell's content comes back when the app ends), and installs a panic hook that restores the terminal if the app panics. `ratatui::restore()` undoes both. Always call `restore` before `main` returns, even on error — that is why the loop lives in its own `run` function whose result is returned after `restore`. | ||
| 49 | + | ||
| 50 | +## The draw loop | ||
| 51 | + | ||
| 52 | +`terminal.draw` takes a closure that receives a `Frame`. `frame.area()` is the whole terminal as a `Rect`; `frame.render_widget(widget, area)` draws a widget into a part of it. Ratatui redraws the whole UI every time: it keeps the previous buffer and only sends the cells that changed, so drawing everything each loop is cheap. A `&str` is itself a widget, which is why the smallest app can render a plain string. | ||
| 53 | + | ||
| 54 | +## Next steps | ||
| 55 | + | ||
| 56 | +- Split the screen into areas: see the layout tutorial. | ||
| 57 | +- Draw borders, titles and wrapped text: see the blocks and paragraphs tutorial. | ||
| 58 | +- Keep state and react to keys: see the app state and events tutorial. | ||
added
learning-rust/agent-config/docs/02-layout.md +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +# Splitting the screen with Layout | |
| 2 | + | |
| 3 | +A `Layout` cuts a `Rect` into smaller ones, vertically or horizontally, following a list of constraints. Layouts nest: split the screen into rows, then split a row into columns. | |
| 4 | + | |
| 5 | +## Constraints | |
| 6 | + | |
| 7 | +| Constraint | Meaning | | |
| 8 | +|---|---| | |
| 9 | +| `Constraint::Length(n)` | exactly `n` rows (or columns) | | |
| 10 | +| `Constraint::Min(n)` | at least `n`, takes what is left | | |
| 11 | +| `Constraint::Max(n)` | at most `n` | | |
| 12 | +| `Constraint::Percentage(p)` | `p` % of the space | | |
| 13 | +| `Constraint::Ratio(a, b)` | `a / b` of the space | | |
| 14 | +| `Constraint::Fill(w)` | shares the remaining space with the other `Fill`s, by weight `w` | | |
| 15 | + | |
| 16 | +## Header, body, footer | |
| 17 | + | |
| 18 | +`Layout::vertical` stacks areas from top to bottom; `areas` returns them as an array you can destructure, so the number of areas is checked at compile time. | |
| 19 | + | |
| 20 | +```rust | |
| 21 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 22 | +use ratatui::layout::{Constraint, Layout}; | |
| 23 | +use ratatui::widgets::{Block, Paragraph}; | |
| 24 | +use ratatui::{DefaultTerminal, Frame}; | |
| 25 | + | |
| 26 | +fn main() -> std::io::Result<()> { | |
| 27 | + let mut terminal = ratatui::init(); | |
| 28 | + let result = run(&mut terminal); | |
| 29 | + ratatui::restore(); | |
| 30 | + result | |
| 31 | +} | |
| 32 | + | |
| 33 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 34 | + loop { | |
| 35 | + terminal.draw(draw)?; | |
| 36 | + if let Event::Key(key) = event::read()? { | |
| 37 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | |
| 38 | + return Ok(()); | |
| 39 | + } | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +fn draw(frame: &mut Frame) { | |
| 45 | + let [header, body, footer] = Layout::vertical([ | |
| 46 | + Constraint::Length(3), | |
| 47 | + Constraint::Min(0), | |
| 48 | + Constraint::Length(1), | |
| 49 | + ]) | |
| 50 | + .areas(frame.area()); | |
| 51 | + | |
| 52 | + let [left, right] = | |
| 53 | + Layout::horizontal([Constraint::Percentage(30), Constraint::Fill(1)]).areas(body); | |
| 54 | + | |
| 55 | + frame.render_widget(Paragraph::new("My app").block(Block::bordered()), header); | |
| 56 | + frame.render_widget(Block::bordered().title("Menu"), left); | |
| 57 | + frame.render_widget(Block::bordered().title("Content"), right); | |
| 58 | + frame.render_widget("q quit", footer); | |
| 59 | +} | |
| 60 | +``` | |
| 61 | + | |
| 62 | +The header is always 3 rows high, the footer 1, and the body takes the rest. The body is then split into a menu (30 % of the width) and a content panel that fills the remaining space. | |
| 63 | + | |
| 64 | +## Spacing and margins | |
| 65 | + | |
| 66 | +`Layout::vertical([...]).spacing(1)` leaves one empty row between areas. `.margin(1)` keeps one cell free all around the area being split. `Block::bordered()` already takes one cell on each side for its border; draw the content in `block.inner(area)` to stay inside it. | |
| 67 | + | |
| 68 | +## Centering an area | |
| 69 | + | |
| 70 | +To center something, give the layout a single constraint and `Flex::Center`: | |
| 71 | + | |
| 72 | +```rust,ignore | |
| 73 | +use ratatui::layout::{Constraint, Flex, Layout, Rect}; | |
| 74 | + | |
| 75 | +fn centered(area: Rect, width: u16, height: u16) -> Rect { | |
| 76 | + let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); | |
| 77 | + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); | |
| 78 | + area | |
| 79 | +} | |
| 80 | +``` | |
| 81 | + | |
| 82 | +The popups tutorial uses this to draw a dialog in the middle of the screen. | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +# Splitting the screen with Layout | ||
| 2 | + | ||
| 3 | +A `Layout` cuts a `Rect` into smaller ones, vertically or horizontally, following a list of constraints. Layouts nest: split the screen into rows, then split a row into columns. | ||
| 4 | + | ||
| 5 | +## Constraints | ||
| 6 | + | ||
| 7 | +| Constraint | Meaning | | ||
| 8 | +|---|---| | ||
| 9 | +| `Constraint::Length(n)` | exactly `n` rows (or columns) | | ||
| 10 | +| `Constraint::Min(n)` | at least `n`, takes what is left | | ||
| 11 | +| `Constraint::Max(n)` | at most `n` | | ||
| 12 | +| `Constraint::Percentage(p)` | `p` % of the space | | ||
| 13 | +| `Constraint::Ratio(a, b)` | `a / b` of the space | | ||
| 14 | +| `Constraint::Fill(w)` | shares the remaining space with the other `Fill`s, by weight `w` | | ||
| 15 | + | ||
| 16 | +## Header, body, footer | ||
| 17 | + | ||
| 18 | +`Layout::vertical` stacks areas from top to bottom; `areas` returns them as an array you can destructure, so the number of areas is checked at compile time. | ||
| 19 | + | ||
| 20 | +```rust | ||
| 21 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 22 | +use ratatui::layout::{Constraint, Layout}; | ||
| 23 | +use ratatui::widgets::{Block, Paragraph}; | ||
| 24 | +use ratatui::{DefaultTerminal, Frame}; | ||
| 25 | + | ||
| 26 | +fn main() -> std::io::Result<()> { | ||
| 27 | + let mut terminal = ratatui::init(); | ||
| 28 | + let result = run(&mut terminal); | ||
| 29 | + ratatui::restore(); | ||
| 30 | + result | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 34 | + loop { | ||
| 35 | + terminal.draw(draw)?; | ||
| 36 | + if let Event::Key(key) = event::read()? { | ||
| 37 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | ||
| 38 | + return Ok(()); | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +fn draw(frame: &mut Frame) { | ||
| 45 | + let [header, body, footer] = Layout::vertical([ | ||
| 46 | + Constraint::Length(3), | ||
| 47 | + Constraint::Min(0), | ||
| 48 | + Constraint::Length(1), | ||
| 49 | + ]) | ||
| 50 | + .areas(frame.area()); | ||
| 51 | + | ||
| 52 | + let [left, right] = | ||
| 53 | + Layout::horizontal([Constraint::Percentage(30), Constraint::Fill(1)]).areas(body); | ||
| 54 | + | ||
| 55 | + frame.render_widget(Paragraph::new("My app").block(Block::bordered()), header); | ||
| 56 | + frame.render_widget(Block::bordered().title("Menu"), left); | ||
| 57 | + frame.render_widget(Block::bordered().title("Content"), right); | ||
| 58 | + frame.render_widget("q quit", footer); | ||
| 59 | +} | ||
| 60 | +``` | ||
| 61 | + | ||
| 62 | +The header is always 3 rows high, the footer 1, and the body takes the rest. The body is then split into a menu (30 % of the width) and a content panel that fills the remaining space. | ||
| 63 | + | ||
| 64 | +## Spacing and margins | ||
| 65 | + | ||
| 66 | +`Layout::vertical([...]).spacing(1)` leaves one empty row between areas. `.margin(1)` keeps one cell free all around the area being split. `Block::bordered()` already takes one cell on each side for its border; draw the content in `block.inner(area)` to stay inside it. | ||
| 67 | + | ||
| 68 | +## Centering an area | ||
| 69 | + | ||
| 70 | +To center something, give the layout a single constraint and `Flex::Center`: | ||
| 71 | + | ||
| 72 | +```rust,ignore | ||
| 73 | +use ratatui::layout::{Constraint, Flex, Layout, Rect}; | ||
| 74 | + | ||
| 75 | +fn centered(area: Rect, width: u16, height: u16) -> Rect { | ||
| 76 | + let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); | ||
| 77 | + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); | ||
| 78 | + area | ||
| 79 | +} | ||
| 80 | +``` | ||
| 81 | + | ||
| 82 | +The popups tutorial uses this to draw a dialog in the middle of the screen. | ||
added
learning-rust/agent-config/docs/03-blocks-and-paragraphs.md +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +# Blocks, paragraphs and styled text | |
| 2 | + | |
| 3 | +## Block: borders and titles | |
| 4 | + | |
| 5 | +A `Block` draws a border and a title around an area. `Block::bordered()` is a block with all four borders; `.title("…")` sets the title; `.border_type(BorderType::Rounded)` rounds the corners. Most widgets take a block with `.block(...)` and draw inside it. | |
| 6 | + | |
| 7 | +## Paragraph: text that wraps | |
| 8 | + | |
| 9 | +`Paragraph::new(text)` draws text. Without wrapping, a long line is cut at the edge; `.wrap(Wrap { trim: true })` wraps it at word boundaries and trims the leading spaces of wrapped lines. `.scroll((y, 0))` scrolls it down by `y` lines, `.alignment(Alignment::Center)` centers it. | |
| 10 | + | |
| 11 | +## Styled text: Span, Line, Text | |
| 12 | + | |
| 13 | +Text is built from three types: | |
| 14 | + | |
| 15 | +- a `Span` is a piece of text with one style; | |
| 16 | +- a `Line` is a row of spans; | |
| 17 | +- a `Text` is several lines. | |
| 18 | + | |
| 19 | +The `Stylize` trait adds short methods to strings, spans and widgets: `"warning".red().bold()`, `"ok".green()`, `Paragraph::new("x").on_blue()`. | |
| 20 | + | |
| 21 | +```rust | |
| 22 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 23 | +use ratatui::style::Stylize; | |
| 24 | +use ratatui::text::{Line, Text}; | |
| 25 | +use ratatui::widgets::{Block, BorderType, Paragraph, Wrap}; | |
| 26 | +use ratatui::{DefaultTerminal, Frame}; | |
| 27 | + | |
| 28 | +fn main() -> std::io::Result<()> { | |
| 29 | + let mut terminal = ratatui::init(); | |
| 30 | + let result = run(&mut terminal); | |
| 31 | + ratatui::restore(); | |
| 32 | + result | |
| 33 | +} | |
| 34 | + | |
| 35 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 36 | + loop { | |
| 37 | + terminal.draw(draw)?; | |
| 38 | + if let Event::Key(key) = event::read()? { | |
| 39 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | |
| 40 | + return Ok(()); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +fn draw(frame: &mut Frame) { | |
| 47 | + let text = Text::from(vec![ | |
| 48 | + Line::from(vec!["Status: ".into(), "running".green().bold()]), | |
| 49 | + Line::from(vec!["Errors: ".into(), "2".red()]), | |
| 50 | + Line::from(""), | |
| 51 | + Line::from("This long line is wrapped at word boundaries when the window is narrower than the text."), | |
| 52 | + ]); | |
| 53 | + let block = Block::bordered() | |
| 54 | + .border_type(BorderType::Rounded) | |
| 55 | + .title(" Report ".bold()); | |
| 56 | + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); | |
| 57 | + frame.render_widget(paragraph, frame.area()); | |
| 58 | +} | |
| 59 | +``` | |
| 60 | + | |
| 61 | +## Colors | |
| 62 | + | |
| 63 | +`Stylize` covers the named colors (`.red()`, `.on_blue()` for the background, `.light_green()`…) and the modifiers (`.bold()`, `.italic()`, `.underlined()`, `.reversed()`, `.dim()`). For anything else, build a `Style`: `Style::new().fg(Color::Rgb(255, 128, 0)).add_modifier(Modifier::BOLD)`, then `.style(style)` on a widget or `Span::styled("text", style)`. | |
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +# Blocks, paragraphs and styled text | ||
| 2 | + | ||
| 3 | +## Block: borders and titles | ||
| 4 | + | ||
| 5 | +A `Block` draws a border and a title around an area. `Block::bordered()` is a block with all four borders; `.title("…")` sets the title; `.border_type(BorderType::Rounded)` rounds the corners. Most widgets take a block with `.block(...)` and draw inside it. | ||
| 6 | + | ||
| 7 | +## Paragraph: text that wraps | ||
| 8 | + | ||
| 9 | +`Paragraph::new(text)` draws text. Without wrapping, a long line is cut at the edge; `.wrap(Wrap { trim: true })` wraps it at word boundaries and trims the leading spaces of wrapped lines. `.scroll((y, 0))` scrolls it down by `y` lines, `.alignment(Alignment::Center)` centers it. | ||
| 10 | + | ||
| 11 | +## Styled text: Span, Line, Text | ||
| 12 | + | ||
| 13 | +Text is built from three types: | ||
| 14 | + | ||
| 15 | +- a `Span` is a piece of text with one style; | ||
| 16 | +- a `Line` is a row of spans; | ||
| 17 | +- a `Text` is several lines. | ||
| 18 | + | ||
| 19 | +The `Stylize` trait adds short methods to strings, spans and widgets: `"warning".red().bold()`, `"ok".green()`, `Paragraph::new("x").on_blue()`. | ||
| 20 | + | ||
| 21 | +```rust | ||
| 22 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 23 | +use ratatui::style::Stylize; | ||
| 24 | +use ratatui::text::{Line, Text}; | ||
| 25 | +use ratatui::widgets::{Block, BorderType, Paragraph, Wrap}; | ||
| 26 | +use ratatui::{DefaultTerminal, Frame}; | ||
| 27 | + | ||
| 28 | +fn main() -> std::io::Result<()> { | ||
| 29 | + let mut terminal = ratatui::init(); | ||
| 30 | + let result = run(&mut terminal); | ||
| 31 | + ratatui::restore(); | ||
| 32 | + result | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 36 | + loop { | ||
| 37 | + terminal.draw(draw)?; | ||
| 38 | + if let Event::Key(key) = event::read()? { | ||
| 39 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | ||
| 40 | + return Ok(()); | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +fn draw(frame: &mut Frame) { | ||
| 47 | + let text = Text::from(vec![ | ||
| 48 | + Line::from(vec!["Status: ".into(), "running".green().bold()]), | ||
| 49 | + Line::from(vec!["Errors: ".into(), "2".red()]), | ||
| 50 | + Line::from(""), | ||
| 51 | + Line::from("This long line is wrapped at word boundaries when the window is narrower than the text."), | ||
| 52 | + ]); | ||
| 53 | + let block = Block::bordered() | ||
| 54 | + .border_type(BorderType::Rounded) | ||
| 55 | + .title(" Report ".bold()); | ||
| 56 | + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); | ||
| 57 | + frame.render_widget(paragraph, frame.area()); | ||
| 58 | +} | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +## Colors | ||
| 62 | + | ||
| 63 | +`Stylize` covers the named colors (`.red()`, `.on_blue()` for the background, `.light_green()`…) and the modifiers (`.bold()`, `.italic()`, `.underlined()`, `.reversed()`, `.dim()`). For anything else, build a `Style`: `Style::new().fg(Color::Rgb(255, 128, 0)).add_modifier(Modifier::BOLD)`, then `.style(style)` on a widget or `Span::styled("text", style)`. | ||
added
learning-rust/agent-config/docs/04-lists.md +60 -0 | new file mode 100644 | ||
| @@ -0,0 +1,60 @@ | ||
| 1 | +# Lists and selection | |
| 2 | + | |
| 3 | +A `List` shows items, one per row. To let the user move a selection, keep a `ListState` next to the list and render it with `render_stateful_widget`. | |
| 4 | + | |
| 5 | +## Keep the selection with ListState | |
| 6 | + | |
| 7 | +`ListState` remembers which item is selected and how far the list is scrolled. It lives in your app, not in the widget: the widget is rebuilt at each draw, the state survives between draws. `state.select_next()` and `state.select_previous()` move the selection; `state.select(Some(0))` selects the first item; `state.selected()` tells which one is selected. | |
| 8 | + | |
| 9 | +## Highlight the selected item | |
| 10 | + | |
| 11 | +`.highlight_style(...)` styles the selected row and `.highlight_symbol(">> ")` puts a marker before it. `HighlightSpacing::Always` keeps the room for the symbol even when nothing is selected, so the text does not jump. | |
| 12 | + | |
| 13 | +## A list you move through with the arrows | |
| 14 | + | |
| 15 | +```rust | |
| 16 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 17 | +use ratatui::style::Style; | |
| 18 | +use ratatui::widgets::{Block, HighlightSpacing, List, ListState}; | |
| 19 | +use ratatui::DefaultTerminal; | |
| 20 | + | |
| 21 | +fn main() -> std::io::Result<()> { | |
| 22 | + let mut terminal = ratatui::init(); | |
| 23 | + let result = run(&mut terminal); | |
| 24 | + ratatui::restore(); | |
| 25 | + result | |
| 26 | +} | |
| 27 | + | |
| 28 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 29 | + let books = ["Dune", "Neuromancer", "Hyperion", "Foundation", "Solaris"]; | |
| 30 | + let mut state = ListState::default(); | |
| 31 | + state.select(Some(0)); | |
| 32 | + loop { | |
| 33 | + terminal.draw(|frame| { | |
| 34 | + let list = List::new(books) | |
| 35 | + .block(Block::bordered().title(" Books — ↑/↓ to move, q to quit ")) | |
| 36 | + .highlight_style(Style::new().black().on_yellow()) | |
| 37 | + .highlight_symbol(">> ") | |
| 38 | + .highlight_spacing(HighlightSpacing::Always); | |
| 39 | + frame.render_stateful_widget(list, frame.area(), &mut state); | |
| 40 | + })?; | |
| 41 | + if let Event::Key(key) = event::read()? { | |
| 42 | + if key.kind != KeyEventKind::Press { | |
| 43 | + continue; | |
| 44 | + } | |
| 45 | + match key.code { | |
| 46 | + KeyCode::Char('q') => return Ok(()), | |
| 47 | + KeyCode::Down | KeyCode::Char('j') => state.select_next(), | |
| 48 | + KeyCode::Up | KeyCode::Char('k') => state.select_previous(), | |
| 49 | + _ => {} | |
| 50 | + } | |
| 51 | + } | |
| 52 | + } | |
| 53 | +} | |
| 54 | +``` | |
| 55 | + | |
| 56 | +`select_next` stops at the last item; when the list is taller than the area, `ListState` scrolls it so the selected item stays visible. | |
| 57 | + | |
| 58 | +## Items with several styles | |
| 59 | + | |
| 60 | +An item can be any `Line` or `Text`: `ListItem::new(Line::from(vec!["Dune".bold(), " — Herbert".dim()]))`. `List::new` takes anything that iterates over items, strings included. | |
| new file mode 100644 | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | +# Lists and selection | ||
| 2 | + | ||
| 3 | +A `List` shows items, one per row. To let the user move a selection, keep a `ListState` next to the list and render it with `render_stateful_widget`. | ||
| 4 | + | ||
| 5 | +## Keep the selection with ListState | ||
| 6 | + | ||
| 7 | +`ListState` remembers which item is selected and how far the list is scrolled. It lives in your app, not in the widget: the widget is rebuilt at each draw, the state survives between draws. `state.select_next()` and `state.select_previous()` move the selection; `state.select(Some(0))` selects the first item; `state.selected()` tells which one is selected. | ||
| 8 | + | ||
| 9 | +## Highlight the selected item | ||
| 10 | + | ||
| 11 | +`.highlight_style(...)` styles the selected row and `.highlight_symbol(">> ")` puts a marker before it. `HighlightSpacing::Always` keeps the room for the symbol even when nothing is selected, so the text does not jump. | ||
| 12 | + | ||
| 13 | +## A list you move through with the arrows | ||
| 14 | + | ||
| 15 | +```rust | ||
| 16 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 17 | +use ratatui::style::Style; | ||
| 18 | +use ratatui::widgets::{Block, HighlightSpacing, List, ListState}; | ||
| 19 | +use ratatui::DefaultTerminal; | ||
| 20 | + | ||
| 21 | +fn main() -> std::io::Result<()> { | ||
| 22 | + let mut terminal = ratatui::init(); | ||
| 23 | + let result = run(&mut terminal); | ||
| 24 | + ratatui::restore(); | ||
| 25 | + result | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 29 | + let books = ["Dune", "Neuromancer", "Hyperion", "Foundation", "Solaris"]; | ||
| 30 | + let mut state = ListState::default(); | ||
| 31 | + state.select(Some(0)); | ||
| 32 | + loop { | ||
| 33 | + terminal.draw(|frame| { | ||
| 34 | + let list = List::new(books) | ||
| 35 | + .block(Block::bordered().title(" Books — ↑/↓ to move, q to quit ")) | ||
| 36 | + .highlight_style(Style::new().black().on_yellow()) | ||
| 37 | + .highlight_symbol(">> ") | ||
| 38 | + .highlight_spacing(HighlightSpacing::Always); | ||
| 39 | + frame.render_stateful_widget(list, frame.area(), &mut state); | ||
| 40 | + })?; | ||
| 41 | + if let Event::Key(key) = event::read()? { | ||
| 42 | + if key.kind != KeyEventKind::Press { | ||
| 43 | + continue; | ||
| 44 | + } | ||
| 45 | + match key.code { | ||
| 46 | + KeyCode::Char('q') => return Ok(()), | ||
| 47 | + KeyCode::Down | KeyCode::Char('j') => state.select_next(), | ||
| 48 | + KeyCode::Up | KeyCode::Char('k') => state.select_previous(), | ||
| 49 | + _ => {} | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | +``` | ||
| 55 | + | ||
| 56 | +`select_next` stops at the last item; when the list is taller than the area, `ListState` scrolls it so the selected item stays visible. | ||
| 57 | + | ||
| 58 | +## Items with several styles | ||
| 59 | + | ||
| 60 | +An item can be any `Line` or `Text`: `ListItem::new(Line::from(vec!["Dune".bold(), " — Herbert".dim()]))`. `List::new` takes anything that iterates over items, strings included. | ||
added
learning-rust/agent-config/docs/05-tables.md +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +# Tables | |
| 2 | + | |
| 3 | +A `Table` shows rows and columns, with an optional header. Like a list, it can keep a selected row in a `TableState`. | |
| 4 | + | |
| 5 | +## Rows, cells and column widths | |
| 6 | + | |
| 7 | +`Row::new(vec!["Dune", "Herbert", "1965"])` is a row of cells. `Table::new(rows, widths)` needs the column widths as constraints, the same ones as `Layout`: `[Constraint::Fill(2), Constraint::Fill(1), Constraint::Length(4)]`. `.header(Row::new(...))` adds a header row, `.column_spacing(2)` the space between columns. | |
| 8 | + | |
| 9 | +## Select a row with TableState | |
| 10 | + | |
| 11 | +`TableState` works like `ListState`: `select_next`, `select_previous`, `selected`. Style the selected row with `.row_highlight_style(...)` and mark it with `.highlight_symbol("> ")`. | |
| 12 | + | |
| 13 | +```rust | |
| 14 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 15 | +use ratatui::layout::Constraint; | |
| 16 | +use ratatui::style::{Style, Stylize}; | |
| 17 | +use ratatui::widgets::{Block, Row, Table, TableState}; | |
| 18 | +use ratatui::DefaultTerminal; | |
| 19 | + | |
| 20 | +fn main() -> std::io::Result<()> { | |
| 21 | + let mut terminal = ratatui::init(); | |
| 22 | + let result = run(&mut terminal); | |
| 23 | + ratatui::restore(); | |
| 24 | + result | |
| 25 | +} | |
| 26 | + | |
| 27 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 28 | + let books = [ | |
| 29 | + ["Dune", "Frank Herbert", "1965"], | |
| 30 | + ["Neuromancer", "William Gibson", "1984"], | |
| 31 | + ["Hyperion", "Dan Simmons", "1989"], | |
| 32 | + ]; | |
| 33 | + let mut state = TableState::default().with_selected(Some(0)); | |
| 34 | + loop { | |
| 35 | + terminal.draw(|frame| { | |
| 36 | + let rows = books.iter().map(|b| Row::new(b.to_vec())); | |
| 37 | + let widths = [Constraint::Fill(2), Constraint::Fill(2), Constraint::Length(4)]; | |
| 38 | + let table = Table::new(rows, widths) | |
| 39 | + .header(Row::new(vec!["Title", "Author", "Year"]).bold().underlined()) | |
| 40 | + .block(Block::bordered().title(" Library ")) | |
| 41 | + .row_highlight_style(Style::new().reversed()) | |
| 42 | + .highlight_symbol("> ") | |
| 43 | + .column_spacing(2); | |
| 44 | + frame.render_stateful_widget(table, frame.area(), &mut state); | |
| 45 | + })?; | |
| 46 | + if let Event::Key(key) = event::read()? { | |
| 47 | + if key.kind != KeyEventKind::Press { | |
| 48 | + continue; | |
| 49 | + } | |
| 50 | + match key.code { | |
| 51 | + KeyCode::Char('q') => return Ok(()), | |
| 52 | + KeyCode::Down => state.select_next(), | |
| 53 | + KeyCode::Up => state.select_previous(), | |
| 54 | + _ => {} | |
| 55 | + } | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
| 59 | +``` | |
| 60 | + | |
| 61 | +## Cells with their own style | |
| 62 | + | |
| 63 | +A cell can be styled on its own: `Cell::from("overdue").red()`, then `Row::new(vec![Cell::from("Dune"), Cell::from("overdue").red()])`. A whole row too: `Row::new(...).on_dark_gray()`. `.height(2)` gives a row two lines. | |
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +# Tables | ||
| 2 | + | ||
| 3 | +A `Table` shows rows and columns, with an optional header. Like a list, it can keep a selected row in a `TableState`. | ||
| 4 | + | ||
| 5 | +## Rows, cells and column widths | ||
| 6 | + | ||
| 7 | +`Row::new(vec!["Dune", "Herbert", "1965"])` is a row of cells. `Table::new(rows, widths)` needs the column widths as constraints, the same ones as `Layout`: `[Constraint::Fill(2), Constraint::Fill(1), Constraint::Length(4)]`. `.header(Row::new(...))` adds a header row, `.column_spacing(2)` the space between columns. | ||
| 8 | + | ||
| 9 | +## Select a row with TableState | ||
| 10 | + | ||
| 11 | +`TableState` works like `ListState`: `select_next`, `select_previous`, `selected`. Style the selected row with `.row_highlight_style(...)` and mark it with `.highlight_symbol("> ")`. | ||
| 12 | + | ||
| 13 | +```rust | ||
| 14 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 15 | +use ratatui::layout::Constraint; | ||
| 16 | +use ratatui::style::{Style, Stylize}; | ||
| 17 | +use ratatui::widgets::{Block, Row, Table, TableState}; | ||
| 18 | +use ratatui::DefaultTerminal; | ||
| 19 | + | ||
| 20 | +fn main() -> std::io::Result<()> { | ||
| 21 | + let mut terminal = ratatui::init(); | ||
| 22 | + let result = run(&mut terminal); | ||
| 23 | + ratatui::restore(); | ||
| 24 | + result | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 28 | + let books = [ | ||
| 29 | + ["Dune", "Frank Herbert", "1965"], | ||
| 30 | + ["Neuromancer", "William Gibson", "1984"], | ||
| 31 | + ["Hyperion", "Dan Simmons", "1989"], | ||
| 32 | + ]; | ||
| 33 | + let mut state = TableState::default().with_selected(Some(0)); | ||
| 34 | + loop { | ||
| 35 | + terminal.draw(|frame| { | ||
| 36 | + let rows = books.iter().map(|b| Row::new(b.to_vec())); | ||
| 37 | + let widths = [Constraint::Fill(2), Constraint::Fill(2), Constraint::Length(4)]; | ||
| 38 | + let table = Table::new(rows, widths) | ||
| 39 | + .header(Row::new(vec!["Title", "Author", "Year"]).bold().underlined()) | ||
| 40 | + .block(Block::bordered().title(" Library ")) | ||
| 41 | + .row_highlight_style(Style::new().reversed()) | ||
| 42 | + .highlight_symbol("> ") | ||
| 43 | + .column_spacing(2); | ||
| 44 | + frame.render_stateful_widget(table, frame.area(), &mut state); | ||
| 45 | + })?; | ||
| 46 | + if let Event::Key(key) = event::read()? { | ||
| 47 | + if key.kind != KeyEventKind::Press { | ||
| 48 | + continue; | ||
| 49 | + } | ||
| 50 | + match key.code { | ||
| 51 | + KeyCode::Char('q') => return Ok(()), | ||
| 52 | + KeyCode::Down => state.select_next(), | ||
| 53 | + KeyCode::Up => state.select_previous(), | ||
| 54 | + _ => {} | ||
| 55 | + } | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | +} | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +## Cells with their own style | ||
| 62 | + | ||
| 63 | +A cell can be styled on its own: `Cell::from("overdue").red()`, then `Row::new(vec![Cell::from("Dune"), Cell::from("overdue").red()])`. A whole row too: `Row::new(...).on_dark_gray()`. `.height(2)` gives a row two lines. | ||
added
learning-rust/agent-config/docs/06-gauges-and-ticks.md +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +# Gauges and a ticking app | |
| 2 | + | |
| 3 | +A `Gauge` draws a progress bar. To animate it, the app must redraw even when no key is pressed: wait for an event with a timeout instead of blocking on `event::read`. | |
| 4 | + | |
| 5 | +## Gauge | |
| 6 | + | |
| 7 | +`Gauge::default().ratio(0.4)` fills 40 % of the bar (`.percent(40)` does the same with an integer). `.gauge_style(...)` colors the filled part, `.label("…")` replaces the default percentage text, `.block(...)` frames it. `LineGauge` is a one-line variant. | |
| 8 | + | |
| 9 | +## Redraw without a key: event::poll | |
| 10 | + | |
| 11 | +`event::poll(timeout)` waits up to `timeout` for an event and returns whether one is ready. Poll with a short timeout (100 ms), and when it returns `false`, advance the app's state — that is a tick — then draw again. | |
| 12 | + | |
| 13 | +```rust | |
| 14 | +use std::time::Duration; | |
| 15 | + | |
| 16 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 17 | +use ratatui::style::{Color, Style}; | |
| 18 | +use ratatui::widgets::{Block, Gauge}; | |
| 19 | +use ratatui::DefaultTerminal; | |
| 20 | + | |
| 21 | +fn main() -> std::io::Result<()> { | |
| 22 | + let mut terminal = ratatui::init(); | |
| 23 | + let result = run(&mut terminal); | |
| 24 | + ratatui::restore(); | |
| 25 | + result | |
| 26 | +} | |
| 27 | + | |
| 28 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 29 | + let mut progress: f64 = 0.0; | |
| 30 | + loop { | |
| 31 | + terminal.draw(|frame| { | |
| 32 | + let gauge = Gauge::default() | |
| 33 | + .block(Block::bordered().title(" Download — q to quit ")) | |
| 34 | + .gauge_style(Style::new().fg(Color::Green)) | |
| 35 | + .ratio(progress) | |
| 36 | + .label(format!("{:.0} %", progress * 100.0)); | |
| 37 | + frame.render_widget(gauge, frame.area()); | |
| 38 | + })?; | |
| 39 | + if event::poll(Duration::from_millis(100))? { | |
| 40 | + if let Event::Key(key) = event::read()? { | |
| 41 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | |
| 42 | + return Ok(()); | |
| 43 | + } | |
| 44 | + } | |
| 45 | + } else { | |
| 46 | + progress = (progress + 0.01).min(1.0); | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | +``` | |
| 51 | + | |
| 52 | +The bar fills by 1 % every 100 ms while no key is pressed. In a real app the tick would read the real progress — a counter shared with a worker thread, a channel — instead of inventing it. | |
| 53 | + | |
| 54 | +## Ticks and frame rate | |
| 55 | + | |
| 56 | +Keep the poll timeout short enough for the UI to feel alive (50–250 ms) and do no heavy work in the draw closure: compute in the tick, draw from the state. If the work is slow, run it on another thread and send the results to the loop with a channel. | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +# Gauges and a ticking app | ||
| 2 | + | ||
| 3 | +A `Gauge` draws a progress bar. To animate it, the app must redraw even when no key is pressed: wait for an event with a timeout instead of blocking on `event::read`. | ||
| 4 | + | ||
| 5 | +## Gauge | ||
| 6 | + | ||
| 7 | +`Gauge::default().ratio(0.4)` fills 40 % of the bar (`.percent(40)` does the same with an integer). `.gauge_style(...)` colors the filled part, `.label("…")` replaces the default percentage text, `.block(...)` frames it. `LineGauge` is a one-line variant. | ||
| 8 | + | ||
| 9 | +## Redraw without a key: event::poll | ||
| 10 | + | ||
| 11 | +`event::poll(timeout)` waits up to `timeout` for an event and returns whether one is ready. Poll with a short timeout (100 ms), and when it returns `false`, advance the app's state — that is a tick — then draw again. | ||
| 12 | + | ||
| 13 | +```rust | ||
| 14 | +use std::time::Duration; | ||
| 15 | + | ||
| 16 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 17 | +use ratatui::style::{Color, Style}; | ||
| 18 | +use ratatui::widgets::{Block, Gauge}; | ||
| 19 | +use ratatui::DefaultTerminal; | ||
| 20 | + | ||
| 21 | +fn main() -> std::io::Result<()> { | ||
| 22 | + let mut terminal = ratatui::init(); | ||
| 23 | + let result = run(&mut terminal); | ||
| 24 | + ratatui::restore(); | ||
| 25 | + result | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 29 | + let mut progress: f64 = 0.0; | ||
| 30 | + loop { | ||
| 31 | + terminal.draw(|frame| { | ||
| 32 | + let gauge = Gauge::default() | ||
| 33 | + .block(Block::bordered().title(" Download — q to quit ")) | ||
| 34 | + .gauge_style(Style::new().fg(Color::Green)) | ||
| 35 | + .ratio(progress) | ||
| 36 | + .label(format!("{:.0} %", progress * 100.0)); | ||
| 37 | + frame.render_widget(gauge, frame.area()); | ||
| 38 | + })?; | ||
| 39 | + if event::poll(Duration::from_millis(100))? { | ||
| 40 | + if let Event::Key(key) = event::read()? { | ||
| 41 | + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { | ||
| 42 | + return Ok(()); | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | + } else { | ||
| 46 | + progress = (progress + 0.01).min(1.0); | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | +} | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +The bar fills by 1 % every 100 ms while no key is pressed. In a real app the tick would read the real progress — a counter shared with a worker thread, a channel — instead of inventing it. | ||
| 53 | + | ||
| 54 | +## Ticks and frame rate | ||
| 55 | + | ||
| 56 | +Keep the poll timeout short enough for the UI to feel alive (50–250 ms) and do no heavy work in the draw closure: compute in the tick, draw from the state. If the work is slow, run it on another thread and send the results to the loop with a channel. | ||
added
learning-rust/agent-config/docs/07-tabs.md +64 -0 | new file mode 100644 | ||
| @@ -0,0 +1,64 @@ | ||
| 1 | +# Tabs | |
| 2 | + | |
| 3 | +`Tabs` draws a row of titles with one highlighted, for apps with several views. The widget only draws the titles: which view is shown under them is up to you. | |
| 4 | + | |
| 5 | +## Draw the tab bar | |
| 6 | + | |
| 7 | +`Tabs::new(vec!["Home", "Books", "Help"])` takes the titles; `.select(index)` highlights one; `.highlight_style(...)` styles it; `.divider(" | ")` sets what goes between titles. | |
| 8 | + | |
| 9 | +## Switch views with the keyboard | |
| 10 | + | |
| 11 | +Keep the selected index in your app, change it on a key, and draw the matching view in the area under the bar. | |
| 12 | + | |
| 13 | +```rust | |
| 14 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 15 | +use ratatui::layout::{Constraint, Layout}; | |
| 16 | +use ratatui::style::Style; | |
| 17 | +use ratatui::widgets::{Block, Paragraph, Tabs}; | |
| 18 | +use ratatui::{DefaultTerminal, Frame}; | |
| 19 | + | |
| 20 | +const TITLES: [&str; 3] = ["Home", "Books", "Help"]; | |
| 21 | + | |
| 22 | +fn main() -> std::io::Result<()> { | |
| 23 | + let mut terminal = ratatui::init(); | |
| 24 | + let result = run(&mut terminal); | |
| 25 | + ratatui::restore(); | |
| 26 | + result | |
| 27 | +} | |
| 28 | + | |
| 29 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 30 | + let mut tab = 0; | |
| 31 | + loop { | |
| 32 | + terminal.draw(|frame| draw(frame, tab))?; | |
| 33 | + if let Event::Key(key) = event::read()? { | |
| 34 | + if key.kind != KeyEventKind::Press { | |
| 35 | + continue; | |
| 36 | + } | |
| 37 | + match key.code { | |
| 38 | + KeyCode::Char('q') => return Ok(()), | |
| 39 | + KeyCode::Tab | KeyCode::Right => tab = (tab + 1) % TITLES.len(), | |
| 40 | + KeyCode::BackTab | KeyCode::Left => tab = (tab + TITLES.len() - 1) % TITLES.len(), | |
| 41 | + _ => {} | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +fn draw(frame: &mut Frame, tab: usize) { | |
| 48 | + let [bar, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(frame.area()); | |
| 49 | + let tabs = Tabs::new(TITLES) | |
| 50 | + .block(Block::bordered().title(" Tab / ← → to switch, q to quit ")) | |
| 51 | + .select(tab) | |
| 52 | + .highlight_style(Style::new().yellow().bold()) | |
| 53 | + .divider(" | "); | |
| 54 | + frame.render_widget(tabs, bar); | |
| 55 | + let text = match tab { | |
| 56 | + 0 => "Welcome.", | |
| 57 | + 1 => "Your books will be listed here.", | |
| 58 | + _ => "Tab and the arrows switch views; q quits.", | |
| 59 | + }; | |
| 60 | + frame.render_widget(Paragraph::new(text).block(Block::bordered()), body); | |
| 61 | +} | |
| 62 | +``` | |
| 63 | + | |
| 64 | +`KeyCode::BackTab` is Shift+Tab. | |
| new file mode 100644 | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | +# Tabs | ||
| 2 | + | ||
| 3 | +`Tabs` draws a row of titles with one highlighted, for apps with several views. The widget only draws the titles: which view is shown under them is up to you. | ||
| 4 | + | ||
| 5 | +## Draw the tab bar | ||
| 6 | + | ||
| 7 | +`Tabs::new(vec!["Home", "Books", "Help"])` takes the titles; `.select(index)` highlights one; `.highlight_style(...)` styles it; `.divider(" | ")` sets what goes between titles. | ||
| 8 | + | ||
| 9 | +## Switch views with the keyboard | ||
| 10 | + | ||
| 11 | +Keep the selected index in your app, change it on a key, and draw the matching view in the area under the bar. | ||
| 12 | + | ||
| 13 | +```rust | ||
| 14 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 15 | +use ratatui::layout::{Constraint, Layout}; | ||
| 16 | +use ratatui::style::Style; | ||
| 17 | +use ratatui::widgets::{Block, Paragraph, Tabs}; | ||
| 18 | +use ratatui::{DefaultTerminal, Frame}; | ||
| 19 | + | ||
| 20 | +const TITLES: [&str; 3] = ["Home", "Books", "Help"]; | ||
| 21 | + | ||
| 22 | +fn main() -> std::io::Result<()> { | ||
| 23 | + let mut terminal = ratatui::init(); | ||
| 24 | + let result = run(&mut terminal); | ||
| 25 | + ratatui::restore(); | ||
| 26 | + result | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 30 | + let mut tab = 0; | ||
| 31 | + loop { | ||
| 32 | + terminal.draw(|frame| draw(frame, tab))?; | ||
| 33 | + if let Event::Key(key) = event::read()? { | ||
| 34 | + if key.kind != KeyEventKind::Press { | ||
| 35 | + continue; | ||
| 36 | + } | ||
| 37 | + match key.code { | ||
| 38 | + KeyCode::Char('q') => return Ok(()), | ||
| 39 | + KeyCode::Tab | KeyCode::Right => tab = (tab + 1) % TITLES.len(), | ||
| 40 | + KeyCode::BackTab | KeyCode::Left => tab = (tab + TITLES.len() - 1) % TITLES.len(), | ||
| 41 | + _ => {} | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +fn draw(frame: &mut Frame, tab: usize) { | ||
| 48 | + let [bar, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(frame.area()); | ||
| 49 | + let tabs = Tabs::new(TITLES) | ||
| 50 | + .block(Block::bordered().title(" Tab / ← → to switch, q to quit ")) | ||
| 51 | + .select(tab) | ||
| 52 | + .highlight_style(Style::new().yellow().bold()) | ||
| 53 | + .divider(" | "); | ||
| 54 | + frame.render_widget(tabs, bar); | ||
| 55 | + let text = match tab { | ||
| 56 | + 0 => "Welcome.", | ||
| 57 | + 1 => "Your books will be listed here.", | ||
| 58 | + _ => "Tab and the arrows switch views; q quits.", | ||
| 59 | + }; | ||
| 60 | + frame.render_widget(Paragraph::new(text).block(Block::bordered()), body); | ||
| 61 | +} | ||
| 62 | +``` | ||
| 63 | + | ||
| 64 | +`KeyCode::BackTab` is Shift+Tab. | ||
added
learning-rust/agent-config/docs/08-app-state-and-events.md +70 -0 | new file mode 100644 | ||
| @@ -0,0 +1,70 @@ | ||
| 1 | +# App state and key events | |
| 2 | + | |
| 3 | +As an app grows, keep its state in one struct, and split the loop into three parts: read an event, update the state, draw from the state. Drawing never changes the state; handling a key never draws. | |
| 4 | + | |
| 5 | +## The App struct | |
| 6 | + | |
| 7 | +```rust | |
| 8 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; | |
| 9 | +use ratatui::widgets::{Block, Paragraph}; | |
| 10 | +use ratatui::{DefaultTerminal, Frame}; | |
| 11 | + | |
| 12 | +#[derive(Default)] | |
| 13 | +struct App { | |
| 14 | + counter: i64, | |
| 15 | + exit: bool, | |
| 16 | +} | |
| 17 | + | |
| 18 | +impl App { | |
| 19 | + fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 20 | + while !self.exit { | |
| 21 | + terminal.draw(|frame| self.draw(frame))?; | |
| 22 | + self.handle_events()?; | |
| 23 | + } | |
| 24 | + Ok(()) | |
| 25 | + } | |
| 26 | + | |
| 27 | + fn draw(&self, frame: &mut Frame) { | |
| 28 | + let text = format!("Counter: {}\n\n← / → to change it, q or Ctrl-C to quit", self.counter); | |
| 29 | + frame.render_widget(Paragraph::new(text).block(Block::bordered().title(" Counter ")), frame.area()); | |
| 30 | + } | |
| 31 | + | |
| 32 | + fn handle_events(&mut self) -> std::io::Result<()> { | |
| 33 | + if let Event::Key(key) = event::read()? { | |
| 34 | + if key.kind == KeyEventKind::Press { | |
| 35 | + self.handle_key(key); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + Ok(()) | |
| 39 | + } | |
| 40 | + | |
| 41 | + fn handle_key(&mut self, key: KeyEvent) { | |
| 42 | + match key.code { | |
| 43 | + KeyCode::Char('q') => self.exit = true, | |
| 44 | + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.exit = true, | |
| 45 | + KeyCode::Left => self.counter -= 1, | |
| 46 | + KeyCode::Right => self.counter += 1, | |
| 47 | + _ => {} | |
| 48 | + } | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +fn main() -> std::io::Result<()> { | |
| 53 | + let mut terminal = ratatui::init(); | |
| 54 | + let result = App::default().run(&mut terminal); | |
| 55 | + ratatui::restore(); | |
| 56 | + result | |
| 57 | +} | |
| 58 | +``` | |
| 59 | + | |
| 60 | +## Why check KeyEventKind::Press | |
| 61 | + | |
| 62 | +On Windows, and on terminals that report key releases, Crossterm sends a `Press` and a `Release` event for one key stroke. Without the `key.kind == KeyEventKind::Press` check, every key would act twice. | |
| 63 | + | |
| 64 | +## Ctrl-C in raw mode | |
| 65 | + | |
| 66 | +In raw mode Ctrl-C is not a signal any more: it arrives as a key, `KeyCode::Char('c')` with `KeyModifiers::CONTROL`. Handle it, or the user has no way out but closing the terminal. | |
| 67 | + | |
| 68 | +## Test the logic without a terminal | |
| 69 | + | |
| 70 | +Because `handle_key` only changes the state, it can be tested with a plain `#[test]`: build an `App`, call `app.handle_key(KeyEvent::from(KeyCode::Right))`, check `app.counter == 1`. For the drawing itself, Ratatui's `TestBackend` renders into a buffer you can compare. | |
| new file mode 100644 | |||
| @@ -0,0 +1,70 @@ | |||
| 1 | +# App state and key events | ||
| 2 | + | ||
| 3 | +As an app grows, keep its state in one struct, and split the loop into three parts: read an event, update the state, draw from the state. Drawing never changes the state; handling a key never draws. | ||
| 4 | + | ||
| 5 | +## The App struct | ||
| 6 | + | ||
| 7 | +```rust | ||
| 8 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; | ||
| 9 | +use ratatui::widgets::{Block, Paragraph}; | ||
| 10 | +use ratatui::{DefaultTerminal, Frame}; | ||
| 11 | + | ||
| 12 | +#[derive(Default)] | ||
| 13 | +struct App { | ||
| 14 | + counter: i64, | ||
| 15 | + exit: bool, | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +impl App { | ||
| 19 | + fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 20 | + while !self.exit { | ||
| 21 | + terminal.draw(|frame| self.draw(frame))?; | ||
| 22 | + self.handle_events()?; | ||
| 23 | + } | ||
| 24 | + Ok(()) | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + fn draw(&self, frame: &mut Frame) { | ||
| 28 | + let text = format!("Counter: {}\n\n← / → to change it, q or Ctrl-C to quit", self.counter); | ||
| 29 | + frame.render_widget(Paragraph::new(text).block(Block::bordered().title(" Counter ")), frame.area()); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + fn handle_events(&mut self) -> std::io::Result<()> { | ||
| 33 | + if let Event::Key(key) = event::read()? { | ||
| 34 | + if key.kind == KeyEventKind::Press { | ||
| 35 | + self.handle_key(key); | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + Ok(()) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + fn handle_key(&mut self, key: KeyEvent) { | ||
| 42 | + match key.code { | ||
| 43 | + KeyCode::Char('q') => self.exit = true, | ||
| 44 | + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.exit = true, | ||
| 45 | + KeyCode::Left => self.counter -= 1, | ||
| 46 | + KeyCode::Right => self.counter += 1, | ||
| 47 | + _ => {} | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +fn main() -> std::io::Result<()> { | ||
| 53 | + let mut terminal = ratatui::init(); | ||
| 54 | + let result = App::default().run(&mut terminal); | ||
| 55 | + ratatui::restore(); | ||
| 56 | + result | ||
| 57 | +} | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +## Why check KeyEventKind::Press | ||
| 61 | + | ||
| 62 | +On Windows, and on terminals that report key releases, Crossterm sends a `Press` and a `Release` event for one key stroke. Without the `key.kind == KeyEventKind::Press` check, every key would act twice. | ||
| 63 | + | ||
| 64 | +## Ctrl-C in raw mode | ||
| 65 | + | ||
| 66 | +In raw mode Ctrl-C is not a signal any more: it arrives as a key, `KeyCode::Char('c')` with `KeyModifiers::CONTROL`. Handle it, or the user has no way out but closing the terminal. | ||
| 67 | + | ||
| 68 | +## Test the logic without a terminal | ||
| 69 | + | ||
| 70 | +Because `handle_key` only changes the state, it can be tested with a plain `#[test]`: build an `App`, call `app.handle_key(KeyEvent::from(KeyCode::Right))`, check `app.counter == 1`. For the drawing itself, Ratatui's `TestBackend` renders into a buffer you can compare. | ||
added
learning-rust/agent-config/docs/09-popups.md +67 -0 | new file mode 100644 | ||
| @@ -0,0 +1,67 @@ | ||
| 1 | +# Popups and dialogs | |
| 2 | + | |
| 3 | +A popup is drawn over the rest of the UI, in a smaller area, after everything else. Ratatui draws in order: what is rendered last is on top. | |
| 4 | + | |
| 5 | +## Clear the area first | |
| 6 | + | |
| 7 | +Widgets only paint the cells they use, so the UI underneath would show through the popup. Render `Clear` on the popup's area first: it blanks it, then draw the popup's block and content in the same area. | |
| 8 | + | |
| 9 | +## A centered confirmation dialog | |
| 10 | + | |
| 11 | +```rust | |
| 12 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | |
| 13 | +use ratatui::layout::{Constraint, Flex, Layout, Rect}; | |
| 14 | +use ratatui::style::Stylize; | |
| 15 | +use ratatui::widgets::{Block, Clear, Paragraph}; | |
| 16 | +use ratatui::{DefaultTerminal, Frame}; | |
| 17 | + | |
| 18 | +fn main() -> std::io::Result<()> { | |
| 19 | + let mut terminal = ratatui::init(); | |
| 20 | + let result = run(&mut terminal); | |
| 21 | + ratatui::restore(); | |
| 22 | + result | |
| 23 | +} | |
| 24 | + | |
| 25 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | |
| 26 | + let mut show_popup = false; | |
| 27 | + loop { | |
| 28 | + terminal.draw(|frame| draw(frame, show_popup))?; | |
| 29 | + if let Event::Key(key) = event::read()? { | |
| 30 | + if key.kind != KeyEventKind::Press { | |
| 31 | + continue; | |
| 32 | + } | |
| 33 | + match (show_popup, key.code) { | |
| 34 | + (false, KeyCode::Char('q')) => show_popup = true, | |
| 35 | + (true, KeyCode::Char('y')) => return Ok(()), | |
| 36 | + (true, KeyCode::Char('n') | KeyCode::Esc) => show_popup = false, | |
| 37 | + _ => {} | |
| 38 | + } | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +fn draw(frame: &mut Frame, show_popup: bool) { | |
| 44 | + let main = Paragraph::new("The main screen. Press q to quit.").block(Block::bordered().title(" App ")); | |
| 45 | + frame.render_widget(main, frame.area()); | |
| 46 | + if show_popup { | |
| 47 | + let area = centered(frame.area(), 40, 5); | |
| 48 | + frame.render_widget(Clear, area); | |
| 49 | + let dialog = Paragraph::new("Really quit? y / n") | |
| 50 | + .centered() | |
| 51 | + .block(Block::bordered().title(" Confirm ".bold()).on_blue()); | |
| 52 | + frame.render_widget(dialog, area); | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +fn centered(area: Rect, width: u16, height: u16) -> Rect { | |
| 57 | + let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); | |
| 58 | + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); | |
| 59 | + area | |
| 60 | +} | |
| 61 | +``` | |
| 62 | + | |
| 63 | +`q` opens the dialog instead of quitting at once; `y` quits, `n` or Esc closes the dialog. | |
| 64 | + | |
| 65 | +## Keys go to the popup | |
| 66 | + | |
| 67 | +While a popup is open, it owns the keyboard: match on the popup's state first, as above, so a key meant for the dialog does not also act on the screen behind it. | |
| new file mode 100644 | |||
| @@ -0,0 +1,67 @@ | |||
| 1 | +# Popups and dialogs | ||
| 2 | + | ||
| 3 | +A popup is drawn over the rest of the UI, in a smaller area, after everything else. Ratatui draws in order: what is rendered last is on top. | ||
| 4 | + | ||
| 5 | +## Clear the area first | ||
| 6 | + | ||
| 7 | +Widgets only paint the cells they use, so the UI underneath would show through the popup. Render `Clear` on the popup's area first: it blanks it, then draw the popup's block and content in the same area. | ||
| 8 | + | ||
| 9 | +## A centered confirmation dialog | ||
| 10 | + | ||
| 11 | +```rust | ||
| 12 | +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; | ||
| 13 | +use ratatui::layout::{Constraint, Flex, Layout, Rect}; | ||
| 14 | +use ratatui::style::Stylize; | ||
| 15 | +use ratatui::widgets::{Block, Clear, Paragraph}; | ||
| 16 | +use ratatui::{DefaultTerminal, Frame}; | ||
| 17 | + | ||
| 18 | +fn main() -> std::io::Result<()> { | ||
| 19 | + let mut terminal = ratatui::init(); | ||
| 20 | + let result = run(&mut terminal); | ||
| 21 | + ratatui::restore(); | ||
| 22 | + result | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> { | ||
| 26 | + let mut show_popup = false; | ||
| 27 | + loop { | ||
| 28 | + terminal.draw(|frame| draw(frame, show_popup))?; | ||
| 29 | + if let Event::Key(key) = event::read()? { | ||
| 30 | + if key.kind != KeyEventKind::Press { | ||
| 31 | + continue; | ||
| 32 | + } | ||
| 33 | + match (show_popup, key.code) { | ||
| 34 | + (false, KeyCode::Char('q')) => show_popup = true, | ||
| 35 | + (true, KeyCode::Char('y')) => return Ok(()), | ||
| 36 | + (true, KeyCode::Char('n') | KeyCode::Esc) => show_popup = false, | ||
| 37 | + _ => {} | ||
| 38 | + } | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +fn draw(frame: &mut Frame, show_popup: bool) { | ||
| 44 | + let main = Paragraph::new("The main screen. Press q to quit.").block(Block::bordered().title(" App ")); | ||
| 45 | + frame.render_widget(main, frame.area()); | ||
| 46 | + if show_popup { | ||
| 47 | + let area = centered(frame.area(), 40, 5); | ||
| 48 | + frame.render_widget(Clear, area); | ||
| 49 | + let dialog = Paragraph::new("Really quit? y / n") | ||
| 50 | + .centered() | ||
| 51 | + .block(Block::bordered().title(" Confirm ".bold()).on_blue()); | ||
| 52 | + frame.render_widget(dialog, area); | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +fn centered(area: Rect, width: u16, height: u16) -> Rect { | ||
| 57 | + let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); | ||
| 58 | + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); | ||
| 59 | + area | ||
| 60 | +} | ||
| 61 | +``` | ||
| 62 | + | ||
| 63 | +`q` opens the dialog instead of quitting at once; `y` quits, `n` or Esc closes the dialog. | ||
| 64 | + | ||
| 65 | +## Keys go to the popup | ||
| 66 | + | ||
| 67 | +While a popup is open, it owns the keyboard: match on the popup's state first, as above, so a key meant for the dialog does not also act on the screen behind it. | ||
added
learning-rust/agent-config/docs/README.md +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Ratatui tutorials | |
| 2 | + | |
| 3 | +Short tutorials on building terminal user interfaces in Rust with [Ratatui](https://ratatui.rs) 0.30. Each one is a complete program you can paste into `src/main.rs` of a project made with `cargo new` and `cargo add ratatui`. | |
| 4 | + | |
| 5 | +1. [Getting started](01-getting-started.md) — the project, the smallest app, `init` / `restore`, the draw loop | |
| 6 | +2. [Layout](02-layout.md) — constraints, header / body / footer, centering an area | |
| 7 | +3. [Blocks, paragraphs and styled text](03-blocks-and-paragraphs.md) — borders, titles, wrapping, `Span` / `Line` / `Text`, colors | |
| 8 | +4. [Lists and selection](04-lists.md) — `List`, `ListState`, highlighting, moving with the arrows | |
| 9 | +5. [Tables](05-tables.md) — rows, column widths, header, `TableState` | |
| 10 | +6. [Gauges and a ticking app](06-gauges-and-ticks.md) — `Gauge`, redrawing with `event::poll` | |
| 11 | +7. [Tabs](07-tabs.md) — a tab bar and switching views | |
| 12 | +8. [App state and key events](08-app-state-and-events.md) — the `App` struct, `KeyEventKind::Press`, Ctrl-C in raw mode, testing | |
| 13 | +9. [Popups and dialogs](09-popups.md) — `Clear`, a centered dialog, keys for the popup | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Ratatui tutorials | ||
| 2 | + | ||
| 3 | +Short tutorials on building terminal user interfaces in Rust with [Ratatui](https://ratatui.rs) 0.30. Each one is a complete program you can paste into `src/main.rs` of a project made with `cargo new` and `cargo add ratatui`. | ||
| 4 | + | ||
| 5 | +1. [Getting started](01-getting-started.md) — the project, the smallest app, `init` / `restore`, the draw loop | ||
| 6 | +2. [Layout](02-layout.md) — constraints, header / body / footer, centering an area | ||
| 7 | +3. [Blocks, paragraphs and styled text](03-blocks-and-paragraphs.md) — borders, titles, wrapping, `Span` / `Line` / `Text`, colors | ||
| 8 | +4. [Lists and selection](04-lists.md) — `List`, `ListState`, highlighting, moving with the arrows | ||
| 9 | +5. [Tables](05-tables.md) — rows, column widths, header, `TableState` | ||
| 10 | +6. [Gauges and a ticking app](06-gauges-and-ticks.md) — `Gauge`, redrawing with `event::poll` | ||
| 11 | +7. [Tabs](07-tabs.md) — a tab bar and switching views | ||
| 12 | +8. [App state and key events](08-app-state-and-events.md) — the `App` struct, `KeyEventKind::Press`, Ctrl-C in raw mode, testing | ||
| 13 | +9. [Popups and dialogs](09-popups.md) — `Clear`, a centered dialog, keys for the popup | ||
added
learning-rust/sbxenv.yaml +60 -0 | new file mode 100644 | ||
| @@ -0,0 +1,60 @@ | ||
| 1 | +schemaVersion: "1" | |
| 2 | + | |
| 3 | +# Sandbox name = SSH host (<name>.sbx): letters, digits and hyphens only. | |
| 4 | +name: zed-dev-rust | |
| 5 | + | |
| 6 | +agent: zed-ssh-kit | |
| 7 | + | |
| 8 | +kits: | |
| 9 | + - docker.io/k33g/zed-ssh-kit:0.0.8 | |
| 10 | + - docker.io/k33g/mini-me-agent-kit:0.0.8 | |
| 11 | + - docker.io/k33g/nushell-kit:0.0.8 | |
| 12 | + #- docker.io/k33g/go-toolchain-kit:0.0.8 | |
| 13 | + - docker.io/k33g/rust-toolchain-kit:0.0.8 | |
| 14 | + | |
| 15 | +# This file and the start / stop scripts stay OUTSIDE every mounted | |
| 16 | +# directory: Docker recommends it, so that an agent cannot rewrite the | |
| 17 | +# commands your host runs (writableEnvFiles is left off). | |
| 18 | +# | |
| 19 | +# dev.team/ | |
| 20 | +# ├── sbxenv.yaml, start-zed.sh, stop-zed.sh, README.* not mounted | |
| 21 | +# ├── team/ the agents (agent*.yaml) and their hooks READ-ONLY | |
| 22 | +# └── workspace/ the code the team works on read-write | |
| 23 | + | |
| 24 | +# The code: playground/, demo/, sessions (.mm/). Zed opens this folder. | |
| 25 | +workspace: ./workspace | |
| 26 | + | |
| 27 | +# The agents: mm reads them, nothing in the sandbox can change them. | |
| 28 | +additionalWorkspaces: | |
| 29 | + - path: ./agent-config | |
| 30 | + readOnly: true | |
| 31 | + | |
| 32 | +# The folder of this file, as the sandbox sees it — start-zed.sh passes it: | |
| 33 | +# sbx env create --force --env-arg DEV_TEAM_DIR="$PWD" | |
| 34 | +args: | |
| 35 | + DEV_TEAM_DIR: | |
| 36 | + required: true | |
| 37 | + | |
| 38 | +env: | |
| 39 | + # mm -acp (Zed) and mm without -config read the coordinator from team/, | |
| 40 | + # not ./agent.yaml: the workspace holds no agent file to rewrite. | |
| 41 | + AGENT_CONFIG: ${{ env.args.DEV_TEAM_DIR }}/agent-config/agent.yaml | |
| 42 | + | |
| 43 | +# Published when the sandbox is created, each to the same port on the host | |
| 44 | +# (loopback, tcp4 by default). A port already taken on the host makes the | |
| 45 | +# creation fail. A change here applies at the next ./start-zed.sh. | |
| 46 | +# Inside the sandbox, a server must listen on 0.0.0.0, not 127.0.0.1. | |
| 47 | +ports: | |
| 48 | + - sandbox: 8080 | |
| 49 | + host: 8080 | |
| 50 | + - sandbox: 6060 | |
| 51 | + host: 6060 | |
| 52 | + - sandbox: 5050 | |
| 53 | + host: 5050 | |
| 54 | + | |
| 55 | +sandboxOptions: | |
| 56 | + pullPolicy: always | |
| 57 | + | |
| 58 | +# sbx setup ssh | |
| 59 | +# ./start-zed.sh (sbx env create … then zed --new ssh://zed-dev-team.sbx<dev.team>/workspace) | |
| 60 | +# ./stop-zed.sh (sbx env rm --force --env-arg DEV_TEAM_DIR="$PWD") | |
| new file mode 100644 | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | +schemaVersion: "1" | ||
| 2 | + | ||
| 3 | +# Sandbox name = SSH host (<name>.sbx): letters, digits and hyphens only. | ||
| 4 | +name: zed-dev-rust | ||
| 5 | + | ||
| 6 | +agent: zed-ssh-kit | ||
| 7 | + | ||
| 8 | +kits: | ||
| 9 | + - docker.io/k33g/zed-ssh-kit:0.0.8 | ||
| 10 | + - docker.io/k33g/mini-me-agent-kit:0.0.8 | ||
| 11 | + - docker.io/k33g/nushell-kit:0.0.8 | ||
| 12 | + #- docker.io/k33g/go-toolchain-kit:0.0.8 | ||
| 13 | + - docker.io/k33g/rust-toolchain-kit:0.0.8 | ||
| 14 | + | ||
| 15 | +# This file and the start / stop scripts stay OUTSIDE every mounted | ||
| 16 | +# directory: Docker recommends it, so that an agent cannot rewrite the | ||
| 17 | +# commands your host runs (writableEnvFiles is left off). | ||
| 18 | +# | ||
| 19 | +# dev.team/ | ||
| 20 | +# ├── sbxenv.yaml, start-zed.sh, stop-zed.sh, README.* not mounted | ||
| 21 | +# ├── team/ the agents (agent*.yaml) and their hooks READ-ONLY | ||
| 22 | +# └── workspace/ the code the team works on read-write | ||
| 23 | + | ||
| 24 | +# The code: playground/, demo/, sessions (.mm/). Zed opens this folder. | ||
| 25 | +workspace: ./workspace | ||
| 26 | + | ||
| 27 | +# The agents: mm reads them, nothing in the sandbox can change them. | ||
| 28 | +additionalWorkspaces: | ||
| 29 | + - path: ./agent-config | ||
| 30 | + readOnly: true | ||
| 31 | + | ||
| 32 | +# The folder of this file, as the sandbox sees it — start-zed.sh passes it: | ||
| 33 | +# sbx env create --force --env-arg DEV_TEAM_DIR="$PWD" | ||
| 34 | +args: | ||
| 35 | + DEV_TEAM_DIR: | ||
| 36 | + required: true | ||
| 37 | + | ||
| 38 | +env: | ||
| 39 | + # mm -acp (Zed) and mm without -config read the coordinator from team/, | ||
| 40 | + # not ./agent.yaml: the workspace holds no agent file to rewrite. | ||
| 41 | + AGENT_CONFIG: ${{ env.args.DEV_TEAM_DIR }}/agent-config/agent.yaml | ||
| 42 | + | ||
| 43 | +# Published when the sandbox is created, each to the same port on the host | ||
| 44 | +# (loopback, tcp4 by default). A port already taken on the host makes the | ||
| 45 | +# creation fail. A change here applies at the next ./start-zed.sh. | ||
| 46 | +# Inside the sandbox, a server must listen on 0.0.0.0, not 127.0.0.1. | ||
| 47 | +ports: | ||
| 48 | + - sandbox: 8080 | ||
| 49 | + host: 8080 | ||
| 50 | + - sandbox: 6060 | ||
| 51 | + host: 6060 | ||
| 52 | + - sandbox: 5050 | ||
| 53 | + host: 5050 | ||
| 54 | + | ||
| 55 | +sandboxOptions: | ||
| 56 | + pullPolicy: always | ||
| 57 | + | ||
| 58 | +# sbx setup ssh | ||
| 59 | +# ./start-zed.sh (sbx env create … then zed --new ssh://zed-dev-team.sbx<dev.team>/workspace) | ||
| 60 | +# ./stop-zed.sh (sbx env rm --force --env-arg DEV_TEAM_DIR="$PWD") | ||
added
learning-rust/start-zed.sh +8 -0 | new file mode 100755 | ||
| @@ -0,0 +1,8 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Creates the sandbox from sbxenv.yaml and opens workspace/ in Zed over SSH. | |
| 3 | +# Run it from dev.team/ (the folder of sbxenv.yaml). Once: sbx setup ssh | |
| 4 | +set -euo pipefail | |
| 5 | +cd "$(dirname "$0")" | |
| 6 | +# DEV_TEAM_DIR tells the sandbox where team/agent.yaml is (AGENT_CONFIG). | |
| 7 | +sbx env create --force --env-arg DEV_TEAM_DIR="$PWD" | |
| 8 | +zed --new "ssh://zed-dev-rust.sbx$PWD/workspace" | |
| new file mode 100755 | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Creates the sandbox from sbxenv.yaml and opens workspace/ in Zed over SSH. | ||
| 3 | +# Run it from dev.team/ (the folder of sbxenv.yaml). Once: sbx setup ssh | ||
| 4 | +set -euo pipefail | ||
| 5 | +cd "$(dirname "$0")" | ||
| 6 | +# DEV_TEAM_DIR tells the sandbox where team/agent.yaml is (AGENT_CONFIG). | ||
| 7 | +sbx env create --force --env-arg DEV_TEAM_DIR="$PWD" | ||
| 8 | +zed --new "ssh://zed-dev-rust.sbx$PWD/workspace" | ||
added
learning-rust/stop-zed.sh +7 -0 | new file mode 100755 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Removes the sandbox. workspace/ and team/ stay on the host as they are. | |
| 3 | +set -euo pipefail | |
| 4 | +cd "$(dirname "$0")" | |
| 5 | +# Every `sbx env` command reads sbxenv.yaml, whose DEV_TEAM_DIR argument is | |
| 6 | +# required: pass it here too, the same value as start-zed.sh. | |
| 7 | +sbx env rm --force --env-arg DEV_TEAM_DIR="$PWD" | |
| new file mode 100755 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Removes the sandbox. workspace/ and team/ stay on the host as they are. | ||
| 3 | +set -euo pipefail | ||
| 4 | +cd "$(dirname "$0")" | ||
| 5 | +# Every `sbx env` command reads sbxenv.yaml, whose DEV_TEAM_DIR argument is | ||
| 6 | +# required: pass it here too, the same value as start-zed.sh. | ||
| 7 | +sbx env rm --force --env-arg DEV_TEAM_DIR="$PWD" | ||
added
learning-rust/workspace/.gitkeep +0 -0 | new file mode 100644 |
| new file mode 100644 |
modified
main.go +19 -8 | @@ -245,6 +245,13 @@ func main() { | ||
| 245 | 245 | // pressed Escape). |
| 246 | 246 | loopDetector := detector.NewLoopDetector(10, 3).WithStop(3) |
| 247 | 247 | |
| 248 | + // A decider answers with its decision model: no tool is ever called. | |
| 249 | + // Checked before the tools are assembled, so no MCP or language server | |
| 250 | + // is started for a file that is about to be refused. | |
| 251 | + if err := config.DecisionConflict(); err != nil { | |
| 252 | + fail("[config error: %v ]", err) | |
| 253 | + os.Exit(1) | |
| 254 | + } | |
| 248 | 255 | set := assembleTools(e, loopDetector, asker) |
| 249 | 256 | defer set.close() |
| 250 | 257 | // A structuredOutput constrains every answer: no room for a tool call. |
| @@ -253,12 +260,6 @@ func main() { | ||
| 253 | 260 | set.close() |
| 254 | 261 | os.Exit(1) |
| 255 | 262 | } |
| 256 | - // A decider answers with its decision model: no tool is ever called. | |
| 257 | - if err := config.DecisionConflict(set.names); err != nil { | |
| 258 | - fail("[config error: %v ]", err) | |
| 259 | - set.close() | |
| 260 | - os.Exit(1) | |
| 261 | - } | |
| 262 | 263 | if err := installRouter(); err != nil { |
| 263 | 264 | fail("[config error: %v ]", err) |
| 264 | 265 | set.close() |
| @@ -386,8 +387,18 @@ type toolSet struct { | ||
| 386 | 387 | // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on); |
| 387 | 388 | // `search_docs` behind `docSearch.autoSearchDocs` (off by default); |
| 388 | 389 | // and the agent file's declared `tools:`. |
| 390 | +// | |
| 391 | +// A decider gets none of them, whatever bashTool, editTools, skillsDir… say | |
| 392 | +// (ticket 141): its model never calls a tool, so a decider file needs no | |
| 393 | +// `…: false` lines. What only a deliberate entry declares (mcp, lsp, | |
| 394 | +// mmConfigs, tools:) is refused earlier by config.DecisionConflict. | |
| 389 | 395 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 390 | 396 | set := toolSet{close: func() {}} |
| 397 | + if config.Cfg.Decision != nil { | |
| 398 | + engine.DeclareSkills(false) | |
| 399 | + skills.Declare(nil) | |
| 400 | + return set | |
| 401 | + } | |
| 391 | 402 | if config.Cfg.BashTool { |
| 392 | 403 | set.tools = append(set.tools, tools.Bash(e.G, d)) |
| 393 | 404 | set.names = append(set.names, "bash") |
| @@ -425,7 +436,7 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | ||
| 425 | 436 | set.tools = append(set.tools, tools.SaveAnswer(e.G, d)) |
| 426 | 437 | set.names = append(set.names, "save_answer") |
| 427 | 438 | } |
| 428 | - if config.Cfg.UnloadModels && config.Cfg.OutputSchema == nil && config.Cfg.Decision == nil { | |
| 439 | + if config.Cfg.UnloadModels && config.Cfg.OutputSchema == nil { | |
| 429 | 440 | set.tools = append(set.tools, tools.UnloadModel(e.G, d, e.Unload)) |
| 430 | 441 | set.names = append(set.names, "unload_model") |
| 431 | 442 | } |
| @@ -620,7 +631,7 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | ||
| 620 | 631 | // whether mm looked in the wrong place or found the right one empty. |
| 621 | 632 | // An agent with a structuredOutput has neither tools nor skills on |
| 622 | 633 | // purpose (config.StructuredOutputConflict): nothing to warn about. |
| 623 | - // A decider has neither on purpose either (config.DecisionConflict). | |
| 634 | + // A decider has neither on purpose either (assembleTools, ticket 141). | |
| 624 | 635 | structuredAgent := config.Cfg.OutputSchema != nil || config.Cfg.Decision != nil |
| 625 | 636 | agentsMdCol, agentsMdWarnings := agentsMdBanner(acpMode, info.ContextWindow) |
| 626 | 637 | info.Warnings = append(info.Warnings, agentsMdWarnings...) |
| @@ -245,6 +245,13 @@ func main() { | |||
| 245 | // pressed Escape). | 245 | // pressed Escape). |
| 246 | loopDetector := detector.NewLoopDetector(10, 3).WithStop(3) | 246 | loopDetector := detector.NewLoopDetector(10, 3).WithStop(3) |
| 247 | 247 | ||
| 248 | + // A decider answers with its decision model: no tool is ever called. | ||
| 249 | + // Checked before the tools are assembled, so no MCP or language server | ||
| 250 | + // is started for a file that is about to be refused. | ||
| 251 | + if err := config.DecisionConflict(); err != nil { | ||
| 252 | + fail("[config error: %v ]", err) | ||
| 253 | + os.Exit(1) | ||
| 254 | + } | ||
| 248 | set := assembleTools(e, loopDetector, asker) | 255 | set := assembleTools(e, loopDetector, asker) |
| 249 | defer set.close() | 256 | defer set.close() |
| 250 | // A structuredOutput constrains every answer: no room for a tool call. | 257 | // A structuredOutput constrains every answer: no room for a tool call. |
| @@ -253,12 +260,6 @@ func main() { | |||
| 253 | set.close() | 260 | set.close() |
| 254 | os.Exit(1) | 261 | os.Exit(1) |
| 255 | } | 262 | } |
| 256 | - // A decider answers with its decision model: no tool is ever called. | ||
| 257 | - if err := config.DecisionConflict(set.names); err != nil { | ||
| 258 | - fail("[config error: %v ]", err) | ||
| 259 | - set.close() | ||
| 260 | - os.Exit(1) | ||
| 261 | - } | ||
| 262 | if err := installRouter(); err != nil { | 263 | if err := installRouter(); err != nil { |
| 263 | fail("[config error: %v ]", err) | 264 | fail("[config error: %v ]", err) |
| 264 | set.close() | 265 | set.close() |
| @@ -386,8 +387,18 @@ type toolSet struct { | |||
| 386 | // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on); | 387 | // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on); |
| 387 | // `search_docs` behind `docSearch.autoSearchDocs` (off by default); | 388 | // `search_docs` behind `docSearch.autoSearchDocs` (off by default); |
| 388 | // and the agent file's declared `tools:`. | 389 | // and the agent file's declared `tools:`. |
| 390 | +// | ||
| 391 | +// A decider gets none of them, whatever bashTool, editTools, skillsDir… say | ||
| 392 | +// (ticket 141): its model never calls a tool, so a decider file needs no | ||
| 393 | +// `…: false` lines. What only a deliberate entry declares (mcp, lsp, | ||
| 394 | +// mmConfigs, tools:) is refused earlier by config.DecisionConflict. | ||
| 389 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { | 395 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 390 | set := toolSet{close: func() {}} | 396 | set := toolSet{close: func() {}} |
| 397 | + if config.Cfg.Decision != nil { | ||
| 398 | + engine.DeclareSkills(false) | ||
| 399 | + skills.Declare(nil) | ||
| 400 | + return set | ||
| 401 | + } | ||
| 391 | if config.Cfg.BashTool { | 402 | if config.Cfg.BashTool { |
| 392 | set.tools = append(set.tools, tools.Bash(e.G, d)) | 403 | set.tools = append(set.tools, tools.Bash(e.G, d)) |
| 393 | set.names = append(set.names, "bash") | 404 | set.names = append(set.names, "bash") |
| @@ -425,7 +436,7 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | |||
| 425 | set.tools = append(set.tools, tools.SaveAnswer(e.G, d)) | 436 | set.tools = append(set.tools, tools.SaveAnswer(e.G, d)) |
| 426 | set.names = append(set.names, "save_answer") | 437 | set.names = append(set.names, "save_answer") |
| 427 | } | 438 | } |
| 428 | - if config.Cfg.UnloadModels && config.Cfg.OutputSchema == nil && config.Cfg.Decision == nil { | 439 | + if config.Cfg.UnloadModels && config.Cfg.OutputSchema == nil { |
| 429 | set.tools = append(set.tools, tools.UnloadModel(e.G, d, e.Unload)) | 440 | set.tools = append(set.tools, tools.UnloadModel(e.G, d, e.Unload)) |
| 430 | set.names = append(set.names, "unload_model") | 441 | set.names = append(set.names, "unload_model") |
| 431 | } | 442 | } |
| @@ -620,7 +631,7 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | |||
| 620 | // whether mm looked in the wrong place or found the right one empty. | 631 | // whether mm looked in the wrong place or found the right one empty. |
| 621 | // An agent with a structuredOutput has neither tools nor skills on | 632 | // An agent with a structuredOutput has neither tools nor skills on |
| 622 | // purpose (config.StructuredOutputConflict): nothing to warn about. | 633 | // purpose (config.StructuredOutputConflict): nothing to warn about. |
| 623 | - // A decider has neither on purpose either (config.DecisionConflict). | 634 | + // A decider has neither on purpose either (assembleTools, ticket 141). |
| 624 | structuredAgent := config.Cfg.OutputSchema != nil || config.Cfg.Decision != nil | 635 | structuredAgent := config.Cfg.OutputSchema != nil || config.Cfg.Decision != nil |
| 625 | agentsMdCol, agentsMdWarnings := agentsMdBanner(acpMode, info.ContextWindow) | 636 | agentsMdCol, agentsMdWarnings := agentsMdBanner(acpMode, info.ContextWindow) |
| 626 | info.Warnings = append(info.Warnings, agentsMdWarnings...) | 637 | info.Warnings = append(info.Warnings, agentsMdWarnings...) |