Skip to content

Commit fed38ca

Browse files
committed
Merge pull request 'feat(claude): preview extractor — board pane parity for Claude (slice 3/3)' (#5) from agent-preview/3-claude-extractor into main
2 parents 3809cfb + 3a0adbe commit fed38ca

12 files changed

Lines changed: 1037 additions & 4 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ your own keybindings.
2626
on-disk sessions (pi and Claude) and deduped.
2727
- **Live board** (`:AgentsBoard`) — a dedicated buffer grouping agents into
2828
running / idle / done / archived sections, with colored state and relative
29-
last-activity times, refreshing on a timer.
29+
last-activity times, refreshing on a timer; toggle a right-side preview
30+
pane (`p`) showing the agent under the cursor's current activity.
3031
- **Lifecycle** — mark done, archive (soft, never deletes a session), rename,
3132
stop; bulk actions over a visual selection.
3233
- **Background auto-naming** (opt-in) — name an agent from its launch prompt,
@@ -157,6 +158,7 @@ keys (`j`/`k`/`/`/`gg`); the per-row actions are:
157158
| `a` | launch a new agent |
158159
| `i` | type a prompt, then launch a new agent started with it |
159160
| `A` | toggle the archived section |
161+
| `p` | toggle the right-side activity-preview pane (live agents) |
160162
| `R` / `gr` | refresh now |
161163

162164
Switching, `a` and `i` hand the board's window to the agent (the board buffer
@@ -181,6 +183,7 @@ require("agent-fleet").setup({
181183
follow_output = true, -- keep unfocused agent terminals scrolled to the bottom
182184
board = { -- the :AgentsBoard buffer
183185
refresh_ms = 2000, -- how often the open board re-renders
186+
preview = { enabled = true, width = 48 },
184187
},
185188
auto_name = { -- background auto-naming (off by default; see below)
186189
enabled = false,
@@ -197,6 +200,8 @@ require("agent-fleet").setup({
197200
| `start_insert` | `true` | Enter terminal insert mode after launching. |
198201
| `follow_output` | `true` | Auto-scroll an agent's terminal to the bottom on new output even when its window is not focused. |
199202
| `board.refresh_ms` | `2000` | How often (ms) the open `:AgentsBoard` re-renders. |
203+
| `board.preview.enabled` | `true` | Show the right-side activity-preview pane beside the board (state, current tool, last assistant message); toggle with `p`. Live agents only. |
204+
| `board.preview.width` | `48` | Width in columns of the preview pane. |
200205

201206
### Registering agents
202207

ROADMAP.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ ships and what's planned. For the user-facing docs, see [README.md](README.md).
7070
Target resolution: the last-focused live agent → the single live agent when
7171
exactly one is running → `vim.ui.select` to pick when two or more are live
7272
→ else it notifies and refuses when no agent is running.
73+
- **[x] Per-row preview** — a right-side preview pane on the board (toggled
74+
with `p`, `board.preview.enabled` default `true` / `board.preview.width`
75+
default `48`) shows the live agent under the cursor's current activity:
76+
its state, the tool it's currently running (when working), and its last
77+
assistant message. Follows the cursor and refreshes on the board's
78+
render timer; live agents only. Works for both **pi** and **Claude**
79+
agents via a per-backend `M.preview` extractor that reads the session
80+
`.jsonl`.
7381

7482
## Planned
7583

@@ -95,8 +103,6 @@ ships and what's planned. For the user-facing docs, see [README.md](README.md).
95103
- **[ ] Auto-restore** — optional `VimEnter` behavior to relaunch the agents
96104
that were live when you quit. Off by default (manual resume from the board is
97105
the better default); a config flag on top of the roster.
98-
- **[ ] Per-row preview** — show each agent's last assistant message (or a short
99-
snippet of it) inline in the board / a preview pane. Deferred.
100106
- **[ ] Persistent board buffer (return with `<C-o>`)** — today the board is
101107
`bufhidden=wipe`, so pressing `<CR>` to enter an agent destroys it and
102108
`<C-o>` can't jump back (you reopen with `:AgentsBoard` / `<leader>ab`).

lua/agent-fleet/backends/claude.lua

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,122 @@ function M.tail_info(file)
127127
return { state = state, last_activity = last_activity }
128128
end
129129

130+
local PREVIEW_BLOCK_SIZE = 65536
131+
local PREVIEW_BUDGET = 1048576
132+
133+
local function read_bounded_tail(file, block_size, budget)
134+
local fd = io.open(file, "rb")
135+
if not fd then
136+
return ""
137+
end
138+
local size = fd:seek("end")
139+
if size == 0 then
140+
fd:close()
141+
return ""
142+
end
143+
144+
local blocks = {}
145+
local total_read = 0
146+
local pos = size
147+
while pos > 0 and total_read < budget do
148+
local read_size = math.min(block_size, pos, budget - total_read)
149+
pos = pos - read_size
150+
fd:seek("set", pos)
151+
local chunk = fd:read(read_size)
152+
if not chunk then
153+
break
154+
end
155+
table.insert(blocks, 1, chunk)
156+
total_read = total_read + #chunk
157+
end
158+
fd:close()
159+
160+
return table.concat(blocks)
161+
end
162+
163+
local function last_tool_use_name(content)
164+
if type(content) ~= "table" then
165+
return nil
166+
end
167+
for i = #content, 1, -1 do
168+
local block = content[i]
169+
if type(block) == "table" and block.type == "tool_use" then
170+
return block.name
171+
end
172+
end
173+
return nil
174+
end
175+
176+
local function assistant_text(content)
177+
if type(content) == "string" then
178+
return content:match("^%s*(.-)%s*$")
179+
end
180+
if type(content) ~= "table" then
181+
return nil
182+
end
183+
local parts = {}
184+
for _, block in ipairs(content) do
185+
if type(block) == "table" and block.type == "text" and type(block.text) == "string" then
186+
parts[#parts + 1] = block.text
187+
end
188+
end
189+
if #parts == 0 then
190+
return nil
191+
end
192+
return table.concat(parts, "\n"):match("^%s*(.-)%s*$")
193+
end
194+
195+
function M.preview(file)
196+
local info = M.tail_info(file)
197+
if not info then
198+
return nil
199+
end
200+
201+
local content = read_bounded_tail(file, PREVIEW_BLOCK_SIZE, PREVIEW_BUDGET)
202+
local lines = vim.split(content, "\n", { plain = true })
203+
204+
local last_message = nil
205+
local last_message_type = nil
206+
local found_assistant = false
207+
local last_assistant_content = nil
208+
for i = #lines, 1, -1 do
209+
local line = lines[i]
210+
if line ~= "" then
211+
local ok, decoded = pcall(vim.json.decode, line)
212+
if ok and type(decoded) == "table" and type(decoded.message) == "table" then
213+
if not last_message and (decoded.type == "assistant" or decoded.type == "user") then
214+
last_message = decoded.message
215+
last_message_type = decoded.type
216+
end
217+
if not found_assistant and decoded.type == "assistant" then
218+
found_assistant = true
219+
last_assistant_content = decoded.message.content
220+
end
221+
end
222+
end
223+
if last_message and found_assistant then
224+
break
225+
end
226+
end
227+
228+
local tool = nil
229+
if info.state == "working" and last_message_type == "assistant" and last_message.stop_reason == "tool_use" then
230+
tool = last_tool_use_name(last_message.content)
231+
end
232+
233+
local text = nil
234+
if found_assistant then
235+
text = assistant_text(last_assistant_content)
236+
end
237+
238+
return {
239+
state = info.state,
240+
last_activity = info.last_activity,
241+
tool = tool,
242+
text = text,
243+
}
244+
end
245+
130246
function M.session_file(cwd, sessions_dir, id)
131247
local path = sessions_dir .. "/" .. M.slug(cwd) .. "/" .. id .. ".jsonl"
132248
if vim.fn.filereadable(path) == 1 then

lua/agent-fleet/backends/pi.lua

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,120 @@ function M.tail_info(file)
161161
}
162162
end
163163

164+
local PREVIEW_BLOCK_SIZE = 65536
165+
local PREVIEW_BUDGET = 1048576
166+
167+
local function read_bounded_tail(file, block_size, budget)
168+
local fd = io.open(file, "rb")
169+
if not fd then
170+
return ""
171+
end
172+
local size = fd:seek("end")
173+
if size == 0 then
174+
fd:close()
175+
return ""
176+
end
177+
178+
local blocks = {}
179+
local total_read = 0
180+
local pos = size
181+
while pos > 0 and total_read < budget do
182+
local read_size = math.min(block_size, pos, budget - total_read)
183+
pos = pos - read_size
184+
fd:seek("set", pos)
185+
local chunk = fd:read(read_size)
186+
if not chunk then
187+
break
188+
end
189+
table.insert(blocks, 1, chunk)
190+
total_read = total_read + #chunk
191+
end
192+
fd:close()
193+
194+
return table.concat(blocks)
195+
end
196+
197+
local function last_tool_call_name(content)
198+
if type(content) ~= "table" then
199+
return nil
200+
end
201+
for i = #content, 1, -1 do
202+
local block = content[i]
203+
if type(block) == "table" and block.type == "toolCall" then
204+
return block.name
205+
end
206+
end
207+
return nil
208+
end
209+
210+
local function assistant_text(content)
211+
if type(content) ~= "table" then
212+
return nil
213+
end
214+
local parts = {}
215+
for _, block in ipairs(content) do
216+
if type(block) == "table" and block.type == "text" and type(block.text) == "string" then
217+
parts[#parts + 1] = block.text
218+
end
219+
end
220+
if #parts == 0 then
221+
return nil
222+
end
223+
return table.concat(parts, "\n"):match("^%s*(.-)%s*$")
224+
end
225+
226+
function M.preview(file)
227+
local info = M.tail_info(file)
228+
if not info then
229+
return nil
230+
end
231+
232+
local content = read_bounded_tail(file, PREVIEW_BLOCK_SIZE, PREVIEW_BUDGET)
233+
local lines = vim.split(content, "\n", { plain = true })
234+
235+
local last_message = nil
236+
local last_assistant_message = nil
237+
for i = #lines, 1, -1 do
238+
local line = lines[i]
239+
if line ~= "" then
240+
local ok, decoded = pcall(vim.json.decode, line)
241+
if ok and type(decoded) == "table" and decoded.type == "message" and type(decoded.message) == "table" then
242+
if not last_message then
243+
last_message = decoded.message
244+
end
245+
if not last_assistant_message and decoded.message.role == "assistant" then
246+
last_assistant_message = decoded.message
247+
end
248+
end
249+
end
250+
if last_message and last_assistant_message then
251+
break
252+
end
253+
end
254+
255+
local tool = nil
256+
if
257+
info.state == "working"
258+
and last_message
259+
and last_message.role == "assistant"
260+
and last_message.stopReason == "toolUse"
261+
then
262+
tool = last_tool_call_name(last_message.content)
263+
end
264+
265+
local text = nil
266+
if last_assistant_message then
267+
text = assistant_text(last_assistant_message.content)
268+
end
269+
270+
return {
271+
state = info.state,
272+
last_activity = info.last_activity,
273+
tool = tool,
274+
text = text,
275+
}
276+
end
277+
164278
function M.session_file(cwd, sessions_dir, id)
165279
local pattern = sessions_dir .. "/**/*_" .. id .. ".jsonl"
166280
local matches = vim.fn.glob(pattern, true, true)

lua/agent-fleet/config.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ M.defaults = {
2222
follow_output = true,
2323
board = {
2424
refresh_ms = 2000,
25+
preview = {
26+
enabled = true,
27+
width = 48,
28+
},
2529
},
2630
auto_name = {
2731
enabled = false,

0 commit comments

Comments
 (0)