diff --git a/CHANGELOG.md b/CHANGELOG.md index c311e6b..79cc108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `pr`; retry now defaults to + `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 diff --git a/Cargo.lock b/Cargo.lock index 1ae4156..14e753e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,7 +368,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loopbiotic_backends" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "async-trait", @@ -381,14 +381,14 @@ dependencies = [ [[package]] name = "loopbiotic_context" -version = "0.3.1" +version = "0.3.2" dependencies = [ "loopbiotic_protocol", ] [[package]] name = "loopbiotic_harness" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "async-trait", @@ -404,7 +404,7 @@ dependencies = [ [[package]] name = "loopbiotic_patch" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "loopbiotic_protocol", @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "loopbiotic_protocol" -version = "0.3.1" +version = "0.3.2" dependencies = [ "serde", "serde_json", @@ -421,7 +421,7 @@ dependencies = [ [[package]] name = "loopbioticd" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "loopbiotic_backends", diff --git a/Cargo.toml b/Cargo.toml index 6ead85b..e7495ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.3.1" +version = "0.3.2" edition = "2024" authors = ["Dorian"] license = "MIT" diff --git a/README.md b/README.md index c10a3fb..984d131 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ +

+ Loopbiotic logo +

+

WHERE HUMAN IS IN THE LOOP

+ 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. @@ -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 `pg` -to jump to a finding or the first line of an inline draft, and `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 `pg` +to jump to a finding or return to the active proposal, and `pr` to reveal or +focus the current Loopbiotic card. Draft retry uses `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 diff --git a/assets/loopbiotic.gif b/assets/loopbiotic.gif new file mode 100644 index 0000000..2ba27a7 Binary files /dev/null and b/assets/loopbiotic.gif differ diff --git a/assets/loopbiotic.svg b/assets/loopbiotic.svg new file mode 100644 index 0000000..f022a6b --- /dev/null +++ b/assets/loopbiotic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/doc/loopbiotic.txt b/doc/loopbiotic.txt index c71bd36..23e8d5e 100644 --- a/doc/loopbiotic.txt +++ b/doc/loopbiotic.txt @@ -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` (`pg` by +default) to return to the active proposal, `keymaps.resume` (`pr`) to +reveal its action card, and `keymaps.draft_retry` (`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 diff --git a/lua/loopbiotic/config.lua b/lua/loopbiotic/config.lua index 9691d1e..8a9d773 100644 --- a/lua/loopbiotic/config.lua +++ b/lua/loopbiotic/config.lua @@ -77,7 +77,7 @@ M.values = { details = "z", draft_accept = "pa", draft_reject = "pd", - draft_retry = "pr", + draft_retry = "pt", }, prompt = { border = "rounded", diff --git a/lua/loopbiotic/diff.lua b/lua/loopbiotic/diff.lua index 5df5cf9..08713eb 100644 --- a/lua/loopbiotic/diff.lua +++ b/lua/loopbiotic/diff.lua @@ -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 ", }) @@ -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)) @@ -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 @@ -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] @@ -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() diff --git a/lua/loopbiotic/init.lua b/lua/loopbiotic/init.lua index 861c3cc..a33b6e5 100644 --- a/lua/loopbiotic/init.lua +++ b/lua/loopbiotic/init.lua @@ -84,6 +84,9 @@ function M.reply_prompt() return end + if not M.require_actions_visible() then + return + end prompt.reply() end @@ -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") @@ -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() @@ -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 diff --git a/lua/loopbiotic/ui.lua b/lua/loopbiotic/ui.lua index 229c745..4448b56 100644 --- a/lua/loopbiotic/ui.lua +++ b/lua/loopbiotic/ui.lua @@ -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) @@ -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 { @@ -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 diff --git a/lua/loopbiotic/version.lua b/lua/loopbiotic/version.lua index 62a6f0f..a0d1502 100644 --- a/lua/loopbiotic/version.lua +++ b/lua/loopbiotic/version.lua @@ -1,4 +1,4 @@ return { - plugin = "0.3.1", + plugin = "0.3.2", protocol = 8, } diff --git a/scripts/headless-smoke.lua b/scripts/headless-smoke.lua index dae281a..738c82d 100644 --- a/scripts/headless-smoke.lua +++ b/scripts/headless-smoke.lua @@ -15,6 +15,15 @@ require("loopbiotic").setup({ }) assert(config.values.backend.prefetch == "off") assert(config.values.backend.token_budget == 50000) +assert(config.values.keymaps.resume ~= config.values.keymaps.draft_retry) +assert(config.values.keymaps.resume == "pr") +assert(config.values.keymaps.draft_retry == "pt") + +local ui = require("loopbiotic.ui") +local below_row = select(1, ui.near({ row = 5, col = 20 }, 20, 4, 2, { width = 80, height = 20 }, 1)) +local above_row = select(1, ui.near({ row = 18, col = 20 }, 20, 4, 2, { width = 80, height = 20 }, 1)) +assert(below_row == 6) +assert(above_row == 10) local legacy_preferences = vim.fn.stdpath("state") .. "/pairagen/preferences.json" vim.fn.delete(config.preferences_path()) @@ -113,6 +122,7 @@ local focus_card = { kind = "patch", title = "Focus inserted text", explanation = "Keep the cursor on the change", + actions = { "apply", "why", "retry", "stop" }, patches = { { id = "focus-patch", @@ -121,12 +131,44 @@ local focus_card = { }, }, } +local loopbiotic = require("loopbiotic") +assert(loopbiotic.action_available(focus_card, "apply")) +assert(loopbiotic.action_available(focus_card, "retry")) +assert(loopbiotic.action_available({ actions = { { apply_patch = { patch_id = "focus-patch" } } } }, "apply")) +assert(not loopbiotic.action_available(focus_card, "follow")) +assert(not loopbiotic.action_available({ kind = "summary", next_actions = {} }, "stop")) +state.session_id = "proposal-session" state.card = focus_card assert(diff.show(focus_card)) assert(vim.api.nvim_get_current_buf() == state.diff_buf) assert(vim.deep_equal(vim.api.nvim_win_get_cursor(0), { 2, 2 })) +local proposal_position = vim.fn.screenpos(state.diff_win, state.diff_cursor[1], state.diff_cursor[2] + 1) +local action_config = vim.api.nvim_win_get_config(state.card_win) +local action_top = math.floor(ui.number(action_config.row)) + 1 +local action_height = action_config.height + (ui.has_border(action_config.border) and 2 or 0) +local action_bottom = action_top + action_height - 1 +assert(action_bottom < proposal_position.row - 1 or action_top > proposal_position.row + 1) +assert(loopbiotic.actions_visible()) +loopbiotic.go_to() +assert(vim.deep_equal(vim.api.nvim_win_get_cursor(0), { 2, 2 })) +loopbiotic.hide() +assert(not loopbiotic.actions_visible()) +local hidden_draft = state.diff_buf +diff.retry() +assert(state.diff_buf == hidden_draft) +assert(diff.valid_preview()) +loopbiotic.resume() +assert(loopbiotic.actions_visible()) +local proposal_card = state.card +state.card = { id = "stopped-card", kind = "summary", title = "Stopped", next_actions = {} } +loopbiotic.action("stop") +assert(not state.thinking_request_id) +state.card = proposal_card +loopbiotic.go_to() +assert(vim.deep_equal(vim.api.nvim_win_get_cursor(0), { 2, 2 })) diff.restore_source() assert(vim.api.nvim_get_current_buf() == focus_source) +state.session_id = nil state.card = nil vim.api.nvim_buf_delete(focus_source, { force = true }) local long_goal = "Mam tutaj problem, bo ta pętla nie uwzględnia wszystkich elementów z kolejnych przebiegów" @@ -164,7 +206,7 @@ state.goal = nil state.details_expanded = false local installer = require("loopbiotic.installer") -assert(installer.artifact("x86_64-unknown-linux-musl") == "loopbioticd-v0.3.1-x86_64-unknown-linux-musl.tar.gz") +assert(installer.artifact("x86_64-unknown-linux-musl") == "loopbioticd-v0.3.2-x86_64-unknown-linux-musl.tar.gz") local log = require("loopbiotic.log") local sanitized = log.sanitize({ buffer_text = "secret source", event = "kept" })