From 18e9c59b9723d4b797840d4a6b05373508ffad09 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 07:17:13 +0000 Subject: [PATCH] feat: docker catalog, provider/edit trust, and CI smoke tests Add an @docker catalog (ps, compose, logs, diagnose) on CTRL+SHIFT+D, and fix Gemini # edits, Ollama CLI ready(), JSON parse errors, and silent multi-file edit drops. CI now runs the existing Lua/Python smoke tests. Co-authored-by: Weldon Sams --- .github/workflows/test.yml | 31 ++ AGENTS.md | 6 +- GUIDE.md | 79 +++- README.md | 19 +- SPECS.md | 80 +++- plugin/composer.py | 9 + plugin/context.lua | 27 ++ plugin/docker.lua | 722 ++++++++++++++++++++++++++++++++ plugin/edit.lua | 24 ++ plugin/history.lua | 33 +- plugin/init.lua | 152 ++++++- plugin/palette.lua | 61 ++- plugin/providers/gemini_api.lua | 35 +- plugin/providers/ollama_bin.lua | 6 +- plugin/settings.lua | 9 + plugin/shell.lua | 5 + plugin/ui.lua | 2 +- scripts/test_json_extract.py | 83 ++++ scripts/test_parse_refs.py | 13 +- scripts/test_settings.lua | 4 + wezai.env.example | 3 + 21 files changed, 1336 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 plugin/docker.lua create mode 100644 scripts/test_json_extract.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bce1106 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + name: Lua and Python smoke tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Lua + run: sudo apt-get update && sudo apt-get install -y lua5.4 python3 + + - name: Run tests + run: | + set -euo pipefail + lua5.4 scripts/test_settings.lua + lua5.4 scripts/test_history.lua + python3 scripts/test_parse_refs.py + python3 scripts/test_json_extract.py + WEZAI_COMPOSER_TEST=1 python3 plugin/composer.py --self-test diff --git a/AGENTS.md b/AGENTS.md index 3eb2544..dfa5e85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Other WezTerm defaults to treat carefully when picking shortcuts (non-exhaustive ## Keybinding checklist (before shipping a new shortcut) 1. Check [WezTerm default keys](https://wezterm.org/config/default-keys.html) for conflicts. -2. Prefer `CTRL|SHIFT` + a free letter for catalog scopes (`G` git, `K` kube, `H` history) — **not `T`** (SpawnTab) or **`W`** (CloseCurrentTab). +2. Prefer `CTRL|SHIFT` + a free letter for catalog scopes (`G` git, `K` kube, `H` history, `D` docker) — **not `T`** (SpawnTab) or **`W`** (CloseCurrentTab). 3. Update `settings.lua` defaults, `init.lua` bind site, and README / GUIDE / SPECS tables together. 4. Call out any intentional override of a WezTerm default in those docs. @@ -76,7 +76,7 @@ Raise `timeout` to **300–600** for big local GGUFs, or pre-warm (`ollama run ## Related pitfalls -- Plugin modules load via fingerprint scan in `init.lua`. Prefer the **most complete** install (`tf.lua`, `weather.lua`, etc.) so a stale local checkout cannot hide new catalogs. -- After merging catalog work to GitHub installs: users need palette **Update wezai plugin** (or `update_all()` + config reload). Log line should include `tf.lua ok` / `weather.lua ok` when present. +- Plugin modules load via fingerprint scan in `init.lua`. Prefer the **most complete** install (`tf.lua`, `weather.lua`, `docker.lua`, etc.) so a stale local checkout cannot hide new catalogs. +- After merging catalog work to GitHub installs: users need palette **Update wezai plugin** (or `update_all()` + config reload). Log line should include `tf.lua ok` / `weather.lua ok` / `docker.lua ok` when present. - **Never** call `wezterm.run_child_process` from a module’s main chunk (inside `require`). WezTerm yields in that API, and Lua errors with `attempt to yield across a C-call boundary`, taking down config load. `util.version_label()` must stay file/`require("version")` only. - Shell vs AI pane, provider contract, and safety rules: see SPECS §4. diff --git a/GUIDE.md b/GUIDE.md index c7b82c1..fa8bca9 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,6 +1,6 @@ # wezai guide -Practical examples for every major surface: Ask, the palette, `@git`, `@kube`, `@tf`, `@weather`, `@history`, and file edits. +Practical examples for every major surface: Ask, the palette, `@git`, `@kube`, `@tf`, `@docker`, `@weather`, `@history`, and file edits. --- @@ -9,7 +9,7 @@ Practical examples for every major surface: Ask, the palette, `@git`, `@kube`, ` | Pane | Role | |------|------| | **Left (shell)** | Your real terminal. cwd, git repo, commands you run. Stay focused here. | -| **Right (wezai)** | Output only — answers, diffs, `git status`, terraform validate, weather, progress. Not a shell. While Ask/Edit or a catalog command waits, this pane scrolls a spinner / timed status so it does not look frozen. | +| **Right (wezai)** | Output only — answers, diffs, `git status`, terraform validate, docker ps, weather, progress. Not a shell. While Ask/Edit or a catalog command waits, this pane scrolls a spinner / timed status so it does not look frozen. | | Entry point | When to use it | |-------------|----------------| @@ -17,6 +17,7 @@ Practical examples for every major surface: Ask, the palette, `@git`, `@kube`, ` | `CTRL+SHIFT+P` | Palette — jump to any action without typing a full Ask line | | `CTRL+SHIFT+G` | Same palette, pre-filtered to `@git` | | `CTRL+SHIFT+K` | Same palette, pre-filtered to `@kube` | +| `CTRL+SHIFT+D` | Same palette, pre-filtered to `@docker` | | `CTRL+ALT+T` | Same palette, pre-filtered to `@tf` | | `CTRL+ALT+W` | Same palette, pre-filtered to `@weather` | | `CTRL+SHIFT+H` | Same palette, pre-filtered to `@history` | @@ -105,6 +106,7 @@ Undo: palette → **Undo last edit**. | `@git:diff` | `@git:diff write a PR summary` | | `@tf:state` | `@tf:state what’s orphaned?` | | `@tf:validate` | `@tf:validate why is this failing?` | +| `@docker:ps` | `@docker:ps what’s using port 5432?` | | `@weather:now` | `@weather should I bring a jacket?` | | `@dir:.` | `@dir:src what modules exist?` | | `@history` | `@history what docker commands did I run?` | @@ -128,6 +130,7 @@ Type to fuzzy-filter. Labels start with namespaces so filtering is easy: | `@git:soft` / `@git:rebase` | Soft reset / interactive rebase (any N) | | `@kube` | All kubectl actions | | `@tf` | All terraform actions | +| `@docker` | Docker / Compose actions | | `@weather` | Open-Meteo current / forecast / set zip | | `@history` | Unique shell history (detected fish/zsh/bash), type to fuzzy-filter | | `Ask` / `Fix` / `model` | Core helpers | @@ -345,6 +348,54 @@ Ask-with-context attach tokens: `@tf:state`, `@tf:validate`, `@tf:output`, `@tf: --- +## `@docker` actions + +Open via `CTRL+SHIFT+P` → type `@docker`, or `CTRL+SHIFT+D`, or Ask → `@docker` / `@docker:ps`. + +Commands use the **shell pane cwd** for Compose (`docker compose` in that directory). wezai resolves the `docker` binary the same way as kubectl/terraform (Homebrew + login shell), or set `docker.docker = "/usr/bin/docker"`. + +**Mutating** actions (`restart`, `rm`, `compose-down`) confirm when `docker.confirm_mutate` is true (default). AI helpers gather read-only context (ps / compose ps / selection) and steer toward logs / inspect — not `rm` / `compose down` unless you asked. + +### Show (no model) + +These print the command in the wezai pane immediately, then a spinner until docker returns. + +| Action | Meaning | +|--------|---------| +| `@docker:ps` | Running containers | +| `@docker:ps-a` | All containers (`ps -a`) | +| `@docker:images` | Local images | +| `@docker:compose-ps` | `docker compose ps` in the shell cwd | +| `@docker:df` | `docker system df` | + +### Shell helpers + +| Action | Notes | +|--------|--------| +| `@docker:logs` / `logs-f` | Prompt for container; `--tail=N` via `@docker:logs200` | +| `@docker:exec` | `docker exec -it sh` | +| `@docker:compose-up` / `compose-down` | Detached up; down confirms | +| `@docker:compose-logs` | Compose logs `--tail=N` | +| `@docker:pull` | Prompt for image | +| `@docker:restart` / `rm` | Confirm before run | + +### AI + +| Action | What it does | +|--------|----------------| +| `@docker:diagnose` | Attach `ps -a` + compose ps (+ selection) → diagnose | +| `@docker:explain-sel` | Explain selected docker/compose output | + +``` +CTRL+SHIFT+D → @docker:ps +CTRL+I → @docker:ps what's using 5432? +CTRL+I → @docker:diagnose (select a crash log first) +``` + +Ask-with-context attach tokens: `@docker:ps`, `@docker:ps-a`, `@docker:images`, `@docker:compose-ps`, `@docker:df`. Bare `@docker` opens the palette. `@docker should I restart?` attaches `ps`. + +--- + ## `@weather` actions Open via `CTRL+SHIFT+P` → type `@weather`, or `CTRL+ALT+W`, or Ask → `@weather` / `@weather:now`. @@ -402,12 +453,14 @@ Bare `@weather` opens the weather palette. Add a question after `@weather` / `@w ## `@history` -The history palette uses the **detected shell** (fish / zsh / bash) with the same actions on every shell. +The history palette uses the **detected shell** (fish / zsh / bash) with the same actions on those shells. 1. `CTRL+SHIFT+H` (or `CTRL+SHIFT+P` → type `@history`) 2. Type to fuzzy-filter unique commands (newest first) 3. Pick a row → **Run** / **Insert** / **Explain** / **Attach & ask** / **Copy** / **Delete** +PowerShell (and unknown shells) still list scrollback / session events, but histfile search and **Delete** are not offered. + **Search:** the history-scoped palette loads up to `search_n` unique commands (default 12 000) so WezTerm’s fuzzy matcher can reach far back. For the rest of a huge histfile, pick **Search entire history…** and type a query (`fzf -f` if installed, otherwise subsequence matching). The unified `CTRL+SHIFT+P` list stays at `palette_n` (200) recent rows. **Delete** removes **all copies** of that command, like fish’s history pager: @@ -507,7 +560,20 @@ CTRL+I → @tf:generate aws_s3_bucket with versioning enabled Then write it with `#main.tf …` after reviewing the suggestion. -### “What’s the weather?” +### “What's running locally?” + +``` +CTRL+SHIFT+D → @docker:ps +CTRL+I → @docker:ps what's using 5432? +``` + +Or diagnose a crash (select the log first): + +``` +CTRL+SHIFT+D → @docker:diagnose +``` + +### “What's the weather?” ``` CTRL+ALT+W → @weather:zip # once — e.g. 90210 @@ -525,7 +591,7 @@ CTRL+I → @weather should I bring a jacket tonight? ## Safety - Secrets (API keys, tokens, private keys) are redacted before send / memory -- Risky shell commands confirm before send (includes `terraform apply` / `destroy` / `force-unlock`) +- Risky shell commands confirm before send (includes `terraform apply` / `destroy` / `force-unlock` and `docker rm` / `compose down`) - `#` / `@@` edits always show a unified diff in the right-hand pane, with Apply/Cancel in a shell split (not a full-window overlay) unless you disable `require_edit_confirm` - Edit backups are timestamped wezai **dotfiles** (`backup.suffix` / `backup.dir` / `backup.dotfile`); set `backup.enabled = false` to skip writing `.bak` files - No force-push action in v1 @@ -540,9 +606,12 @@ CTRL+I → @weather should I bring a jacket tonight? |---------|-----| | `no pane cwd` | Focus the **shell** pane, not the wezai pane; then open the palette again | | Overlay covers the diff / right pane | Edit confirm should split under the shell. Palette → **Update wezai plugin**. Fallback InputSelector (no python3) still covers the tab. | +| Composer/confirm is a full-window overlay | Needs `python3` plus `plugin/composer.py` / `confirm.py`. The AI pane prints a warning; overlay still works. | | Palette is WezTerm’s, not wezai | Reload config; wezai overrides `CTRL+SHIFT+P`. Or set `keybinding_palette` | | Empty `@history` | Run commands in fish/zsh/bash first; check `history.tail_bytes` / `search_n`. Bash often needs `histappend` (or a `history -a` in `PROMPT_COMMAND`) so the current session is on disk. | +| `@history` says histfile unsupported | PowerShell (and unknown shells) have no histfile search/delete. Scrollback and session events still list. Use fish/zsh/bash for Delete. | | No `@tf` in palette | Need wezai ≥ 1.5.0 with `plugin/tf.lua`. Palette → **Update wezai plugin**, then reload. Log should say `tf.lua ok`. Shortcut is `CTRL+ALT+T` (not `CTRL+SHIFT+T`) | +| No `@docker` in palette | Need `plugin/docker.lua`. Palette → **Update wezai plugin**, then reload. Log should say `docker.lua ok`. Shortcut is `CTRL+SHIFT+D`. | | No `@weather` / “No zip set” | Need `plugin/weather.lua`. Set `@weather:zip 90210` or `WEZAI_WEATHER_ZIP=90210` in `wezai.env`. Shortcut is `CTRL+ALT+W` (not `CTRL+SHIFT+W`) | | Palette title is `wezai ?` | Stale plugin without `plugin/version.lua`. Palette → **Update wezai plugin**; log should show `wezai v1.12.0…` not `?` | | Config error `yield across a C-call boundary` | Load-time process spawn (fixed after 1.12.0). Palette is unavailable — `git fetch` in the WezTerm plugin cache (see README Troubleshooting), then reload | diff --git a/README.md b/README.md index 40f5a69..2c4071f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # wezai -**wezai** is a WezTerm plugin that puts an AI assistant, a command palette, and git/kube/terraform/weather/history shortcuts next to your shell — without leaving the terminal. +**wezai** is a WezTerm plugin that puts an AI assistant, a command palette, and git/kube/terraform/docker/weather/history shortcuts next to your shell — without leaving the terminal. > **Alpha software.** wezai is in active development and under heavy testing. Behavior and APIs may change. If you hit a bug or have an idea, please [open an issue](https://github.com/wsams/wezai/issues) — reports are welcome and help shape the project. See [CONTRIBUTING.md](CONTRIBUTING.md) for what to include. -- Ask questions with file, directory, clipboard, git, kube, terraform, weather, and selection context +- Ask questions with file, directory, clipboard, git, kube, terraform, docker, weather, and selection context - Edit files in one pass (`#path`, legacy `@@path`) with a unified diff confirm and wezai dotfile backups - CTRL+I composer keeps the AI log visible; `@` / `#` fuzzy-complete cwd paths plus `~/`, `/abs`, `../`; context persists until Compact/Clear -- One palette (`CTRL+SHIFT+P`) for Ask helpers, `@git:…`, `@kube:…`, `@tf:…`, `@weather:…`, and `@history` +- One palette (`CTRL+SHIFT+P`) for Ask helpers, `@git:…`, `@kube:…`, `@tf:…`, `@docker:…`, `@weather:…`, and `@history` - Shell-aware suggestions, secret redaction, risky-command confirms @@ -65,18 +65,20 @@ Do **not** leave `update_all()` / `reload_configuration()` at config file scope |-----|--------| | `CTRL+I` | **Ask** — composer under the shell (`@` attach, `#` edit). Esc saves a draft | | `CTRL+SHIFT+E` | **Ask** with pane scrollback attached as context | -| `CTRL+SHIFT+P` | **Palette** — type `@git`, `@kube`, `@tf`, `@weather`, `@history`, or `Ask` to filter | +| `CTRL+SHIFT+P` | **Palette** — type `@git`, `@kube`, `@tf`, `@docker`, `@weather`, `@history`, or `Ask` to filter | | `CTRL+SHIFT+G` | Palette scoped to `@git` | | `CTRL+SHIFT+K` | Palette scoped to `@kube` | +| `CTRL+SHIFT+D` | Palette scoped to `@docker` | | `CTRL+ALT+T` | Palette scoped to `@tf` (not `CTRL+SHIFT+T` — that is WezTerm’s new tab) | | `CTRL+ALT+W` | Palette scoped to `@weather` (not `CTRL+SHIFT+W` — that is WezTerm’s close tab) | | `CTRL+SHIFT+H` | Palette scoped to `@history` (fuzzy unique commands; Delete like fish) | -Stay on your **shell** pane. The right split is **output only** (answers, diffs, git/kube/tf/weather status). Catalog commands print immediately and spin `waiting…` until output arrives, so a slow `@weather:now` does not look frozen. Don’t run git from that pane — wezai always uses your shell’s cwd. +Stay on your **shell** pane. The right split is **output only** (answers, diffs, git/kube/tf/docker/weather status). Catalog commands print immediately and spin `waiting…` until output arrives, so a slow `@weather:now` does not look frozen. Don’t run git from that pane — wezai always uses your shell’s cwd. ``` CTRL+SHIFT+P → type @git:status → Enter CTRL+SHIFT+P → type @tf:validate → Enter +CTRL+SHIFT+P → type @docker:ps → Enter CTRL+I → @README.md is this safe? → Enter CTRL+I → #notes.txt sort the lines → review diff → Apply CTRL+I → @plugin/ (pins the tree) → how is loading wired? @@ -105,6 +107,8 @@ More examples: [GUIDE.md](GUIDE.md). | `@tf` / `@tf:validate` | Terraform helpers in the shell cwd (`terraform` binary auto-resolved) | | `@tf:state` in a question | Attach `terraform state list` and ask | | `@tf:generate …` / `@tf:debug` | AI helpers to generate or debug HCL | +| `@docker` / `@docker:ps` | Local Docker / Compose helpers (`docker` binary auto-resolved) | +| `@docker:ps` in a question | Attach `docker ps` and ask | | `@weather` / `@weather:now` | Current conditions (Open-Meteo; needs a zip) | | `@weather:zip 90210` | Save zip from the plugin (`~/.local/share/wezai/weather.json`) | | `@dir:path` | Directory listing | @@ -129,6 +133,7 @@ Most people never need the Lua table. Copy [wezai.env.example](wezai.env.example | `WEZAI_OLLAMA_PATH` | `ollama_path` | unset | | `WEZAI_LMS_PATH` | `lms_path` | unset | | `WEZAI_KUBE_NS` | `kube.namespace` | kubectl current ns | +| `WEZAI_DOCKER_BIN` | `docker.docker` | unset (auto-resolve) | | `WEZAI_WEATHER_ZIP` | `weather.zip` | unset (`@weather:zip` still works) | | `WEZAI_WEATHER_COUNTRY` | `weather.country` | `US` | | `WEZAI_WEATHER_UNITS` | `weather.units` | `auto` | @@ -146,6 +151,7 @@ wezai.apply_to_config(config, { keybinding_history = { key = "h", mods = "CTRL|SHIFT" }, keybinding_git = { key = "g", mods = "CTRL|SHIFT" }, keybinding_kube = { key = "k", mods = "CTRL|SHIFT" }, + keybinding_docker = { key = "d", mods = "CTRL|SHIFT" }, keybinding_tf = { key = "t", mods = "CTRL|ALT" }, keybinding_weather = { key = "w", mods = "CTRL|ALT" }, @@ -155,6 +161,9 @@ wezai.apply_to_config(config, { -- Optional: absolute terraform if GUI PATH can't find it tf = { terraform = nil, confirm_mutate = true }, + -- Optional: absolute docker if GUI PATH can't find it + docker = { docker = nil, confirm_mutate = true }, + -- Optional: ZIP for @weather (Open-Meteo). Prefer WEZAI_WEATHER_ZIP or @weather:zip -- (saved under ~/.local/share/wezai/weather.json — does not rewrite this file). weather = { zip = nil, country = "US", units = "auto" }, diff --git a/SPECS.md b/SPECS.md index f6ad987..c6aa8cf 100644 --- a/SPECS.md +++ b/SPECS.md @@ -16,12 +16,12 @@ User-facing walkthroughs: [GUIDE.md](GUIDE.md). Install / config sketch: [README **wezai** is a WezTerm plugin that puts an AI assistant and utility catalogs next to the user’s shell: -- **Ask** — natural-language CLI help with attachable context (`@file`, `@dir/`, selection, git, kube, tf, weather, history). CTRL+I opens a **composer pane** under the shell so the right-hand AI log stays visible; drafts persist if you Esc. +- **Ask** — natural-language CLI help with attachable context (`@file`, `@dir/`, selection, git, kube, tf, docker, weather, history). CTRL+I opens a **composer pane** under the shell so the right-hand AI log stays visible; drafts persist if you Esc. - **Edit** — create/rewrite via `#path` (legacy `@@path`) with unified diff + confirm in a **shell split** (right-hand AI pane stays visible). `#dir/` pins a tree of editable files. -- **Palette** — fuzzy `InputSelector` (`CTRL+SHIFT+P`) for Ask helpers, `@git`, `@kube`, `@tf`, `@weather`, `@history`, Compact, Clear, plugin update. -- **AI output pane** — right-side keep-alive pane for answers, diffs, git/kube/tf/weather show output (not for running shell/git). +- **Palette** — fuzzy `InputSelector` (`CTRL+SHIFT+P`) for Ask helpers, `@git`, `@kube`, `@tf`, `@docker`, `@weather`, `@history`, Compact, Clear, plugin update. +- **AI output pane** — right-side keep-alive pane for answers, diffs, git/kube/tf/docker/weather show output (not for running shell/git). -- **Safety** — secret redaction, risky-command confirms, mutate confirms for git/kube/tf/edit. +- **Safety** — secret redaction, risky-command confirms, mutate confirms for git/kube/tf/docker/edit. Brand string in UI/logs: **`wezai`**. Never introduce third-party product branding in UI strings. @@ -57,7 +57,7 @@ Public entrypoint: `plugin/init.lua` → `apply_to_config(wezterm_config, user_c ``` plugin/ init.lua -- bootstrap, keybindings, ask/edit orchestration, palette hooks - settings.lua -- defaults + wezai.env / process env / user merge (nested: ai_pane, history, git, kube, tf, weather, stats, files, backup, composer, context) + settings.lua -- defaults + wezai.env / process env / user merge (nested: ai_pane, history, git, kube, tf, docker, weather, stats, files, backup, composer, context) version.lua -- bundled semver for UI (semantic-release writes this) util.lua -- paths, files, JSON parse, run_cmd (pcall), resolve_executable, large-file read, version_label @@ -78,6 +78,7 @@ plugin/ kube.lua -- @kube action catalog tf.lua -- @tf terraform action catalog weather.lua -- @weather Open-Meteo catalog (zip overlay) + docker.lua -- @docker container / compose catalog palette.lua -- unified CTRL+SHIFT+P palette stats.lua -- persistent token/model usage DB providers/ @@ -101,7 +102,7 @@ Node/`package.json` is for semantic-release tooling. At runtime the Lua plugin p ### 4.1 Shell pane vs AI pane -- All git/kube/tf/history/cwd work must use the **shell** pane, never the AI output pane. +- All git/kube/tf/docker/history/cwd work must use the **shell** pane, never the AI output pane. - `ui.shell_pane_for(window, pane)` resolves the real shell when focus is on the AI pane. - AI pane is output-only (keep-alive `sh`/`sleep` loop marked `WEZAI_OUTPUT_PANE`). Catalog **show** and plugin update print the command immediately, then `ui.start_busy` (braille spinner + elapsed) until output arrives. @@ -145,7 +146,7 @@ Parsing (`util.parse_json_response`): try raw → fenced body → fence-stripped While Ask/Edit wait, `ui.start_progress` (when `show_loading` is true) scrolls status lines in the AI pane: model + endpoint, elapsed vs timeout with %, remaining time, and rotating phase hints (cold load / warmup / approaching timeout). Pulses use `wezterm.time.call_after` so they keep printing during long `run_child_process` yields. -Catalog **show** actions (`@weather`, `@git` status/log/diff, `@kube` pods/logs, `@tf` validate/plan, …), weather ZIP geocode, and **Update wezai plugin** use `ui.start_busy` / `ui.with_busy`: print the command immediately, then a braille spinner (`waiting… 2s`). Same `show_loading` flag: `false` still prints the command plus one waiting line, but no pulses. +Catalog **show** actions (`@weather`, `@git` status/log/diff, `@kube` pods/logs, `@tf` validate/plan, `@docker` ps/compose-ps, …), weather ZIP geocode, and **Update wezai plugin** use `ui.start_busy` / `ui.with_busy`: print the command immediately, then a braille spinner (`waiting… 2s`). Same `show_loading` flag: `false` still prints the command plus one waiting line, but no pulses. ### 4.5 Dialect + platform injection On every ask (`with_dialect` in `init.lua`): @@ -161,7 +162,7 @@ Command labels print as `(fish/macos)` style when showing suggested commands. ### 4.6 Safety - `context.redact` strips common secrets (keys, tokens, JWTs, private keys) before prompts/history. -- `shell.is_risky` + confirm before sending dangerous commands (includes kubectl mutate/exec patterns, terraform apply/destroy/import/state-rm/force-unlock, and git force/push/reset/etc.). +- `shell.is_risky` + confirm before sending dangerous commands (includes kubectl mutate/exec patterns, terraform apply/destroy/import/state-rm/force-unlock, docker rm/compose-down/system prune, and git force/push/reset/etc.). - Edit apply and kube/tf mutate actions confirm unless config disables confirms. Edit (and other Yes/No) confirms use a **split of the shell pane** so the right-hand AI pane stays visible — that is where the unified diff is printed. WezTerm InputSelector is only the fallback when python3/`confirm.py` is missing (it covers the tab, so that fallback still embeds a diff preview in the selector). - Edit backups are timestamped **dotfiles** with `wezai` in the name (`backup.enabled` / `backup.dir` / `backup.suffix` / `backup.dotfile`). Default: `.notes.txt..wezai.bak` next to the target. Undo uses the bak file or in-memory prior content. Directory walks skip `*wezai*.bak`. - Kube AI helpers must prefer **read-only** next steps (get/describe/logs); never bake org-specific cluster/namespace names into the catalog. @@ -197,13 +198,14 @@ Supports: | `@git` / `@git:id` | Git picker or action (see §5.4) | | `@kube` / `@kube:id` / `@kube:pods/` | Kube picker, action, or attach with optional ns (see §5.5) | | `@tf` / `@tf:id` / `@terraform:id` | Terraform picker, action, or attach (see §5.6) | +| `@docker` / `@docker:id` | Docker / compose picker, action, or attach (see §5.10) | | `@weather` / `@weather:id` | Weather picker, current/forecast, or attach (see §5.9) | | `@history` / bare history ref | History palette / attach | | `@dir:path` | Shallow directory **listing** only (not file contents) | | `compact` / `/compact` | Compact conversation + sticky selection; keep `@`/`#` pins | | `clear` / `/clear` | Wipe turns, selections, drafts, and file pins | -**Composer autocomplete:** typing `@` or `#` lists files and directories under the shell cwd (prefix then fuzzy). Paths that start with `~/`, `/` (absolute), `./`, or `../` live-list that location instead of the cwd. Tab inserts the highlighted path; Enter accepts an incomplete match, or sends the line when the token is already exact / the cursor is outside a ref. `@git:` / `@kube:` / `@tf:` / `@history` are reserved and do not open the file list. +**Composer autocomplete:** typing `@` or `#` lists files and directories under the shell cwd (prefix then fuzzy). Paths that start with `~/`, `/` (absolute), `./`, or `../` live-list that location instead of the cwd. Tab inserts the highlighted path; Enter accepts an incomplete match, or sends the line when the token is already exact / the cursor is outside a ref. `@git:` / `@kube:` / `@tf:` / `@docker:` / `@weather:` / `@history` are reserved and do not open the file list. **Path parsing:** unquoted `@`/`#` refs strip trailing sentence punctuation (`?!. ,;:)` …) so `@package.json?` works. Quoted paths are literal. `#` is only a ref at token start when the next character is path-like (not `# heading` with a space). @@ -223,10 +225,11 @@ Supports: Unified fuzzy `InputSelector` with scopes: -- full (Ask helpers + git + kube + tf + weather + history rows) +- full (Ask helpers + git + kube + tf + docker + weather + history rows) - `git` (`CTRL+SHIFT+G`) - `kube` (`CTRL+SHIFT+K`) - `tf` (`CTRL+ALT+T` — not `CTRL+SHIFT+T`, which is WezTerm SpawnTab) +- `docker` (`CTRL+SHIFT+D`) - `weather` (`CTRL+ALT+W` — not `CTRL+SHIFT+W`, which is WezTerm CloseCurrentTab) - `history` (`CTRL+SHIFT+H`) and filtered history scopes @@ -234,7 +237,7 @@ Core palette rows include: Ask, Ask+pane, Fix last error, Explain last command, ### 5.3 History -Shell is **auto-detected** from the pane (`fish` / `zsh` / `bash`). The same palette actions apply on every shell: **Run / Insert / Explain / Attach & ask / Copy / Delete**. +Shell is **auto-detected** from the pane (`fish` / `zsh` / `bash`). The same palette actions apply on those shells: **Run / Insert / Explain / Attach & ask / Copy / Delete**. - **Fish:** list via native `history` (`fish --no-config`) when available, else `fish_history`; **Delete** sends `history delete --exact --case-sensitive` to the live pane (session + file, same as the fish pager). - **Bash / zsh:** unique newest-first index from `HISTFILE` (pane `/proc/pid/environ` on Linux, then `~/.bash_history` / `~/.zsh_history`). **Delete** flushes (`history -a` / `fc -W`), rewrites the file (all exact copies), reloads in-memory history — one synchronous chain via `history_edit.py`. @@ -244,6 +247,8 @@ Shell is **auto-detected** from the pane (`fish` / `zsh` / `bash`). The same pal - Attach limits: `@history:40` (all, N entries), `@history:shell:40` / `@history:ai:20` / `@history:failed:15` (filter + N). Default limit = `attach_n` (40). Bare `@history` / `@history:shell` open the palette. +- **PowerShell / unknown:** histfile search and **Delete** are not offered. Scrollback and session events still list. Palette shows an explicit “histfile unsupported” row. + ### 5.4 `@git` catalog (`git.lua`) Kinds: `show` (print in AI pane with command + spinner until git returns), `shell` (run/insert in shell), `ai` (LLM with git context). @@ -382,6 +387,37 @@ Kinds: `show` (print in AI pane), `shell` (prompt / persist zip). No model. Fore - `@weather`, `@weather:now`, `@weather:forecast` +### 5.10 `@docker` catalog (`docker.lua`) + +Kinds: `show` (print in AI pane with command + spinner until docker returns), `shell` (run/insert in shell), `ai` (LLM diagnose/explain). + +Uses the shell pane cwd for compose (`docker compose --project-directory=` for show/AI). Binary resolution mirrors kube/tf: `config.docker.docker` or `util.resolve_executable` (Homebrew + login shell). Shell-kind actions insert bare `docker …` into the user’s shell. + +#### Invocation forms + +| Form | Meaning | +|------|---------| +| `@docker` / `@docker:` | Open docker palette | +| `@docker:ps` | Show `docker ps` in AI pane | +| `@docker:logs nginx` | Insert/run `docker logs --tail=200 nginx` in shell | +| `@docker:logs200` / `@docker:logs 200` | `--tail=N` (same idea as kube logs) | +| `@docker:diagnose why is api crashlooping?` | AI diagnose (ps + compose ps + selection) | +| `@docker:ps what’s using port 5432?` | Attach ps + ask | + +Bare `@docker:id` = run action; show actions with trailing text fall through to attach + ask. `@docker should I restart?` attaches `ps`. + +#### Catalog kinds + +- Show (no model): `ps`, `ps-a`/`psa`, `images`, `compose-ps`/`compose`, `df`/`system-df`. +- Shell (no model): `logs`/`logs-f` (tail N), `exec`, `compose-up`, `compose-down`, `compose-logs`, `pull`, `restart`, `rm`. +- AI: `diagnose` — ps + compose ps + selection; `explain-sel` — selected docker output. + +Mutate confirms when `docker.confirm_mutate` (default true) for `restart` / `rm` / `compose-down`. AI helpers steer toward ps/logs/inspect — never bake hostnames into the catalog. + +#### Ask attach tokens + +- `@docker`, `@docker:ps`, `@docker:ps-a`, `@docker:images`, `@docker:compose-ps`, `@docker:df` + --- ## 6. Default keybindings @@ -395,6 +431,7 @@ Kinds: `show` (print in AI pane), `shell` (prompt / persist zip). No model. Fore | Git scope | `CTRL\|SHIFT+g` | Git palette | | Kube scope | `CTRL\|SHIFT+k` | Kube palette | | Terraform scope | `CTRL\|ALT+t` | Terraform palette | +| Docker scope | `CTRL\|SHIFT+d` | Docker palette | | Weather scope | `CTRL\|ALT+w` | Weather palette | **Reserved:** `CTRL+SHIFT+T` is WezTerm **`SpawnTab`** — never bind wezai to it. `CTRL+SHIFT+W` is WezTerm **`CloseCurrentTab`** — weather uses `CTRL+ALT+W`. See [AGENTS.md](AGENTS.md). @@ -405,7 +442,7 @@ Single-letter keys are also bound with opposite case for WezTerm quirks. ## 7. Configuration surface -Merged in `settings.finalize`. Order: **BASE defaults** < **`wezai.env` file** < **process environment** < **`apply_to_config` table**. Nested keys deep-merged: `ai_pane`, `history`, `git`, `kube`, `tf`, `weather`, `stats`, `files`, `backup`, `composer`, `context`. +Merged in `settings.finalize`. Order: **BASE defaults** < **`wezai.env` file** < **process environment** < **`apply_to_config` table**. Nested keys deep-merged: `ai_pane`, `history`, `git`, `kube`, `tf`, `docker`, `weather`, `stats`, `files`, `backup`, `composer`, `context`. Env file (first existing): `$WEZAI_ENV_FILE`, `$XDG_CONFIG_HOME/wezterm/wezai.env`, `~/.config/wezterm/wezai.env`, `~/.local/share/wezai/wezai.env`. Simple `KEY=VALUE` (`#` comments, optional `export`, quotes). GUI/Flatpak often has no shell env — the file is the supported path for secrets and zip/model. @@ -426,6 +463,7 @@ Important fields (see `settings.lua` for full defaults): | `require_edit_confirm`, `require_risk_confirm` | Safety toggles | | `kube.namespace`, `kube.kubectl`, `kube.confirm_mutate`, `kube.max_attach_bytes` | kubectl defaults / binary / attach cap (`WEZAI_KUBE_NS`) | | `tf.terraform`, `tf.confirm_mutate`, `tf.max_attach_bytes` | terraform binary / mutate confirms / attach cap | +| `docker.docker`, `docker.confirm_mutate`, `docker.max_attach_bytes` | docker binary / mutate confirms / attach cap (`WEZAI_DOCKER_BIN`) | | `weather.zip`, `weather.country`, `weather.units`, `weather.path` | Open-Meteo location (plugin `@weather:zip` overlay beats `zip` / `WEZAI_WEATHER_ZIP`) | | `git.default_branch`, `git.confirm_push`, `git.max_attach_bytes` | git catalog | | `history.*` | Shell/session history: `max_shell` unique cap, `search_n` (CTRL+SHIFT+H), `palette_n` (unified), `tail_bytes`, delete/search backends | @@ -439,8 +477,8 @@ Important fields (see `settings.lua` for full defaults): | `type` | Module | Notes | |--------|--------|-------| | `http` | `providers.chat_http` | OpenAI-compatible `/v1/chat/completions` (includes Ollama at `http://host:11434/v1/chat/completions`) | -| `google` | `providers.gemini_api` | Gemini `generateContent` + JSON schema | -| `ollama` | `providers.ollama_bin` | `ollama run … --format json` | +| `google` | `providers.gemini_api` | Gemini `generateContent` + JSON schema. Ask schema is `message`+`command`; `#` edits use a schema that also allows `file` / `files`. | +| `ollama` | `providers.ollama_bin` | `ollama run … --format json`. `ready()` accepts PATH `ollama` when `ollama_path` is unset (same default as `ask()`). | | `local` | `providers.lms_bin` | `lms chat …` | Shared transport: `providers.proc`. @@ -450,7 +488,7 @@ For `http` + Ollama: pick an **instruct** model that obeys §4.4; set a high `ti ## 9. Bootstrap / module path -WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scanning `wezterm.plugin.list()` for fingerprint files (`settings.lua`, `stats.lua`, `providers/init.lua`, `palette.lua`). Each listed `plugin_dir` is tried as **clone-root/`plugin/`** and as **the Lua dir itself** (Flatpak/Bazzite sometimes report the latter). Among matches it **prefers the most complete install** (counts `git.lua` / `kube.lua` / `tf.lua` / `weather.lua` / `version.lua` / `composer.lua` / `confirm.lua` / …) so a stale local checkout cannot shadow an updated GitHub cache that has newer catalogs. Local/`wsams` paths win only as a tie-breaker. Then extends `package.path` for `?.lua` and `?/init.lua`, and passes `plugin_dir` + repo root into `util.set_install_dirs` so `util.version_label()` can `require("version")` and walk up for `package.json` / git HEAD. `@tf` and `@weather` are soft-required — a missing `tf.lua` / `weather.lua` logs a warning and disables that catalog instead of failing the whole plugin. +WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scanning `wezterm.plugin.list()` for fingerprint files (`settings.lua`, `stats.lua`, `providers/init.lua`, `palette.lua`). Each listed `plugin_dir` is tried as **clone-root/`plugin/`** and as **the Lua dir itself** (Flatpak/Bazzite sometimes report the latter). Among matches it **prefers the most complete install** (counts `git.lua` / `kube.lua` / `tf.lua` / `weather.lua` / `docker.lua` / `version.lua` / `composer.lua` / `confirm.lua` / …) so a stale local checkout cannot shadow an updated GitHub cache that has newer catalogs. Local/`wsams` paths win only as a tie-breaker. Then extends `package.path` for `?.lua` and `?/init.lua`, and passes `plugin_dir` + repo root into `util.set_install_dirs` so `util.version_label()` can `require("version")` and walk up for `package.json` / git HEAD. `@tf`, `@weather`, and `@docker` are soft-required — a missing `tf.lua` / `weather.lua` / `docker.lua` logs a warning and disables that catalog instead of failing the whole plugin. --- @@ -459,6 +497,7 @@ WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scannin ### semantic-release (direct npm — not a third-party GitHub Action wrapper) - Workflow: `.github/workflows/release.yml` on push to `main`. +- Tests: `.github/workflows/test.yml` runs `scripts/test_settings.lua`, `scripts/test_history.lua`, `scripts/test_parse_refs.py`, `scripts/test_json_extract.py`, and `composer.py --self-test` on push/PR. - Config: `.releaserc.json` — changelog + version bump in git + GitHub Release; **`npmPublish: false`**. `@semantic-release/exec` writes `plugin/version.lua` during prepare so WezTerm installs that cannot see `package.json` still show a semver. - Conventional Commits required (`feat:`, `fix:`, `BREAKING CHANGE`, etc.). - Node: `^22.14.0 || >=24.10.0`. @@ -509,15 +548,19 @@ WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scannin - [ ] `@kube:use-ns myns` / `@kube:diagnose`; mutates confirm. - [ ] `@tf:validate` prints in AI pane; `@tf:plan` runs in shell; `@tf:apply` confirms. - [ ] `@tf:generate …` / `@tf:debug` use the model; attach `@tf:state what’s orphaned?` works. +- [ ] `@docker:ps` prints in AI pane; `@docker:compose-up` runs in shell; `@docker:rm` confirms. +- [ ] Ask `@docker:ps what’s using 5432?` attaches ps; `@docker:diagnose` uses the model. - [ ] `@weather:zip 90210` persists overlay; `@weather:now` prints the command + spinner before Open-Meteo output; `@weather:zip clear` falls back to `weather.zip`. - [ ] Ask `@weather should I bring a jacket?` attaches current conditions (needs a zip). - [ ] Stats banner/line appears; `~/.local/share/wezai/stats.json` updates. -- [ ] Git/kube/tf/weather/history always use shell cwd (not AI pane). +- [ ] Git/kube/tf/docker/weather/history always use shell cwd (not AI pane). - [ ] `@history` / `CTRL+SHIFT+H` fuzzy-filters unique commands from the detected shell (fish native `history` or bash/zsh `HISTFILE`); row **Delete** removes all copies (fish `history delete`, bash/zsh flush+rewrite+reload). - [ ] Local Ollama HTTP: with an unloaded large model, `timeout` ≥ load+warmup still returns JSON (not curl 28 / 0 bytes); second Ask is fast while model stays loaded. - [ ] During a multi-minute Ask wait, AI pane scrolls progress with model/endpoint, elapsed vs timeout %, and rotating hints (not only a bare “thinking…” line). -- [ ] Chatty model wrapping JSON in prose still parses via `extract_json_object` when a single object is present. +- [ ] Chatty model wrapping JSON in prose still parses via `extract_json_object` when a single object is present. Unparseable Ask replies print as errors (not assistant turns). - [ ] `#` / `@@` edit accepting `content` alias when `file` is missing still shows diff confirm (diff visible in the right pane; confirm split does not cover it). +- [ ] Gemini `type = "google"` `#` edits return `file` / `files` (schema allows those keys; Ask still uses message+command). +- [ ] `#dir/` edit that omits some pinned files warns which paths were skipped before confirm. - [ ] Apply writes `.file..wezai.bak`; `backup.enabled = false` skips bak and Undo still works; `backup.dir` relocates bak files; `backup.dotfile = false` drops the leading dot. --- @@ -546,10 +589,11 @@ WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scannin | Install version label | `plugin/version.lua`, `plugin/util.lua` (`version_label` / `brand_with_version` / `sync_plugin_git`) | | Settings / env overlay | `plugin/settings.lua` (`wezai.env`, `WEZAI_*`) | | Providers | `plugin/providers/` | -| Git / Kube / Terraform catalogs | `plugin/git.lua`, `plugin/kube.lua`, `plugin/tf.lua` | +| Git / Kube / Terraform / Docker catalogs | `plugin/git.lua`, `plugin/kube.lua`, `plugin/tf.lua`, `plugin/docker.lua` | | Weather / zip overlay | `plugin/weather.lua` (`set_zip`, `resolved_location`, `collect_attach`) | | Kube ns / kubectl bin / attach | `plugin/kube.lua` (`resolve_namespace`, `kubectl_bin`, `collect_attach`) | | Terraform bin / attach / AI | `plugin/tf.lua` (`terraform_bin`, `collect_attach`, `generate`/`debug`) | +| Docker bin / attach / AI | `plugin/docker.lua` (`docker_bin`, `collect_attach`, `diagnose`) | | Fuzzy files | `plugin/files.lua` | | Usage DB | `plugin/stats.lua` | | User docs | `README.md`, `GUIDE.md` | diff --git a/plugin/composer.py b/plugin/composer.py index 1f4cceb..bc8b1ea 100755 --- a/plugin/composer.py +++ b/plugin/composer.py @@ -24,6 +24,8 @@ "git:", "kube:", "tf:", + "docker:", + "weather:", "history", "clipboard", "selection", @@ -35,6 +37,8 @@ "kube", "tf", "terraform", + "docker", + "weather", "history", "clipboard", "selection", @@ -378,6 +382,8 @@ def is_reserved_path(pathpart: str) -> bool: "kube", "tf", "terraform", + "docker", + "weather", "dir", ): if pathpart == head or pathpart.startswith(head + ":") or pathpart.startswith(head + "/"): @@ -685,6 +691,9 @@ def _self_test() -> int: assert any(rel.startswith("README") or "AGENTS" in rel for _, rel, _ in m2) or True assert is_reserved_path("git") assert is_reserved_path("git:status") + assert is_reserved_path("docker") + assert is_reserved_path("docker:ps") + assert is_reserved_path("weather:now") assert not is_reserved_path("init.lua") assert not is_reserved_path("plugin/") buf = list("see @in") diff --git a/plugin/context.lua b/plugin/context.lua index fd4f54f..3badc6e 100644 --- a/plugin/context.lua +++ b/plugin/context.lua @@ -54,6 +54,8 @@ local function is_reserved_ref(raw) or raw:match("^kube:") ~= nil or raw:match("^tf:") ~= nil or raw:match("^terraform:") ~= nil + or raw:match("^docker:") ~= nil + or raw == "docker" or raw == "weather" or raw:match("^weather:") ~= nil or raw:match("^dir:") ~= nil @@ -138,6 +140,12 @@ function M.parse_at_refs(line) end return "synthetic", raw end + if raw:match("^docker:") then + return "synthetic", raw + end + if raw == "docker" then + return "synthetic", "docker:ps" + end if raw == "weather" or raw:match("^weather:") then if raw == "weather" then return "synthetic", "weather:now" @@ -604,6 +612,25 @@ local function resolve_synthetics(synthetics, window, pane, cwd, config) table.insert(labels, "@" .. syn) end end + elseif syn:match("^docker:") then + local dok, dmod = pcall(require, "docker") + if not dok then + table.insert(errors, "@" .. syn .. " failed: docker module not loaded (update wezai plugin)") + else + local content, derr = dmod.collect_attach(syn, cwd, config) + if derr and derr ~= "" then + table.insert(errors, "@" .. syn .. " failed: " .. derr) + elseif content == nil then + table.insert(errors, "@" .. syn .. " failed: empty attach") + else + table.insert(blocks, { + label = "Docker " .. (syn:match("^docker:(.+)$") or syn), + path = "@" .. syn, + content = content, + }) + table.insert(labels, "@" .. syn) + end + end elseif syn == "weather" or syn:match("^weather:") then local wok, wmod = pcall(require, "weather") if not wok then diff --git a/plugin/docker.lua b/plugin/docker.lua new file mode 100644 index 0000000..12e2342 --- /dev/null +++ b/plugin/docker.lua @@ -0,0 +1,722 @@ +-- @docker catalog for wezai — local containers and compose. +-- Show/shell actions are no-model; AI helpers diagnose from ps/logs/selection. +-- Mutating restart/rm/compose-down confirm first. +local wezterm = require("wezterm") +local act = wezterm.action +local util = require("util") +local ui = require("ui") +local shell = require("shell") + +local M = {} + +-- Wired by init.lua +M._ask = nil +M._dispatch = nil + +local MAX_ATTACH = 80000 + +local function trim(s) + return (s or ""):match("^%s*(.-)%s*$") or "" +end + +local function docker_opts(config) + local d = (config and config.docker) or {} + return { + docker = d.docker, + confirm_mutate = d.confirm_mutate ~= false, + max_attach_bytes = d.max_attach_bytes or MAX_ATTACH, + } +end + +local function cap(text, max_bytes) + text = text or "" + max_bytes = max_bytes or MAX_ATTACH + if #text <= max_bytes then + return text + end + return text:sub(1, max_bytes) .. "\n… (truncated)" +end + +local function redact(text) + return require("context").redact(text) +end + +-- Cached absolute docker for WezTerm's process (GUI PATH is often incomplete). +local _docker_resolved = nil + +local function home_dir() + return wezterm.home_dir or os.getenv("HOME") or "" +end + +function M.docker_bin(config) + local override = docker_opts(config).docker + if override and override ~= "" then + return override + end + if _docker_resolved then + return _docker_resolved + end + local home = home_dir() + local found = util.resolve_executable("docker", { + candidates = { + "/opt/homebrew/bin/docker", + "/usr/local/bin/docker", + "/usr/bin/docker", + home .. "/.local/bin/docker", + home .. "/.docker/bin/docker", + home .. "/bin/docker", + }, + login_shell = true, + }) + _docker_resolved = found or "docker" + return _docker_resolved +end + +local function docker_run(config, args) + local cmd = { M.docker_bin(config) } + for _, a in ipairs(args) do + table.insert(cmd, a) + end + return util.run_cmd(cmd) +end + +local function compose_run(config, cwd, args) + local cmd = { M.docker_bin(config), "compose", "--project-directory", cwd } + for _, a in ipairs(args) do + table.insert(cmd, a) + end + return util.run_cmd(cmd) +end + +function M.ensure_cwd(pane) + local cwd = util.get_pane_cwd(pane) + if not cwd then + return nil, "no pane cwd (open a shell where compose files live)" + end + return cwd, nil +end + +local function print_show(ai_pane, title, body) + ui.ai_print(ai_pane, title, "attach") + ui.ai_print(ai_pane, body ~= "" and body or "(empty)", "plain") +end + +local function prompt_line(window, pane, description, callback) + window:perform_action( + act.PromptInputLine({ + description = description, + action = wezterm.action_callback(function(win, p, line) + if line == nil then + return + end + callback(win, p, trim(line)) + end), + }), + pane + ) +end + +local function run_in_shell(window, shell_pane, ai_pane, config, command, opts) + opts = opts or {} + local execute = opts.execute ~= false + local force_confirm = opts.confirm == true + + local function send(skip_risk) + shell.send_command(window, shell_pane, ai_pane, config, command, function(sent) + if sent and execute then + shell_pane:send_text("\r") + ui.ai_print(ai_pane, "Ran: " .. util.truncate(command, 120), "success") + elseif sent then + ui.ai_print(ai_pane, "Inserted: " .. util.truncate(command, 120), "success") + end + end, { skip_risk_confirm = skip_risk }) + end + + if force_confirm then + ui.ai_print(ai_pane, "About to run:\n" .. command, "warn") + ui.confirm(window, shell_pane, "Run docker: " .. util.truncate(command, 50) .. " ?", "run", function(_, _, yes) + if yes then + send(true) + else + ui.ai_print(ai_pane, "Cancelled.", "warn") + end + end) + else + send(false) + end +end + +local function still_needs_placeholder(cmd) + return cmd:find("<[%w%-]+>", 1) ~= nil +end + +local function fill_placeholder(cmd, token, value) + if not value or value == "" then + return cmd + end + return cmd:gsub("<" .. token:gsub("%-", "%%-") .. ">", value) +end + +--- Fill remaining placeholders via prompts, then insert/run. +local function finalize_command(ctx, template, opts) + opts = opts or {} + local extra = trim(ctx.extra or "") + local cmd = template + local mutate = opts.mutate == true + local confirm = mutate and docker_opts(ctx.config).confirm_mutate + + if extra ~= "" then + if cmd:find("", 1, true) then + cmd = fill_placeholder(cmd, "container", extra) + elseif cmd:find("", 1, true) then + cmd = fill_placeholder(cmd, "image", extra) + elseif cmd:find("", 1, true) then + cmd = fill_placeholder(cmd, "service", extra) + end + end + + local function after_filled(final_cmd) + if still_needs_placeholder(final_cmd) then + ui.ai_print(ctx.ai_pane, "Command still has placeholders — edit then Enter:\n" .. final_cmd, "warn") + run_in_shell(ctx.window, ctx.pane, ctx.ai_pane, ctx.config, final_cmd, { + execute = false, + confirm = false, + }) + return + end + run_in_shell(ctx.window, ctx.pane, ctx.ai_pane, ctx.config, final_cmd, { + execute = opts.execute ~= false, + confirm = confirm, + }) + end + + local function step_image(c) + if c:find("", 1, true) then + prompt_line(ctx.window, ctx.pane, "Image name (e.g. nginx:latest)", function(_, _, image) + if image == "" then + return + end + after_filled(fill_placeholder(c, "image", image)) + end) + return + end + after_filled(c) + end + + local function step_service(c) + if c:find("", 1, true) then + prompt_line(ctx.window, ctx.pane, "Compose service name", function(_, _, service) + if service == "" then + return + end + after_filled(fill_placeholder(c, "service", service)) + end) + return + end + step_image(c) + end + + if cmd:find("", 1, true) then + prompt_line(ctx.window, ctx.pane, "Container name or id", function(_, _, name) + if name == "" then + return + end + step_service(fill_placeholder(cmd, "container", name)) + end) + return + end + step_service(cmd) +end + +local function show_cmd(ctx, title, args) + local ok, stdout, stderr = docker_run(ctx.config, args) + local body = ok and stdout or (stderr ~= "" and stderr or stdout) + if ok and trim(body) == "" then + body = "(no containers / empty output)" + end + print_show(ctx.ai_pane, title, cap(body, docker_opts(ctx.config).max_attach_bytes)) +end + +local function show_compose(ctx, title, args) + local ok, stdout, stderr = compose_run(ctx.config, ctx.cwd, args) + local body = ok and stdout or (stderr ~= "" and stderr or stdout) + if ok and trim(body) == "" then + body = "(no compose services in " .. ctx.cwd .. ")" + end + print_show(ctx.ai_pane, title .. " (cwd=" .. ctx.cwd .. ")", cap(body, docker_opts(ctx.config).max_attach_bytes)) +end + +local function ask_ai(ctx, prompt, user_text) + if M._ask then + M._ask(ctx.window, ctx.pane, ctx.config, prompt, user_text) + else + ui.ai_print(ctx.ai_pane, "Docker AI hook not wired.", "error") + end +end + +local function positive_count(raw) + local n = tonumber(raw) + if n and n == math.floor(n) and n >= 1 then + return n + end + return nil +end + +-- Peel trailing N from logs200 / logs-f50 / compose-logs100. +local function split_tail_id(id) + id = id or "" + for _, base in ipairs({ "compose-logs", "logs-f", "logs" }) do + local n = id:match("^" .. base:gsub("%-", "%%-") .. "(%d+)$") + if n then + return base, tonumber(n) + end + end + return id, nil +end + +local function logs_tail(ctx, default_n) + if ctx.count then + local n = positive_count(ctx.count) + if n then + return n + end + end + local n = positive_count(ctx.extra) + if n then + return n + end + return default_n +end + +-- --- Attach collectors (shared with context.lua) --- + +function M.collect_ps(config, all) + local args = { "ps", "--format", "table" } + if all then + args = { "ps", "-a", "--format", "table" } + end + local ok, stdout, stderr = docker_run(config, args) + if not ok then + return nil, stderr ~= "" and stderr or stdout + end + return stdout ~= "" and stdout or "(no containers)", nil +end + +function M.collect_images(config) + local ok, stdout, stderr = docker_run(config, { "images", "--format", "table" }) + if not ok then + return nil, stderr ~= "" and stderr or stdout + end + return stdout ~= "" and stdout or "(no images)", nil +end + +function M.collect_compose_ps(cwd, config) + local ok, stdout, stderr = compose_run(config, cwd, { "ps" }) + if not ok then + return nil, stderr ~= "" and stderr or stdout + end + return stdout ~= "" and stdout or "(no compose services)", nil +end + +function M.collect_df(config) + local ok, stdout, stderr = docker_run(config, { "system", "df" }) + if not ok then + return nil, stderr ~= "" and stderr or stdout + end + return stdout ~= "" and stdout or "(empty)", nil +end + +function M.collect_attach(syn, cwd, config) + local raw = syn:match("^docker:(.+)$") or syn + local id = trim(raw) + local maxb = docker_opts(config).max_attach_bytes + if id == "ps" then + local body, err = M.collect_ps(config, false) + return body and cap(body, maxb) or nil, err + elseif id == "ps-a" or id == "psa" then + local body, err = M.collect_ps(config, true) + return body and cap(body, maxb) or nil, err + elseif id == "images" then + local body, err = M.collect_images(config) + return body and cap(body, maxb) or nil, err + elseif id == "compose-ps" or id == "compose" then + if not cwd then + return nil, "compose attach needs pane cwd" + end + local body, err = M.collect_compose_ps(cwd, config) + return body and cap(body, maxb) or nil, err + elseif id == "df" or id == "system-df" then + local body, err = M.collect_df(config) + return body and cap(body, maxb) or nil, err + end + return nil, "unknown @docker attach: " .. tostring(syn) +end + +-- --- Actions --- + +local ACTIONS = {} +local ACTION_ORDER = {} + +local function add(a) + ACTIONS[a.id] = a + table.insert(ACTION_ORDER, a.id) + if a.aliases then + for _, al in ipairs(a.aliases) do + ACTIONS[al] = a + end + end +end + +add({ + id = "ps", + kind = "show", + label = "docker ps (running)", + attach = true, + run = function(ctx) + show_cmd(ctx, "@docker:ps", { "ps", "--format", "table" }) + end, +}) + +add({ + id = "ps-a", + kind = "show", + label = "docker ps -a (all)", + aliases = { "psa" }, + attach = true, + run = function(ctx) + show_cmd(ctx, "@docker:ps-a", { "ps", "-a", "--format", "table" }) + end, +}) + +add({ + id = "images", + kind = "show", + label = "docker images", + attach = true, + run = function(ctx) + show_cmd(ctx, "@docker:images", { "images", "--format", "table" }) + end, +}) + +add({ + id = "compose-ps", + kind = "show", + label = "docker compose ps", + aliases = { "compose" }, + attach = true, + run = function(ctx) + show_compose(ctx, "@docker:compose-ps", { "ps" }) + end, +}) + +add({ + id = "df", + kind = "show", + label = "docker system df", + aliases = { "system-df" }, + attach = true, + run = function(ctx) + show_cmd(ctx, "@docker:df", { "system", "df" }) + end, +}) + +add({ + id = "logs", + kind = "shell", + label = "logsN — --tail=N (default 200)", + run = function(ctx) + local tail = logs_tail(ctx, 200) + local extra = trim(ctx.extra or "") + if positive_count(extra) then + ctx.extra = "" + end + finalize_command(ctx, "docker logs --tail=" .. tail .. " ", { execute = true }) + end, +}) + +add({ + id = "logs-f", + kind = "shell", + label = "logs-fN — follow --tail=N (default 100)", + aliases = { "logs-follow" }, + run = function(ctx) + local tail = logs_tail(ctx, 100) + local extra = trim(ctx.extra or "") + if positive_count(extra) then + ctx.extra = "" + end + finalize_command(ctx, "docker logs -f --tail=" .. tail .. " ", { execute = true }) + end, +}) + +add({ + id = "exec", + kind = "shell", + label = "exec -it sh", + run = function(ctx) + finalize_command(ctx, "docker exec -it sh", { execute = true }) + end, +}) + +add({ + id = "compose-up", + kind = "shell", + label = "docker compose up -d", + run = function(ctx) + run_in_shell(ctx.window, ctx.pane, ctx.ai_pane, ctx.config, "docker compose up -d", { execute = true }) + end, +}) + +add({ + id = "compose-down", + kind = "shell", + label = "docker compose down (confirm)", + run = function(ctx) + run_in_shell(ctx.window, ctx.pane, ctx.ai_pane, ctx.config, "docker compose down", { + execute = true, + confirm = docker_opts(ctx.config).confirm_mutate, + }) + end, +}) + +add({ + id = "compose-logs", + kind = "shell", + label = "compose-logsN — compose logs --tail=N (default 200)", + run = function(ctx) + local tail = logs_tail(ctx, 200) + local extra = trim(ctx.extra or "") + if positive_count(extra) then + extra = "" + end + local cmd = "docker compose logs --tail=" .. tail + if extra ~= "" then + cmd = cmd .. " " .. extra + end + run_in_shell(ctx.window, ctx.pane, ctx.ai_pane, ctx.config, cmd, { execute = true }) + end, +}) + +add({ + id = "pull", + kind = "shell", + label = "docker pull ", + run = function(ctx) + finalize_command(ctx, "docker pull ", { execute = true }) + end, +}) + +add({ + id = "restart", + kind = "shell", + label = "docker restart (confirm)", + run = function(ctx) + finalize_command(ctx, "docker restart ", { execute = true, mutate = true }) + end, +}) + +add({ + id = "rm", + kind = "shell", + label = "docker rm (confirm)", + run = function(ctx) + finalize_command(ctx, "docker rm ", { execute = true, mutate = true }) + end, +}) + +add({ + id = "diagnose", + kind = "ai", + label = "diagnose running containers / compose", + run = function(ctx) + local ps_body, ps_err = M.collect_ps(ctx.config, true) + local compose_body = nil + if ctx.cwd then + local body, err = M.collect_compose_ps(ctx.cwd, ctx.config) + compose_body = (err and err ~= "") and err or body + end + local sel = util.get_selection(ctx.window, ctx.pane) + local extra = trim(ctx.extra or "") + local prompt = "Diagnose these Docker / Compose containers. " + .. "Prefer read-only next steps (docker ps, logs, compose ps, inspect). " + .. "Never bake hostnames into commands. Use placeholders like / . " + .. "Set command to a single safe next step or null.\n\n=== docker ps -a ===\n```\n" + .. redact(cap((ps_err and ps_err ~= "") and ps_err or (ps_body or ""), 40000)) + .. "\n```" + if compose_body and compose_body ~= "" then + prompt = prompt + .. "\n\n=== docker compose ps (" + .. ctx.cwd + .. ") ===\n```\n" + .. redact(cap(compose_body, 20000)) + .. "\n```" + end + if sel and sel ~= "" then + prompt = prompt .. "\n\n=== selection ===\n```\n" .. redact(cap(sel, 12000)) .. "\n```" + end + if extra ~= "" then + prompt = prompt .. "\n\nUser instruction: " .. extra + end + ask_ai(ctx, prompt, extra ~= "" and extra or "docker:diagnose") + end, +}) + +add({ + id = "explain-sel", + kind = "ai", + label = "explain selected docker output", + aliases = { "explain" }, + run = function(ctx) + local sel = util.get_selection(ctx.window, ctx.pane) + if not sel or trim(sel) == "" then + ui.ai_print(ctx.ai_pane, "Select docker/compose output first, then @docker:explain-sel", "error") + return + end + local extra = trim(ctx.extra or "") + local prompt = "Explain this Docker / Compose output. Prefer diagnosis when it looks like an error. " + .. "Set command to a single safe next step (logs/ps/inspect) or null.\n\n```\n" + .. redact(cap(sel, 40000)) + .. "\n```" + if extra ~= "" then + prompt = prompt .. "\n\nUser instruction: " .. extra + end + ask_ai(ctx, prompt, extra ~= "" and extra or "docker:explain-sel") + end, +}) + +function M.list_actions() + local list = {} + for _, id in ipairs(ACTION_ORDER) do + local a = ACTIONS[id] + if a and a.id == id then + table.insert(list, a) + end + end + return list +end + +function M.get_action(id) + local a = ACTIONS[id] + if a then + return a + end + local base = select(1, split_tail_id(id)) + if base ~= id then + return ACTIONS[base] + end + return nil +end + +-- Parse ask-line for docker intercept. +-- Returns nil (not docker), or { mode="picker"|"run"|"attach", id?, extra?, count? } +function M.parse_line(line) + local token = trim(line or "") + if token == "@docker" or token == "@docker:" then + return { mode = "picker" } + end + local rest_only = token:match("^@docker%s+(.+)$") + if rest_only then + return { mode = "attach", id = "ps" } + end + local id, rest = token:match("^@docker:([%w%-]+)%s*(.-)%s*$") + if not id then + return nil + end + local embedded_n + id, embedded_n = split_tail_id(id) + local action = ACTIONS[id] + if not action then + return nil + end + rest = trim(rest or "") + local count = embedded_n + if not count and (id == "logs" or id == "logs-f" or id == "compose-logs") and positive_count(rest) then + count = positive_count(rest) + rest = "" + end + if rest == "" and not count then + return { mode = "run", id = action.id } + end + if action.kind == "ai" then + local out = { mode = "run", id = action.id } + if rest ~= "" then + out.extra = rest + end + return out + end + if (action.attach or action.kind == "show") and rest ~= "" then + return { mode = "attach", id = action.id } + end + local out = { mode = "run", id = action.id } + if rest ~= "" then + out.extra = rest + end + if count then + out.count = count + end + return out +end + +function M.run_action(window, pane, config, id, extra, opts) + opts = opts or {} + local shell_pane = ui.shell_pane_for(window, pane) + local ai_pane = ui.ensure_ai_pane(window, shell_pane, config) + local cwd = util.get_pane_cwd(shell_pane) + local embedded_n + id, embedded_n = split_tail_id(id) + local action = ACTIONS[id] + if not action then + ui.ai_print(ai_pane, "Unknown @docker:" .. tostring(id) .. " — try @docker for the picker", "error") + return + end + local count = opts.count or embedded_n + local ctx = { + window = window, + pane = shell_pane, + ai_pane = ai_pane, + config = config, + cwd = cwd, + extra = extra, + count = count, + } + local function go() + if action.kind == "show" or action.kind == "ai" then + local bin = M.docker_bin(config) + local ok, _, verr = util.run_cmd({ bin, "version", "--format", "{{.Client.Version}}" }) + if not ok then + ui.ai_print( + ai_pane, + "docker not available (" + .. tostring(bin) + .. "): " + .. tostring(verr) + .. "\nSet docker.docker = \"/absolute/path/to/docker\" in wezai config if needed.", + "error" + ) + return + end + end + if (action.id == "compose-ps" or action.id == "compose-up" or action.id == "compose-down" or action.id == "compose-logs") + and not cwd + then + ui.ai_print(ai_pane, "no pane cwd (open a shell in a compose project)", "error") + return + end + action.run(ctx) + end + if action.kind == "show" then + ui.with_busy(ai_pane, { + title = "docker", + command = "@docker:" .. action.id, + config = config, + }, go) + return + end + go() +end + +function M.open_picker(window, pane, config) + require("palette").show(window, pane, config, { scope = "docker" }) +end + +return M diff --git a/plugin/edit.lua b/plugin/edit.lua index 085b000..e14c367 100644 --- a/plugin/edit.lua +++ b/plugin/edit.lua @@ -444,6 +444,12 @@ local function show_diff_then_confirm(window, shell_pane, ai_pane, config, path, end, }) if not opened then + wezterm.log_warn("wezai: confirm pane unavailable; falling back to overlay selector") + ui.ai_print( + ai_pane, + "Confirm pane needs python3 + plugin/confirm.py — using overlay. See GUIDE.md Troubleshooting.", + "warn" + ) open_overlay_fallback() end end @@ -509,7 +515,19 @@ function M.confirm_and_apply_many(window, shell_pane, ai_pane, config, changes, for _, c in ipairs(changes) do local ok, err, backup, prior = M.apply_file_edit(c.path, c.new_content, config) if not ok then + local done = #items + local remain = #changes - done ui.ai_print(ai_pane, err or ("write failed: " .. tostring(c.path)), "error") + ui.ai_print( + ai_pane, + string.format( + "Stopped after saving %d of %d files (%d remaining not written). Palette → Undo last edit restores the files that were saved.", + done, + #changes, + remain + ), + "warn" + ) if #items > 0 then session.set_last_edit_batch(window, items) end @@ -565,6 +583,12 @@ function M.confirm_and_apply_many(window, shell_pane, ai_pane, config, changes, end, }) if not opened then + wezterm.log_warn("wezai: confirm pane unavailable; falling back to overlay selector") + ui.ai_print( + ai_pane, + "Confirm pane needs python3 + plugin/confirm.py — using overlay. See GUIDE.md Troubleshooting.", + "warn" + ) open_overlay_fallback() end end diff --git a/plugin/history.lua b/plugin/history.lua index 8aceef8..cba234e 100644 --- a/plugin/history.lua +++ b/plugin/history.lua @@ -133,6 +133,11 @@ function M.detect_kind(pane) return shell.detect_shell(pane) end +--- Fish / zsh / bash histfiles only. PowerShell and others fall back to scrollback. +function M.histfile_kind_supported(kind) + return kind == "fish" or kind == "zsh" or kind == "bash" +end + local function pane_pid(pane) if not pane then return nil @@ -889,26 +894,14 @@ function M.delete_command(window, pane, config, cmd) if kind ~= "fish" and kind ~= "zsh" and kind ~= "bash" then os.remove(del_path) - local removed_any = 0 - for _, k in ipairs({ "fish", "zsh", "bash" }) do - local path = first_existing(histfile_paths(k, penv)) - if path then - local ok, removed = lua_rewrite_histfile(k, path, cmd) - if ok and removed and removed > 0 then - removed_any = removed_any + removed - end - end - end - if removed_any < 1 then - return false, "command not found in history files (shell=" .. tostring(kind) .. ")" - end ui.ai_print( ai_pane, - "Removed from history file(s). Reload the shell to drop the in-memory copy: " - .. shorten(cmd, 80), - "success" + "History delete is not supported for " + .. tostring(kind) + .. " (fish/zsh/bash histfiles only). See GUIDE.md Troubleshooting.", + "error" ) - return true, nil + return false, "unsupported shell" end if kind == "fish" then @@ -1171,7 +1164,11 @@ function M.show_actions(window, pane, config, entry) table.insert(choices, { id = "explain", label = "Explain — ask AI about this" }) table.insert(choices, { id = "attach", label = "Attach & ask — use as context in a new prompt" }) table.insert(choices, { id = "copy", label = "Copy — clipboard" }) - if entry.kind ~= "edit" and entry.text and trim(entry.text) ~= "" then + if entry.kind ~= "edit" + and entry.text + and trim(entry.text) ~= "" + and M.histfile_kind_supported(kind) + then table.insert(choices, { id = "delete", label = "Delete — remove from " .. kind .. " history (all copies)", diff --git a/plugin/init.lua b/plugin/init.lua index 3074957..6ff9388 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -20,6 +20,7 @@ do "kube.lua", "tf.lua", "weather.lua", + "docker.lua", "history.lua", "history_store.lua", "files.lua", @@ -133,11 +134,13 @@ do package.path = table.concat({ package.path, root .. "?.lua", root .. "?/init.lua" }, ";") local has_tf = file_exists(root .. "tf.lua") local has_weather = file_exists(root .. "weather.lua") + local has_docker = file_exists(root .. "docker.lua") wezterm.log_info( "wezai: load path " .. root .. (has_tf and " (tf.lua ok)" or " (tf.lua MISSING — palette → Update wezai plugin, then reload)") .. (has_weather and " (weather.lua ok)" or " (weather.lua MISSING)") + .. (has_docker and " (docker.lua ok)" or " (docker.lua MISSING)") ) discovered_plugin_dir = root discovered_repo_dir = chosen and chosen.repo @@ -236,6 +239,44 @@ do end end +-- Soft-load @docker so a stale install cannot take down the whole plugin. +local docker +do + local ok, mod = pcall(require, "docker") + if ok then + docker = mod + else + wezterm.log_warn( + "wezai: @docker catalog disabled — " + .. tostring(mod) + .. " (sync plugin: wezterm.plugin.update_all() or update your local require path)" + ) + docker = { + list_actions = function() + return {} + end, + parse_line = function() + return nil + end, + run_action = function(window, pane, config) + local ai = ui.ensure_ai_pane(window, pane, config) + ui.ai_print( + ai, + "@docker unavailable — plugin missing docker.lua. Run wezterm.plugin.update_all() then reload config.", + "error" + ) + end, + collect_attach = function() + return nil, "docker module not loaded" + end, + open_picker = function(window, pane, config) + local ai = ui.ensure_ai_pane(window, pane, config) + ui.ai_print(ai, "@docker unavailable — update the wezai plugin cache.", "error") + end, + } + end +end + local palette = require("palette") local files = require("files") local composer = require("composer") @@ -252,14 +293,16 @@ local function note_usage(ai_pane, config, meta) ui.ai_print(ai_pane, stats.format_turn(meta, db), "status") end +--- Parse Ask JSON. On failure return nil so callers do not treat the blob as +--- an assistant turn (no session history, no command send). local function parse_ai_response(response) local json, err = util.parse_json_response(response) if not json then local cleaned = util.clean_response(response) wezterm.log_error("wezai: Failed to parse JSON response: ", cleaned) - return { message = "❌ Error parsing AI response \r\n" .. cleaned, command = nil } + return nil, err or "Failed to parse JSON response", cleaned end - return json + return json, nil, nil end local function with_dialect(config, pane) @@ -329,7 +372,17 @@ local function handle_ai_request(window, shell_pane, prompt, config, opts) note_usage(ai_pane, config, meta) - local response = parse_ai_response(stdout) + local response, parse_err, cleaned = parse_ai_response(stdout) + if not response then + ui.ai_print( + ai_pane, + "Failed to parse JSON response (need a JSON object with \"message\").\n" + .. (cleaned or parse_err or ""), + "error" + ) + return + end + if response.message and response.message ~= "" then ui.ai_print(ai_pane, response.message, "message") session.add_turn(window, "assistant", context.redact(response.message), config.chat_max_turns) @@ -372,8 +425,11 @@ local function collect_edit_changes(response, request) local changes = {} local used = {} + local skipped_empty = {} + local unmatched = {} local function add(path, content) if type(content) ~= "string" or content == "" then + skipped_empty[#skipped_empty + 1] = path or "(no path)" return end local t = (path and by_path[path]) or nil @@ -384,7 +440,11 @@ local function collect_edit_changes(response, request) if not t and #targets == 1 then t = targets[1] end - if not t or not t.path or used[t.path] then + if not t or not t.path then + unmatched[#unmatched + 1] = path or "(no path)" + return + end + if used[t.path] then return end used[t.path] = true @@ -429,7 +489,18 @@ local function collect_edit_changes(response, request) add(request.target_path, new_content) end end - return changes + local missing = {} + for _, t in ipairs(targets) do + if t.path and not used[t.path] then + missing[#missing + 1] = t.path + end + end + return changes, { + missing = missing, + unmatched = unmatched, + empty = skipped_empty, + target_count = #targets, + } end local function handle_edit_request(window, shell_pane, request, config) @@ -472,14 +543,39 @@ local function handle_edit_request(window, shell_pane, request, config) end -- Prefer "file" / "files"; accept common aliases models invent despite the prompt. - local changes = collect_edit_changes(response, request) + local changes, info = collect_edit_changes(response, request) if #changes == 0 then - ui.ai_print(ai_pane, "Edit response missing non-empty file contents", "error") + ui.ai_print( + ai_pane, + "Edit response missing non-empty file contents (looked for \"file\", \"content\", \"new_content\", and \"files\"[].path + content).", + "error" + ) + if info and info.unmatched and #info.unmatched > 0 then + ui.ai_print(ai_pane, "Unmatched model paths: " .. table.concat(info.unmatched, ", "), "warn") + end + if info and info.empty and #info.empty > 0 then + ui.ai_print(ai_pane, "Empty bodies for: " .. table.concat(info.empty, ", "), "warn") + end if response.message then ui.ai_print(ai_pane, tostring(response.message), "message") end return end + if info and info.missing and #info.missing > 0 then + ui.ai_print( + ai_pane, + "Model omitted " + .. tostring(#info.missing) + .. " of " + .. tostring(info.target_count) + .. " edit targets:\n" + .. table.concat(info.missing, "\n"), + "warn" + ) + end + if info and info.unmatched and #info.unmatched > 0 then + ui.ai_print(ai_pane, "Ignored extra model paths: " .. table.concat(info.unmatched, ", "), "warn") + end local apply_cb = function(applied) if applied and response.message then @@ -619,11 +715,11 @@ local function prompt_for_ai(window, pane, config, opts) .. util.truncate(selected_file) .. "\n---" elseif selection then - description = "wezai — selection sticky until Compact; @pick / #file / @git / @kube / @tf / @weather\n---\n" + description = "wezai — selection sticky until Compact; @pick / #file / @git / @kube / @tf / @docker / @weather\n---\n" .. util.truncate(selection) .. "\n---" else - description = "wezai — @ attach # edit · @git / @weather / @history · compact / clear · palette CTRL+SHIFT+P" + description = "wezai — @ attach # edit · @git / @docker / @weather / @history · compact / clear · palette CTRL+SHIFT+P" if share_pane then description = description .. " · sharing pane history" end @@ -727,6 +823,17 @@ local function prompt_for_ai(window, pane, config, opts) end end + local docker_ref = docker.parse_line(line) + if docker_ref then + if docker_ref.mode == "picker" then + palette.show(win, p, config, { scope = "docker" }) + return + elseif docker_ref.mode == "run" then + docker.run_action(win, p, config, docker_ref.id, docker_ref.extra, { count = docker_ref.count }) + return + end + end + local function run_prepared(req_line) local ai_pane = ui.ensure_ai_pane(win, p, config) local busy @@ -736,6 +843,7 @@ local function prompt_for_ai(window, pane, config, opts) or req_line:find("@git", 1, true) or req_line:find("@kube", 1, true) or req_line:find("@tf", 1, true) + or req_line:find("@docker", 1, true) ) then busy = ui.start_busy(ai_pane, { @@ -898,6 +1006,14 @@ local function prompt_for_ai(window, pane, config, opts) return end wezterm.log_warn("wezai: composer unavailable (" .. tostring(err) .. "); falling back to overlay prompt") + local ai_pane = ui.ensure_ai_pane(window, pane, config) + ui.ai_print( + ai_pane, + "Composer needs python3 + plugin/composer.py (" + .. tostring(err) + .. "). Using overlay prompt. See GUIDE.md Troubleshooting.", + "warn" + ) end open_overlay_prompt() end @@ -998,6 +1114,14 @@ tf._dispatch = function(win, p, request, config) dispatch_request(win, p, request, config, {}) end +docker._ask = function(win, p, config, prompt, user_text) + handle_ai_request(win, p, prompt, config, { user_text = user_text or "docker" }) +end + +docker._dispatch = function(win, p, request, config) + dispatch_request(win, p, request, config, {}) +end + palette.handlers = { ask = function(win, p, cfg) prompt_for_ai(win, p, cfg, { share_pane = false }) @@ -1282,6 +1406,16 @@ local function apply_to_config(wezterm_config, user_config) ) end + if type(config.keybinding_docker) == "table" and config.keybinding_docker.key then + bind_key( + config.keybinding_docker.key, + config.keybinding_docker.mods, + wezterm.action_callback(function(window, pane) + palette.show(window, pane, config, { scope = "docker" }) + end) + ) + end + wezterm.log_info( util.brand_with_version() .. " loaded model=" diff --git a/plugin/palette.lua b/plugin/palette.lua index 5bcefd3..46c8880 100644 --- a/plugin/palette.lua +++ b/plugin/palette.lua @@ -37,6 +37,22 @@ do end end +local docker +do + local ok, mod = pcall(require, "docker") + if ok then + docker = mod + else + wezterm.log_warn("wezai: palette @docker disabled — " .. tostring(mod)) + docker = { + list_actions = function() + return {} + end, + run_action = function() end, + } + end +end + local M = {} -- Set by init.lua: functions(win, pane, config) @@ -55,7 +71,7 @@ local function add(choices, handlers, id, label, fn) handlers[id] = fn end --- scope: nil (all) | "git" | "kube" | "tf" | "weather" | "history" | "history:…" +-- scope: nil (all) | "git" | "kube" | "tf" | "weather" | "docker" | "history" | "history:…" function M.show(window, pane, config, opts) opts = opts or {} local scope = opts.scope @@ -72,6 +88,7 @@ function M.show(window, pane, config, opts) local include_kube = (scope == nil or scope == "kube") local include_tf = (scope == nil or scope == "tf") local include_weather = (scope == nil or scope == "weather") + local include_docker = (scope == nil or scope == "docker") local hist_filter = nil if scope == "history" then hist_filter = "all" @@ -204,12 +221,44 @@ function M.show(window, pane, config, opts) end end + if include_docker then + for _, a in ipairs(docker.list_actions()) do + local kind = a.kind == "ai" and "ai" or (a.kind == "show" and "show" or "run") + local label = "@docker:" .. a.id .. " [" .. kind .. "] " .. a.label + local action_id = a.id + add(choices, handlers, "docker:" .. action_id, label, function(win, p, cfg) + docker.run_action(win, p, cfg, action_id, nil) + end) + end + end + local hist_scoped = scope and tostring(scope):find("^history") ~= nil if hist_filter then if hist_scoped then add(choices, handlers, "hist:search", "@history Search entire history…", function(win, p, cfg) history.prompt_search(win, p, cfg) end) + local kind = history.detect_kind(pane) + if not history.histfile_kind_supported(kind) then + add( + choices, + handlers, + "hist:unsupported", + "@history " + .. kind + .. " — histfile search/delete not supported (fish/zsh/bash only)", + function(win, p, cfg) + local ai = ui.ensure_ai_pane(win, p, cfg) + ui.ai_print( + ai, + "wezai @history indexes fish/zsh/bash histfiles. Detected shell is " + .. kind + .. ". Scrollback and session events still appear below. See GUIDE.md Troubleshooting.", + "warn" + ) + end + ) + end end local cap = hist_cap(config, hist_scoped) local cok, entries_or_err = pcall(history.collect_entries, window, pane, config, hist_filter, { @@ -250,11 +299,17 @@ function M.show(window, pane, config, opts) title = brand .. " · @tf (type to filter)" elseif scope == "weather" then title = brand .. " · @weather (type to filter)" + elseif scope == "docker" then + title = brand .. " · @docker (type to filter)" elseif scope and tostring(scope):find("^history") then local kind = history.detect_kind(pane) - title = brand .. " · @history · " .. kind .. " (type to filter)" + if history.histfile_kind_supported(kind) then + title = brand .. " · @history · " .. kind .. " (type to filter)" + else + title = brand .. " · @history · " .. kind .. " (histfile unsupported)" + end else - title = brand .. " · type @git / @kube / @tf / @weather / @history / Ask…" + title = brand .. " · type @git / @kube / @tf / @docker / @weather / @history / Ask…" end -- Open selector on the current pane (do not create AI pane first — that steals focus) diff --git a/plugin/providers/gemini_api.lua b/plugin/providers/gemini_api.lua index 1d2bbd2..7c864d8 100644 --- a/plugin/providers/gemini_api.lua +++ b/plugin/providers/gemini_api.lua @@ -19,11 +19,43 @@ local function json_object_schema(fields, required) } end +-- Ask: message + optional command. Edit also needs file / files or Gemini +-- will refuse those keys (responseSchema is a closed allow-list). +local ASK_SCHEMA = json_object_schema({ "message", "command" }, { "message" }) + +local FILE_ITEM_SCHEMA = { + type = "OBJECT", + properties = { + path = { type = "STRING" }, + file = { type = "STRING" }, + content = { type = "STRING" }, + new_content = { type = "STRING" }, + }, +} + +local EDIT_SCHEMA = { + type = "OBJECT", + properties = { + message = { type = "STRING" }, + command = { type = "STRING" }, + file = { type = "STRING" }, + content = { type = "STRING" }, + new_content = { type = "STRING" }, + files = { type = "ARRAY", items = FILE_ITEM_SCHEMA }, + }, + required = { "message" }, +} + +local function is_edit_prompt(sys) + return type(sys) == "string" and sys:find("You create or rewrite one or more files", 1, true) ~= nil +end + local function endpoint(model, key) return string.format("%s/%s:generateContent?key=%s", HOST, model, key) end local function request_body(cfg, user_text) + local sys = cfg.system_instruction or cfg.system_prompt local body = { contents = { { @@ -33,10 +65,9 @@ local function request_body(cfg, user_text) }, generationConfig = { responseMimeType = "application/json", - responseSchema = json_object_schema({ "message", "command" }, { "message" }), + responseSchema = is_edit_prompt(sys) and EDIT_SCHEMA or ASK_SCHEMA, }, } - local sys = cfg.system_instruction or cfg.system_prompt if type(sys) == "string" and sys ~= "" then body.systemInstruction = { parts = { { text = sys } } } end diff --git a/plugin/providers/ollama_bin.lua b/plugin/providers/ollama_bin.lua index b69d5e9..a47ae55 100644 --- a/plugin/providers/ollama_bin.lua +++ b/plugin/providers/ollama_bin.lua @@ -37,8 +37,10 @@ function B.ask(cfg, user_text) end function B.ready(cfg) - if type(cfg.ollama_path) ~= "string" or cfg.ollama_path == "" then - wezterm.log_error("wezai/ollama: set ollama_path to the ollama binary") + -- ask() uses PATH "ollama" when ollama_path is unset — same default here + -- so apply_to_config still binds keys. + if not select(1, proc.require_nonempty(cfg, "model")) then + wezterm.log_error("wezai/ollama: model required") return false end return true diff --git a/plugin/settings.lua b/plugin/settings.lua index edb4078..5b16883 100644 --- a/plugin/settings.lua +++ b/plugin/settings.lua @@ -30,6 +30,8 @@ local BASE = { keybinding_tf = { key = "t", mods = "CTRL|ALT" }, -- CTRL|SHIFT+W is WezTerm's CloseCurrentTab — use CTRL|ALT+W for @weather. keybinding_weather = { key = "w", mods = "CTRL|ALT" }, + -- CTRL|SHIFT+D is unused by WezTerm defaults (not SpawnTab / CloseCurrentTab). + keybinding_docker = { key = "d", mods = "CTRL|SHIFT" }, -- Shell dialect (fish/zsh/bash/…) is appended automatically per request. system_prompt = "You are a concise terminal assistant. Provide direct commands or brief explanations. " .. "Warn of dangerous commands. Avoid unnecessary verbosity. Prefer interactive commands that " @@ -97,6 +99,11 @@ local BASE = { confirm_mutate = true, max_attach_bytes = 80000, }, + docker = { + docker = nil, -- absolute path; nil → auto-resolve (GUI PATH is often incomplete) + confirm_mutate = true, + max_attach_bytes = 80000, + }, weather = { zip = nil, -- e.g. "90210"; plugin @weather:zip overrides via ~/.local/share/wezai/weather.json country = "US", -- ISO 3166-1 alpha-2 for geocoding @@ -124,6 +131,7 @@ local NESTED = { git = true, kube = true, tf = true, + docker = true, weather = true, stats = true, files = true, @@ -144,6 +152,7 @@ local ENV_SPEC = { { env = "WEZAI_LMS_PATH", key = "lms_path" }, { env = "WEZAI_KUBE_NS", nested = "kube", key = "namespace" }, { env = "WEZAI_KUBE_KUBECTL", nested = "kube", key = "kubectl" }, + { env = "WEZAI_DOCKER_BIN", nested = "docker", key = "docker" }, { env = "WEZAI_WEATHER_ZIP", nested = "weather", key = "zip" }, { env = "WEZAI_WEATHER_COUNTRY", nested = "weather", key = "country" }, { env = "WEZAI_WEATHER_UNITS", nested = "weather", key = "units" }, diff --git a/plugin/shell.lua b/plugin/shell.lua index df67b53..0f8090e 100644 --- a/plugin/shell.lua +++ b/plugin/shell.lua @@ -29,6 +29,11 @@ local RISK_PATTERNS = { "terraform%s+force%-unlock", "terraform%s+state%s+rm", "terraform%s+import", + "docker%s+rm%s", + "docker%s+rmi", + "docker%s+compose%s+down", + "docker%s+system%s+prune", + "docker%s+volume%s+rm", "chmod%s+%-R%s+777", ">:?/dev/sd", "curl%s+[^\n]*%|%s*sh", diff --git a/plugin/ui.lua b/plugin/ui.lua index afbe7cc..69f5d65 100644 --- a/plugin/ui.lua +++ b/plugin/ui.lua @@ -58,7 +58,7 @@ local function keep_alive_args(pad) "sh", "-c", string.format( - "printf '\\r\\n%s%s%s%s — output pane\\r\\n%s%sFollow up: CTRL+i%s (composer keeps this pane visible) · %sCTRL+SHIFT+P%s command palette\\r\\n%sType @ to attach, # to edit · Compact/Clear · @git / @weather in the palette\\r\\n\\r\\n'; " + "printf '\\r\\n%s%s%s%s — output pane\\r\\n%s%sFollow up: CTRL+i%s (composer keeps this pane visible) · %sCTRL+SHIFT+P%s command palette\\r\\n%sType @ to attach, # to edit · Compact/Clear · @git / @docker / @weather in the palette\\r\\n\\r\\n'; " .. "WEZAI_OUTPUT_PANE=1; while true; do sleep 86400; done", indent, BOLD .. CYAN, diff --git a/scripts/test_json_extract.py b/scripts/test_json_extract.py new file mode 100644 index 0000000..fa573e6 --- /dev/null +++ b/scripts/test_json_extract.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Regression checks for util.extract_json_object (mirrors plugin/util.lua).""" + +from __future__ import annotations + +import json +import sys + + +def extract_json_object(s: str | None) -> str | None: + if not isinstance(s, str): + return None + start = s.find("{") + if start < 0: + return None + depth = 0 + in_str = False + escape = False + for i in range(start, len(s)): + c = s[i] + if in_str: + if escape: + escape = False + elif c == "\\": + escape = True + elif c == '"': + in_str = False + else: + if c == '"': + in_str = True + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return s[start : i + 1] + return None + + +def main() -> int: + failed = 0 + + def expect(cond: bool, msg: str) -> None: + nonlocal failed + if not cond: + failed += 1 + print("FAIL:", msg) + + raw = 'Sure.\n{"message":"hi","command":null}\nThanks' + blob = extract_json_object(raw) + expect(blob is not None, "prose wrapper extracted") + obj = json.loads(blob or "") + expect(obj.get("message") == "hi", "message from wrapper") + expect(obj.get("command") is None, "null command") + + gemini_edit = '{"message":"sorted","file":"a\\nb\\n","command":""}' + gblob = extract_json_object(gemini_edit) + expect(gblob == gemini_edit, "raw object is identity") + gobj = json.loads(gblob or "") + expect("file" in gobj and "sorted" in gobj["message"], "gemini-shape file field") + + nested = 'prefix {"message":"ok","files":[{"path":"/tmp/a","content":"x"}]} suffix' + nblob = extract_json_object(nested) + expect(nblob is not None, "nested files array extracted") + nobj = json.loads(nblob or "") + expect(nobj["files"][0]["path"] == "/tmp/a", "files[].path") + + braced_string = '{"message":"use {braces} in text","command":null}' + sblob = extract_json_object("note " + braced_string) + expect(sblob == braced_string, "braces inside strings ignored") + + expect(extract_json_object("no object here") is None, "missing object") + expect(extract_json_object("{unterminated") is None, "unterminated") + + if failed: + print(f"{failed} json extract cases failed") + return 1 + print("extract_json_object cases ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_parse_refs.py b/scripts/test_parse_refs.py index da43736..cab3637 100644 --- a/scripts/test_parse_refs.py +++ b/scripts/test_parse_refs.py @@ -19,7 +19,7 @@ def is_reserved(raw: str) -> bool: if raw in RESERVED: return True - for p in ("history:", "git:", "kube:", "tf:", "terraform:", "dir:"): + for p in ("history:", "git:", "kube:", "tf:", "terraform:", "docker:", "weather:", "dir:"): if raw.startswith(p) or raw == p[:-1]: return True return False @@ -67,6 +67,14 @@ def classify(raw: str): if raw.startswith("terraform:"): return "synthetic", "tf:" + raw[len("terraform:") :] return "synthetic", raw + if raw == "docker" or raw.startswith("docker:"): + if raw == "docker": + return "synthetic", "docker:ps" + return "synthetic", raw + if raw == "weather" or raw.startswith("weather:"): + if raw == "weather": + return "synthetic", "weather:now" + return "synthetic", raw if raw.startswith("dir:"): return "synthetic", raw return "path", raw @@ -127,6 +135,9 @@ def main() -> int: ("@a.lua #b.lua add tests", ["a.lua"], ["b.lua"], [], "add tests"), ("# heading with space", [], [], [], "# heading with space"), ("@git:status what should I commit?", [], [], ["git:status"], "what should I commit?"), + ("@docker:ps what's running?", [], [], ["docker:ps"], "what's running?"), + ("@docker should I restart?", [], [], ["docker:ps"], "should I restart?"), + ("@weather:now jacket?", [], [], ["weather:now"], "jacket?"), ('@"path with spaces" explain', ["path with spaces"], [], [], "explain"), ("plain question", [], [], [], "plain question"), ] diff --git a/scripts/test_settings.lua b/scripts/test_settings.lua index d4917f5..83cea34 100644 --- a/scripts/test_settings.lua +++ b/scripts/test_settings.lua @@ -44,6 +44,7 @@ local overlay = settings.config_from_env_map({ WEZAI_WEATHER_ZIP = "02139", WEZAI_WEATHER_COUNTRY = "US", WEZAI_KUBE_NS = "kube-system", + WEZAI_DOCKER_BIN = "/usr/bin/docker", OPENAI_API_KEY = "from-openai", }) eq(overlay.type, "http", "type") @@ -52,6 +53,7 @@ eq(overlay.timeout, 450, "timeout number") eq(overlay.api_key, "from-openai", "openai fallback") eq(overlay.weather.zip, "02139", "weather zip") eq(overlay.kube.namespace, "kube-system", "kube ns") +eq(overlay.docker.docker, "/usr/bin/docker", "docker bin") eq(#overlay.models, 3, "models csv count") eq(overlay.models[2], "b", "models csv trim") @@ -88,6 +90,8 @@ local base = settings.finalize(nil, { skip_live_env = true }) eq(base.model, "llama3.2", "default model") eq(base.timeout, 300, "default timeout") eq(base.weather.country, "US", "default weather country") +eq(base.keybinding_docker.key, "d", "CTRL+SHIFT+D docker") +eq(base.docker.confirm_mutate, true, "docker confirm default") -- process-like map wins over file text when passed as env_map after env_text local stacked = settings.finalize(nil, { diff --git a/wezai.env.example b/wezai.env.example index a9a9b63..f05aae4 100644 --- a/wezai.env.example +++ b/wezai.env.example @@ -24,6 +24,9 @@ # WEZAI_KUBE_NS=default +# Optional absolute docker if GUI PATH can't find it +# WEZAI_DOCKER_BIN=/usr/bin/docker + # Seed @weather until you run @weather:zip (saved under ~/.local/share/wezai/weather.json). # WEZAI_WEATHER_ZIP=90210 # WEZAI_WEATHER_COUNTRY=US