Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -67,15 +68,15 @@ 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
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!"
```

Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +45,10 @@ This plugin drives the `byteask` CLI from inside Neovim:
{ '<leader>bx', ':ByteAskExec ', mode = 'n', desc = 'ByteAsk exec' },
{ '<leader>bx', ":'<,'>ByteAskExec ", mode = 'v', desc = 'ByteAsk exec (selection)' },
{ '<leader>bd', function() require('byteask').fix_diagnostics() end, desc = 'ByteAsk: fix diagnostics' },
{ '<leader>br', function() require('byteask').review() end, desc = 'ByteAsk review' },
{ '<leader>br', function() require('byteask').review() end, desc = 'ByteAsk review (scoped)' },
{ '<leader>ba', function() require('byteask').agents() end, desc = 'ByteAsk: open/create AGENTS.md' },
{ '<leader>bs', function() require('byteask').sessions() end, desc = 'ByteAsk: sessions (archive/unarchive/delete)' },
{ '<leader>bm', function() require('byteask').model() end, desc = 'ByteAsk: set model/config override' },
},
opts = {
layout = 'float', -- 'float' | 'panel' | 'tab'
Expand Down Expand Up @@ -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, `<C-q>` hides it (the session keeps running in the
background — reopen with `:ByteAsk`).
Expand Down Expand Up @@ -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

Expand Down
206 changes: 203 additions & 3 deletions lua/byteask/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>` / `--commit <sha>` / 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',
'',
'<!-- Instructions/tips for ByteAsk when working in this repo or directory. -->',
'<!-- Deeper AGENTS.md files override this one; direct user instructions override both. -->',
'',
}, 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)
Expand Down
22 changes: 22 additions & 0 deletions lua/byteask/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 25 additions & 4 deletions lua/byteask/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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' })
Expand All @@ -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()
Expand Down
Loading
Loading