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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.3.2] - 2026-07-14

### Added

- Added the Loopbiotic logo, the “WHERE HUMAN IS IN THE LOOP” slogan, and an
animated workflow demo to the README.

### Fixed

- Reveal and draft retry no longer share `<leader>pr`; retry now defaults to
`<leader>pt`, hidden cards cannot trigger actions, and draft action cards stay
one row clear of the proposal cursor. Actions that the current card no longer
offers are also ignored, preventing duplicate requests after a session stops.

## [0.3.1] - 2026-07-14

### Fixed
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ members = [
resolver = "3"

[workspace.package]
version = "0.3.1"
version = "0.3.2"
edition = "2024"
authors = ["Dorian"]
license = "MIT"
Expand Down
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
<p align="center">
<img src="assets/loopbiotic.svg" width="96" alt="Loopbiotic logo">
</p>
<p align="center"><strong>WHERE HUMAN IS IN THE LOOP</strong></p>

Repo ready for desloppification. I had fun with vibing this project,
but because of growing size and complexity it's time take the steer.

# Loopbiotic

![Loopbiotic demo](assets/loopbiotic.gif)

Loopbiotic is an interactive pair-programming stepper for Neovim.

It is not a chat.
Expand Down Expand Up @@ -170,11 +177,13 @@ Next editable hunk
Repeat until completed-goal summary and local diagnostics check
```

Cards stay anchored beside the source line and do not take focus. Use `<leader>pg`
to jump to a finding or the first line of an inline draft, and `<leader>pr` to
focus the current Loopbiotic card. Long goals and draft explanations stay compact by
default; press `z` while the card is focused to expand or collapse their full
text (`keymaps.details` changes this key).
Cards stay anchored clear of the source line and do not take focus. Use `<leader>pg`
to jump to a finding or return to the active proposal, and `<leader>pr` to reveal or
focus the current Loopbiotic card. Draft retry uses `<leader>pt`; actions
are disabled while their card is hidden or no longer offers them. Long goals
and draft explanations stay compact by default; press `z` while the card is
focused to expand or collapse their full text (`keymaps.details` changes this
key).

For a goal patch, Loopbiotic validates every returned file against its live editor
buffer, queues the complete batch, and opens each location only when its hunk is
Expand Down
Binary file added assets/loopbiotic.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions assets/loopbiotic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions doc/loopbiotic.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ keeps that exact hunk pending and offers `Back to draft`.

Navigation stays inside the current workspace. A location-bearing card moves
the cursor to its evidence, while a draft moves it to the first non-blank
character of the first added line. The action card follows the active tab and
does not move the cursor back to an older window.
character of the first added line. Its action card sits above or below that
line with a one-row gap. The action card follows the active tab and does not
move the cursor back to an older window. Use `keymaps.go_to` (`<leader>pg` by
default) to return to the active proposal, `keymaps.resume` (`<leader>pr`) to
reveal its action card, and `keymaps.draft_retry` (`<leader>pt`) to retry.
Actions cannot be triggered while the card is hidden.
Actions that are absent from the current card are also ignored, so repeated
terminal keypresses do not send another request to a finished session.

Long goals and patch explanations are compact by default. Focus the Loopbiotic card
and press `z` to expand or collapse the full text. Configure this with
Expand Down
2 changes: 1 addition & 1 deletion lua/loopbiotic/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ M.values = {
details = "z",
draft_accept = "<leader>pa",
draft_reject = "<leader>pd",
draft_retry = "<leader>pr",
draft_retry = "<leader>pt",
},
prompt = {
border = "rounded",
Expand Down
24 changes: 21 additions & 3 deletions lua/loopbiotic/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,13 @@ function M.controls(card, opts)
local buf, win = ui.render(state.card_buf, state.card_win, lines, {
width = width,
height = height,
anchor = ui.buffer_anchor(state.diff_buf, (state.diff_first_row or 0) + 1, 0),
anchor = ui.buffer_anchor(
state.diff_buf,
(state.diff_cursor or { (state.diff_first_row or 0) + 1, 0 })[1],
(state.diff_cursor or { 1, 0 })[2]
),
anchor_gap = 1,
avoid_anchor_row = true,
enter = opts.enter == true,
title = " Loopbiotic: Draft ",
})
Expand Down Expand Up @@ -243,7 +249,7 @@ function M.control_lines(card, keys)
local label = state.details_expanded and "Collapse details" or "Expand details"
table.insert(lines, string.format("[%s] %s", keys.details or "z", label))
end
table.insert(lines, string.format("[%s] Go to change", keys.go_to))
table.insert(lines, string.format("[%s] Back to proposal", keys.go_to))
table.insert(lines, string.format("[%s] Accept [%s] Reject", keys.draft_accept, keys.draft_reject))
table.insert(lines, string.format("[%s] Why this hunk", keys.why or "w"))
table.insert(lines, string.format("[%s] Retry edit the draft directly", keys.draft_retry))
Expand Down Expand Up @@ -304,6 +310,10 @@ function M.one_line(text)
end

function M.accept()
if not require("loopbiotic").require_actions_visible() then
return
end

local card = state.card
local patch = card and (card.patches or {})[1]
local draft_buf = state.diff_buf
Expand All @@ -328,6 +338,10 @@ function M.accept()
end

function M.reject()
if not require("loopbiotic").require_actions_visible() then
return
end

local card = state.card
local patch = card and (card.patches or {})[1]

Expand All @@ -340,8 +354,12 @@ function M.reject()
end

function M.retry()
if not require("loopbiotic").require_actions_visible() then
return
end

M.restore_source()
require("loopbiotic").action("retry")
require("loopbiotic").action("retry", { allow_hidden = true })
end

function M.valid_preview()
Expand Down
48 changes: 46 additions & 2 deletions lua/loopbiotic/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ function M.reply_prompt()

return
end
if not M.require_actions_visible() then
return
end

prompt.reply()
end
Expand Down Expand Up @@ -142,12 +145,20 @@ function M.start(text, mode, source)
end)
end

function M.action(action)
function M.action(action, opts)
opts = opts or {}
if not state.session_id then
ui.notify("No active session", vim.log.levels.WARN)

return
end
if not opts.allow_hidden and not M.require_actions_visible() then
return
end
if not M.action_available(state.card, action) then
ui.notify("Action is not available on this Loopbiotic card", vim.log.levels.WARN)
return
end

if action == "why" then
local diff = require("loopbiotic.diff")
Expand Down Expand Up @@ -415,7 +426,7 @@ function M.open_and_retry()

ui.close(state.card_win)
state.card_win = nil
M.action("retry")
M.action("retry", { allow_hidden = true })
end

function M.go_to()
Expand Down Expand Up @@ -443,6 +454,39 @@ function M.go_to()
ui.notify("No Loopbiotic location to open", vim.log.levels.WARN)
end

function M.actions_visible()
return not state.thinking_request_id
and state.card_win
and vim.api.nvim_win_is_valid(state.card_win)
and vim.api.nvim_win_get_tabpage(state.card_win) == vim.api.nvim_get_current_tabpage()
end

function M.action_available(active_card, action)
if type(active_card) ~= "table" or type(action) ~= "string" then
return false
end

for _, available in ipairs(active_card.actions or active_card.next_actions or {}) do
if available == action or (action == "apply" and type(available) == "table") then
return true
end
end

return false
end

function M.require_actions_visible()
if M.actions_visible() then
return true
end

ui.notify(
"Loopbiotic actions are hidden; " .. tostring(config.values.keymaps.resume) .. " shows them",
vim.log.levels.WARN
)
return false
end

function M.hide()
if not state.session_id then
return
Expand Down
18 changes: 14 additions & 4 deletions lua/loopbiotic/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ function M.geometry(line_count, opts)
width = math.max(width, 1)
height = math.max(height, 1)

if opts.anchor and opts.avoid_anchor_row then
local gap = math.max(tonumber(opts.anchor_gap) or 0, 0)
local cursor_row = M.clamp((opts.anchor.row or 1) - 1, 0, viewport.height - 1)
local room_above = math.max(cursor_row - gap, 0)
local room_below = math.max(viewport.height - cursor_row - 1 - gap, 0)
local anchored_height = math.max(math.max(room_above, room_below) - border_size, 1)
height = math.min(height, anchored_height)
end

local default_row = math.floor((viewport.height - height - border_size) / 2)
local default_col = math.floor((viewport.width - width - border_size) / 2)
local max_row = math.max(viewport.height - height - border_size, 0)
Expand All @@ -129,7 +138,7 @@ function M.geometry(line_count, opts)
local col = M.number(opts.col)

if opts.anchor then
row, col = M.near(opts.anchor, width, height, border_size, viewport)
row, col = M.near(opts.anchor, width, height, border_size, viewport, opts.anchor_gap)
end

return {
Expand All @@ -140,16 +149,17 @@ function M.geometry(line_count, opts)
}
end

function M.near(anchor, width, height, border_size, viewport)
function M.near(anchor, width, height, border_size, viewport, gap)
local cursor_row = M.clamp((anchor.row or 1) - 1, 0, viewport.height - 1)
local cursor_col = M.clamp((anchor.col or 1) - 1, 0, viewport.width - 1)
local total_width = width + border_size
local total_height = height + border_size
local row = cursor_row + 1
gap = math.max(tonumber(gap) or 0, 0)
local row = cursor_row + 1 + gap
local col = cursor_col + 2

if row + total_height > viewport.height then
row = cursor_row - total_height
row = cursor_row - total_height - gap
end
if col + total_width > viewport.width then
col = cursor_col - total_width - 1
Expand Down
2 changes: 1 addition & 1 deletion lua/loopbiotic/version.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
return {
plugin = "0.3.1",
plugin = "0.3.2",
protocol = 8,
}
Loading