diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cb8dc8..309e83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,5 @@ jobs: run: | nvim --headless --clean --cmd "set rtp+=$(pwd)" \ -c "lua require('byteask').setup({})" \ - -c "lua for _,n in ipairs({'ByteAsk','ByteAskToggle','ByteAskExec','ByteAskReview','ByteAskApply','ByteAskResume','ByteAskFork','ByteAskFixDiagnostics'}) do assert(vim.fn.exists(':'..n)==2, n..' not registered') end; print('ALL_COMMANDS_OK')" \ + -c "lua for _,n in ipairs({'ByteAsk','ByteAskToggle','ByteAskExec','ByteAskReview','ByteAskApply','ByteAskResume','ByteAskFork','ByteAskFixDiagnostics','ByteAskAgents','ByteAskSessions','ByteAskModel'}) do assert(vim.fn.exists(':'..n)==2, n..' not registered') end; print('ALL_COMMANDS_OK')" \ -c "qa!" diff --git a/AGENTS.md b/AGENTS.md index 91bfdcc..7ff4a38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,10 @@ byteask doctor # health check ``` Commands: `:ByteAsk [prompt]`, `:ByteAskExec {instr}` (range-aware — visual -selection becomes context), `:ByteAskReview`, `:ByteAskApply`, -`:ByteAskFixDiagnostics`, `:ByteAskResume[!]`, `:ByteAskFork[!]`. Full list and -config options in `README.md`. +selection becomes context), `:ByteAskReview` (scoped: uncommitted/base/commit/ +repo), `:ByteAskApply`, `:ByteAskFixDiagnostics`, `:ByteAskResume[!]`, +`:ByteAskFork[!]`, `:ByteAskAgents`, `:ByteAskSessions`, `:ByteAskModel[!]`. +Full list and config options in `README.md`. ## If you're cutting a release @@ -67,7 +68,7 @@ gh release view vX.Y.Z --repo ByteAsk/ByteAsk.nvim `.github/workflows/ci.yml` runs three jobs: `stylua --check .` (formatting), `luacheck lua/ plugin/ --globals vim` (lint), and a headless Neovim load-test -that asserts all 8 `:ByteAsk*` commands register. Run these locally before +that asserts all 11 `:ByteAsk*` commands register. Run these locally before pushing to avoid a red CI: ```bash @@ -75,7 +76,7 @@ stylua . # auto-fix formatting luacheck lua/ plugin/ --globals vim # lint (brew install luarocks; luarocks install luacheck) nvim --headless --clean --cmd "set rtp+=$(pwd)" \ -c "lua require('byteask').setup({})" \ - -c "lua for _,n in ipairs({'ByteAsk','ByteAskToggle','ByteAskExec','ByteAskReview','ByteAskApply','ByteAskResume','ByteAskFork','ByteAskFixDiagnostics'}) do assert(vim.fn.exists(':'..n)==2, n..' not registered') end; print('ALL_COMMANDS_OK')" \ + -c "lua for _,n in ipairs({'ByteAsk','ByteAskToggle','ByteAskExec','ByteAskReview','ByteAskApply','ByteAskResume','ByteAskFork','ByteAskFixDiagnostics','ByteAskAgents','ByteAskSessions','ByteAskModel'}) do assert(vim.fn.exists(':'..n)==2, n..' not registered') end; print('ALL_COMMANDS_OK')" \ -c "qa!" ``` diff --git a/README.md b/README.md index f49e428..6e441fa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,12 @@ This plugin drives the `byteask` CLI from inside Neovim: scratch buffer and can apply the produced diff straight to your working tree. - **Context sends** — pipe the current selection, file, or LSP diagnostics into a one-shot `exec` (e.g. "fix these compiler errors"). -- **Session control** — resume/fork previous sessions. +- **Scoped review** — uncommitted changes, a base branch, a specific commit, or the + whole repo, matching what `byteask review` actually supports. +- **AGENTS.md steering** — jump straight to (or scaffold) the file ByteAsk reads for + repo-local instructions. +- **Full session lifecycle** — resume/fork, plus archive/unarchive/delete. +- **Per-invocation model/config overrides** without editing your Lua config. - `:checkhealth byteask` and a lualine statusline component. > Inspired by [`codex.nvim`](https://github.com/johnseth97/codex.nvim), rebuilt for @@ -40,7 +45,10 @@ This plugin drives the `byteask` CLI from inside Neovim: { 'bx', ':ByteAskExec ', mode = 'n', desc = 'ByteAsk exec' }, { 'bx', ":'<,'>ByteAskExec ", mode = 'v', desc = 'ByteAsk exec (selection)' }, { 'bd', function() require('byteask').fix_diagnostics() end, desc = 'ByteAsk: fix diagnostics' }, - { 'br', function() require('byteask').review() end, desc = 'ByteAsk review' }, + { 'br', function() require('byteask').review() end, desc = 'ByteAsk review (scoped)' }, + { 'ba', function() require('byteask').agents() end, desc = 'ByteAsk: open/create AGENTS.md' }, + { 'bs', function() require('byteask').sessions() end, desc = 'ByteAsk: sessions (archive/unarchive/delete)' }, + { 'bm', function() require('byteask').model() end, desc = 'ByteAsk: set model/config override' }, }, opts = { layout = 'float', -- 'float' | 'panel' | 'tab' @@ -69,10 +77,13 @@ use { | `:ByteAskToggle` | Toggle the ByteAsk window (session survives when hidden) | | `:ByteAskExec {instr}` | Headless `exec`. In visual mode, sends the selection as context | | `:ByteAskFixDiagnostics` | Send current-buffer LSP/compiler diagnostics to `exec` for a fix | -| `:ByteAskReview` | Run `byteask review` on the repository | +| `:ByteAskReview` | Run `byteask review`, scoped via a prompt: uncommitted / base branch / commit / whole repo | | `:ByteAskApply` | Apply the latest agent diff to the working tree (`byteask apply`) | | `:ByteAskResume[!]` | Resume a session (picker; `!` = most recent) | | `:ByteAskFork[!]` | Fork a session (picker; `!` = most recent) | +| `:ByteAskAgents` | Open the nearest `AGENTS.md` (walks up from the current file), or offer to create one | +| `:ByteAskSessions` | Browse all sessions, or archive/unarchive/delete one by id or name | +| `:ByteAskModel[!]` | Set a model / `-c` override for future invocations (`!` clears it) | Inside the ByteAsk window, `` hides it (the session keeps running in the background — reopen with `:ByteAsk`). @@ -120,6 +131,13 @@ require('lualine').setup({ `jobstart`, streaming stdout/stderr into the `byteask://output` scratch buffer. - `resume`/`fork` re-launch the TUI with the corresponding subcommand (model/config overrides are omitted so the restored session keeps its own recorded settings). +- `:ByteAskAgents` never spawns `byteask` — it just opens/creates the file on disk; + ByteAsk picks it up itself on the next turn. +- `:ByteAskSessions`'s "browse all" delegates to `byteask resume --all`'s own picker; + archive/unarchive/delete shell the matching subcommand directly. +- `:ByteAskModel` stores a transient override (session-lifetime, not persisted to + disk) that `apply_common_flags` layers on top of your static `setup()` config for + every subsequent invocation, until cleared with `:ByteAskModel!`. ## Roadmap diff --git a/lua/byteask/commands.lua b/lua/byteask/commands.lua index 0cd6ca7..269fafb 100644 --- a/lua/byteask/commands.lua +++ b/lua/byteask/commands.lua @@ -153,12 +153,212 @@ function M.exec(prompt, extra_context) run_headless(argv, { title = 'exec', apply_after = config.options.auto_apply }) end ---- `byteask review` against the current repository. -function M.review() - local argv = util.build_subcommand({ 'review' }) +local function run_review(flags) + local argv = util.build_subcommand({ 'review' }, flags) run_headless(argv, { title = 'review' }) end +--- `byteask review`, scoped to match what the CLI actually supports +--- (`--uncommitted` / `--base ` / `--commit ` / whole repo). +--- With no scope, prompts via vim.ui.select. +---@param scope string|nil one of 'uncommitted' | 'base' | 'commit' | 'repo' +function M.review(scope) + if not scope then + vim.ui.select( + { 'Uncommitted changes', 'Against base branch', 'Specific commit', 'Whole repo' }, + { prompt = 'ByteAsk review scope:' }, + function(choice) + if not choice then + return + end + local scope_of = { + ['Uncommitted changes'] = 'uncommitted', + ['Against base branch'] = 'base', + ['Specific commit'] = 'commit', + ['Whole repo'] = 'repo', + } + M.review(scope_of[choice]) + end + ) + return + end + + if scope == 'uncommitted' then + run_review({ '--uncommitted' }) + elseif scope == 'base' then + vim.ui.input({ prompt = 'Base branch: ', default = 'main' }, function(branch) + if branch and branch ~= '' then + run_review({ '--base', branch }) + end + end) + elseif scope == 'commit' then + vim.ui.input({ prompt = 'Commit SHA: ' }, function(sha) + if sha and sha ~= '' then + run_review({ '--commit', sha }) + end + end) + else + run_review({}) + end +end + +--- Locate (or offer to create) the nearest AGENTS.md, walking up from the +--- current buffer's directory. Pure editor action: no byteask process is +--- involved, since AGENTS.md is picked up by the agent on its next turn. +function M.agents() + local abs = vim.api.nvim_buf_get_name(0) + local start_dir = (abs and abs ~= '') and vim.fn.fnamemodify(abs, ':p:h') or vim.loop.cwd() + local found = util.find_upward(start_dir, 'AGENTS.md') + if found then + vim.cmd('edit ' .. vim.fn.fnameescape(found)) + return + end + + local target = vim.loop.cwd() .. '/AGENTS.md' + vim.ui.select({ 'Yes', 'No' }, { + prompt = 'No AGENTS.md found. Create one at ' .. vim.fn.fnamemodify(target, ':.') .. '?', + }, function(choice) + if choice ~= 'Yes' then + return + end + vim.fn.writefile({ + '# AGENTS.md', + '', + '', + '', + '', + }, target) + vim.cmd('edit ' .. vim.fn.fnameescape(target)) + end) +end + +--- Session lifecycle beyond resume/fork: browse all sessions (delegates to +--- byteask's own picker), or archive/unarchive/delete by id or name. +function M.sessions() + vim.ui.select( + { 'Browse all sessions', 'Archive a session', 'Unarchive a session', 'Delete a session' }, + { prompt = 'ByteAsk sessions:' }, + function(choice) + if not choice then + return + end + if choice == 'Browse all sessions' then + local argv = util.base_cmd() + argv[#argv + 1] = 'resume' + argv[#argv + 1] = '--all' + require('byteask.terminal').open_argv(argv) + return + end + + local subcommand = ({ + ['Archive a session'] = 'archive', + ['Unarchive a session'] = 'unarchive', + ['Delete a session'] = 'delete', + })[choice] + + vim.ui.input({ prompt = 'Session id or name to ' .. subcommand .. ': ' }, function(id) + if not id or id == '' then + return + end + -- No common flags: archive/unarchive/delete only take a session id, + -- same precedent as M.apply ("No common flags: apply only performs a + -- git-apply and rejects model/-c flags") — so this builds off + -- base_cmd(), not build_subcommand(). + local argv = util.base_cmd() + argv[#argv + 1] = subcommand + argv[#argv + 1] = id + local stderr_lines = {} + vim.fn.jobstart(argv, { + cwd = vim.loop.cwd(), + on_stderr = function(_, data) + for _, line in ipairs(data or {}) do + if line ~= '' then + stderr_lines[#stderr_lines + 1] = line + end + end + end, + on_exit = function(_, code) + vim.schedule(function() + if code == 0 then + vim.notify('ByteAsk: ' .. subcommand .. 'd session ' .. id .. '.', vim.log.levels.INFO) + else + local detail = #stderr_lines > 0 and (': ' .. table.concat(stderr_lines, ' ')) or '' + vim.notify( + 'ByteAsk ' .. subcommand .. ' failed (code ' .. code .. ')' .. detail .. '.', + vim.log.levels.ERROR + ) + end + end) + end, + }) + end) + end + ) +end + +--- Interactively set (or, with clear=true, reset) the model / -c overrides +--- used by every subsequent invocation. Persists for the nvim session; falls +--- back to config.lua defaults when left blank or cleared. +---@param clear boolean|nil +function M.model(clear) + if clear then + config.clear_override() + vim.notify('ByteAsk: cleared model/config overrides.', vim.log.levels.INFO) + return + end + + local current_model = config.override.model or config.options.model or '' + vim.ui.input({ + prompt = 'ByteAsk model (blank = default' + .. (current_model ~= '' and (', current: ' .. current_model) or '') + .. '): ', + default = current_model, + }, function(model) + if model == nil then + return -- cancelled: leave the override untouched + end + + -- Commit the model right away so cancelling the (optional) -c prompt + -- below can't silently discard it; the existing config override carries + -- over unless the next prompt explicitly replaces it. + config.set_override({ model = model ~= '' and model or nil, config = config.override.config }) + + local current_cfg = {} + for key, value in pairs(config.override.config or {}) do + current_cfg[#current_cfg + 1] = string.format('%s=%s', key, tostring(value)) + end + table.sort(current_cfg) + + vim.ui.input({ + prompt = 'ByteAsk -c overrides, comma-separated key=value (blank = none): ', + default = table.concat(current_cfg, ','), + }, function(cfg_str) + if cfg_str == nil then + vim.notify('ByteAsk: model override set; config overrides left unchanged.', vim.log.levels.INFO) + return -- cancelled: keep the config override as it was + end + local cfg = {} + if cfg_str ~= '' then + for pair in cfg_str:gmatch('[^,]+') do + local key, value = pair:match('^%s*([%w_%.%-]+)%s*=%s*(.-)%s*$') + if key and value then + cfg[key] = value + else + vim.notify( + "ByteAsk: ignoring malformed -c entry '" .. pair .. "' (expected key=value).", + vim.log.levels.WARN + ) + end + end + end + -- Replaces the config override wholesale (blank clears it entirely) — + -- see config.set_override's docstring for why this can't be a merge. + config.set_override({ model = config.override.model, config = cfg }) + vim.notify('ByteAsk: model/config override set (use :ByteAskModel! to clear).', vim.log.levels.INFO) + end) + end) +end + --- Ask ByteAsk to fix the current buffer's diagnostics via exec. function M.fix_diagnostics() local diags = util.diagnostics_block(0) diff --git a/lua/byteask/config.lua b/lua/byteask/config.lua index bd4c1bb..1a7980e 100644 --- a/lua/byteask/config.lua +++ b/lua/byteask/config.lua @@ -68,4 +68,26 @@ function M.setup(user) return M.options end +-- Transient model/-c override, set via :ByteAskModel and consulted by +-- util.apply_common_flags on top of M.options. Session-lifetime, not +-- persisted; cleared with :ByteAskModel!. +M.override = { model = nil, config = {} } + +--- Replace the active override wholesale — NOT a merge. `model = nil` means +--- "no model override" and `config = {}`/nil means "no config overrides": +--- each :ByteAskModel invocation fully restates the desired override, so a +--- blank field genuinely clears it. (A deep-merge here would make a blank +--- field unable to ever clear a previously-set value, since a nil table +--- field is simply absent from the table rather than an explicit unset.) +---@param o table { model = string|nil, config = table|nil } +function M.set_override(o) + o = o or {} + M.override = { model = o.model, config = o.config or {} } +end + +--- Clear the transient override, falling back to M.options again. +function M.clear_override() + M.override = { model = nil, config = {} } +end + return M diff --git a/lua/byteask/init.lua b/lua/byteask/init.lua index 525a1e7..eac5057 100644 --- a/lua/byteask/init.lua +++ b/lua/byteask/init.lua @@ -26,8 +26,8 @@ end M.exec = function(prompt, ctx) commands.exec(prompt, ctx) end -M.review = function() - commands.review() +M.review = function(scope) + commands.review(scope) end M.apply = function() commands.apply() @@ -41,6 +41,15 @@ end M.fork = function(last) commands.fork(last) end +M.agents = function() + commands.agents() +end +M.sessions = function() + commands.sessions() +end +M.model = function(clear) + commands.model(clear) +end -- ── Statusline helper (lualine-friendly) ─────────────────────────────────── function M.statusline() @@ -95,8 +104,8 @@ function M._register_commands() end, { nargs = '*', range = true, desc = 'Run byteask exec (headless); range = send selection as context' }) cmd('ByteAskReview', function() - commands.review() - end, { desc = 'Run byteask review on the repository' }) + commands.review() -- no scope: prompts (uncommitted / base branch / commit / whole repo) + end, { desc = 'Run byteask review, scoped via a prompt' }) cmd('ByteAskApply', function() commands.apply() end, { desc = 'Apply the latest ByteAsk diff to the working tree' }) @@ -111,6 +120,18 @@ function M._register_commands() cmd('ByteAskFork', function(a) commands.fork(a.bang) end, { bang = true, desc = 'Fork a ByteAsk session (! = most recent)' }) + + cmd('ByteAskAgents', function() + commands.agents() + end, { desc = 'Open (or create) the nearest AGENTS.md to steer ByteAsk' }) + + cmd('ByteAskSessions', function() + commands.sessions() + end, { desc = 'Browse, archive, unarchive, or delete ByteAsk sessions' }) + + cmd('ByteAskModel', function(a) + commands.model(a.bang) -- :ByteAskModel! clears the override + end, { bang = true, desc = 'Set (or ! = clear) a model/-c override for future ByteAsk invocations' }) end local function register_keymaps() diff --git a/lua/byteask/util.lua b/lua/byteask/util.lua index f08544a..2c43c81 100644 --- a/lua/byteask/util.lua +++ b/lua/byteask/util.lua @@ -36,15 +36,23 @@ function M.is_installed() end --- Append the shared flags (model, -c overrides, extra args) to an argv list. +--- The transient override set via :ByteAskModel (config.override) wins over +--- the static M.options defaults: its model replaces opts.model when set, +--- and its config keys are layered on top of opts.config. ---@param argv string[] ---@return string[] argv (mutated + returned) function M.apply_common_flags(argv) local opts = config.options - if opts.model and opts.model ~= '' then + local override = config.override or {} + + local model = override.model or opts.model + if model and model ~= '' then argv[#argv + 1] = '-m' - argv[#argv + 1] = opts.model + argv[#argv + 1] = model end - for key, value in pairs(opts.config or {}) do + + local cfg = vim.tbl_extend('force', opts.config or {}, override.config or {}) + for key, value in pairs(cfg) do argv[#argv + 1] = '-c' -- Values are parsed as TOML by ByteAsk; strings pass through as literals. argv[#argv + 1] = string.format('%s=%s', key, tostring(value)) @@ -108,6 +116,27 @@ function M.visual_selection() return table.concat(lines, '\n') end +--- Walk upward from `start_dir` looking for `filename`, stopping at the +--- filesystem root. Used to locate the nearest AGENTS.md the way ByteAsk +--- itself resolves it (deeper files take precedence over shallower ones). +---@param start_dir string +---@param filename string +---@return string|nil absolute path if found +function M.find_upward(start_dir, filename) + local dir = vim.fn.fnamemodify(start_dir, ':p:h') + while true do + local candidate = dir .. '/' .. filename + if vim.fn.filereadable(candidate) == 1 then + return candidate + end + local parent = vim.fn.fnamemodify(dir, ':h') + if parent == dir then + return nil -- reached the filesystem root + end + dir = parent + end +end + --- Buffer diagnostics rendered as a compact, model-friendly block. ---@param bufnr integer|nil ---@return string