aboutsummaryrefslogtreecommitdiffstats
path: root/plugin/50-claude_bash_monitor.lua
blob: 458daecc8a37e12ab17b999e6341d9f779e70d0c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
--[=[ 50-claude_bash_monitor.lua — follow each Claude Code session's Bash activity.

The Claude config's nvim-notify hook fires `User Claude<Hook>` autocmds — SessionStart, PreToolUse,
PostToolUse, PostToolUseFailure, SessionEnd — with the dump basename in `data`.
The listeners read the dump under `$XDG_STATE_HOME/claude/…` and route it by `session_id` to the
session's own view: two scratch buffers in a vsplit column, the command on top (bash-formatted) and
its output below (ANSI colour rendered by baleia.nvim), opened on session start and closed on
session end.
Each event overwrites its buffer, so only the latest command and output show; a command arriving
before the previous one's output keeps the stale command and output visible under banner rules.
:ClaudeBashHistory flips a session into history mode — one completed command per view, indexed from
the session's post dumps on disk; entering terminal mode in its `claude-cli` terminal returns to
the live view.
State is keyed by session_id, so concurrent sessions have independent views, and every session
buffer carries `b:claude_session_id` for the buffer-local mappings.

User commands:
  ClaudeBashMonitor:         pick a session from a floating list to (re)open its Bash view
  ClaudeBashHistory:         toggle the session's Bash history mode
  ClaudeBashHistoryNext:     show the next (newer) history entry
  ClaudeBashHistoryPrevious: show the previous (older) history entry

Keymaps (buffer-local on `claude-cli`/`claude-cmd`/`claude-output` buffers, normal mode):
  `<leader>w`   toggle wrap in the session's output window
  `<leader>h`   toggle history mode (:ClaudeBashHistory)
  `[[`          previous (older) history entry, entering history mode from the live view
  `]]`          next (newer) history entry
]=]

local discovery = require("claude_session_discovery")
local format = require("format")
local help = require("help")
local float = require("float")

--[[ ANSI colourizer for the output buffer, turning escape codes into highlights.
Synchronous (`async = false`) so each full re-render highlights atomically and in order. ]]
local baleia = require("baleia").setup({ name = "ClaudeOutput", async = false })

-- Base of Claude's dump dirs (its XDG state home, not Neovim's).
local STATE = vim.env.XDG_STATE_HOME or (os.getenv("HOME") .. "/.local/state")
local DIR = STATE .. "/claude"

---One followed session's view: its buffers, live command/output, and history index.
---@class ClaudeBashView
---@field session_id string the session this view follows
---@field cmd_buf integer? scratch buffer showing the command
---@field out_buf integer? scratch buffer showing the output
---@field command string[]? last command shown — re-labelled as previous when a new one arrives
---@field output string? last output rendered, kept as stale while a command is pending
---@field history_mode boolean? show the single entry at `history_index` instead of the live flow
---@field history string[] the session's post dump paths, oldest first (built by sync_history)
---@field history_index integer? position of the shown entry in `history`

--[[ The followed sessions' views, keyed by session_id.
A session_id present here is one being followed; concurrent sessions never share buffers. ]]
---@type table<string, ClaudeBashView>
local views = {}

----------------------------------------------------------------------------------------------------
-- Dumps -------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

-- Decode the JSON file at `path`, or nil if it can't be read or parsed.
local function read_json(path)
	local fd = io.open(path, "r")
	if not fd then
		return nil
	end
	local content = fd:read("*a")
	fd:close()
	local ok, data = pcall(vim.json.decode, content)
	return ok and data or nil
end

--[[ The dump for `id` read from `subdir` if it belongs to a followed session; nil otherwise.
The caller routes it via the returned dump's `session_id`. ]]
local function dump_for(subdir, id)
	local d = read_json(DIR .. "/" .. subdir .. "/" .. id .. ".json")
	if d and d.session_id and views[d.session_id] then
		return d
	end
	return nil
end

--[[ The Bash command a pre/post dump `d` carries, as bash-formatted buffer lines.
The command's `description` precedes it as a `#` comment line when one is given. ]]
local function command_lines(d)
	local input = d.tool_input or {}
	local lines = {}
	if input.description and input.description ~= "" then
		lines[#lines + 1] = "# " .. input.description
	end
	vim.list_extend(
		lines,
		format.format("bash", vim.split(input.command or "", "\n", { plain = true }))
	)
	return lines
end

-- The output a post dump `d` carries: its non-empty stdout, stderr, and error joined into one text.
local function output_text(d)
	local parts = {}
	local r = d.tool_response
	if type(r) == "table" then
		for _, chunk in ipairs({ r.stdout, r.stderr }) do
			if type(chunk) == "string" and chunk ~= "" then
				parts[#parts + 1] = chunk
			end
		end
	end
	if d.error then
		parts[#parts + 1] = tostring(d.error)
	end
	return table.concat(parts, "\n")
end

--[[ Rebuild `view.history` from the post dumps on disk.
The paths belong to `view.session_id`, one completed command each since a post dump carries both
the command and its output, ordered oldest first by dump mtime. ]]
local function sync_history(view)
	local dir = DIR .. "/posttooluse"
	local entries = {}
	local scan = vim.uv.fs_scandir(dir)
	while scan do
		local name = vim.uv.fs_scandir_next(scan)
		if not name then
			break
		end
		local path = dir .. "/" .. name
		local d = name:match("%.json$") and read_json(path)
		local stat = d and d.session_id == view.session_id and vim.uv.fs_stat(path)
		if stat then
			entries[#entries + 1] = { path = path, sec = stat.mtime.sec, nsec = stat.mtime.nsec }
		end
	end
	table.sort(entries, function(a, b)
		return a.sec == b.sec and a.nsec < b.nsec or a.sec < b.sec
	end)
	view.history = vim.tbl_map(function(entry)
		return entry.path
	end, entries)
end

----------------------------------------------------------------------------------------------------
-- Buffers & layout --------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

--[[ Return `buf` if still valid, else a fresh scratch buffer named `name`.
The filetype is set later, once the buffer is in its window (open_layout), so window-local ftplugin
settings take effect. ]]
local function scratch(buf, name)
	if buf and vim.api.nvim_buf_is_valid(buf) then
		return buf
	end
	buf = vim.api.nvim_create_buf(true, true)
	vim.api.nvim_buf_set_name(buf, name)
	vim.bo[buf].buftype = "nofile"
	vim.bo[buf].swapfile = false
	return buf
end

--[[ Overwrite `buf` with `lines` and park the cursor at the top in every window showing it.
The full overwrite is the point: the monitor shows only the latest event. ]]
local function render(buf, lines)
	if not (buf and vim.api.nvim_buf_is_valid(buf)) then
		return
	end
	vim.bo[buf].modifiable = true
	vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
	vim.bo[buf].modifiable = false
	for _, win in ipairs(vim.fn.win_findbuf(buf)) do
		vim.api.nvim_win_set_cursor(win, { 1, 0 })
	end
end

--[[ Overwrite output buffer `buf` with `text`, its ANSI colour codes rendered as baleia highlights.
Normalises line endings (CRLF and stray CR) before splitting, toggles 'modifiable' around the write
(baleia.buf_set_lines needs it), and parks the cursor at the top in every window showing it. ]]
local function show_output(buf, text)
	if not (buf and vim.api.nvim_buf_is_valid(buf)) then
		return
	end
	local lines = vim.split((text:gsub("\r\n", "\n"):gsub("\r", "")), "\n", { plain = true })
	vim.bo[buf].modifiable = true
	baleia.buf_set_lines(buf, 0, -1, false, lines)
	vim.bo[buf].modifiable = false
	for _, win in ipairs(vim.fn.win_findbuf(buf)) do
		vim.api.nvim_win_set_cursor(win, { 1, 0 })
	end
end

--[[ The text width of the window showing `buf`, or `fallback` when the buffer is hidden.
The scratch panes carry no gutter, so the whole window width is text and a rule of that many
columns fills the pane without wrapping. ]]
local function pane_width(buf, fallback)
	local win = vim.fn.win_findbuf(buf)[1]
	if win then
		return vim.api.nvim_win_get_width(win)
	end
	return fallback
end

--[[ `label` centered in a rule of `char` filling `width` columns.
Both panes' previous-* banners are built from this divider. ]]
local function divider(label, char, width)
	local pad = math.max(0, width - vim.fn.strdisplaywidth(label))
	local left = math.floor(pad / 2)
	return string.rep(char, left) .. label .. string.rep(char, pad - left)
end

--[[ Labels centered in each pane's previous-* banner.
The command banner is a `#` rule — a whole-line bash comment the parser dims; the output banner a
box-drawing rule shown bold yellow via ANSI. ]]
local CMD_LABEL = " ⏳ previous command ⏳ "
local OUT_LABEL = " ⏳ last command's output ⏳ "

--[[ Render `view`'s pending state — a new command in, its output not yet — from `new_cmd`.
The new command tops the command buffer, the previous command drops below it under a banner
comment, and the previous output stays in the output buffer under a stale divider.
With no previous command/output (the session's first event) it shows only the new command. ]]
local function render_pending(view, new_cmd)
	local lines = {}
	vim.list_extend(lines, new_cmd)
	if view.command then
		local rule = divider(CMD_LABEL, "#", pane_width(view.cmd_buf, 48))
		vim.list_extend(lines, { "", rule, "" })
		vim.list_extend(lines, view.command)
	end
	render(view.cmd_buf, lines)
	if view.output and view.output ~= "" then
		local rule = divider(OUT_LABEL, "─", pane_width(view.out_buf, 48))
		show_output(view.out_buf, "\27[1;33m" .. rule .. "\27[0m\n\n" .. view.output)
	end
end

--[[ Render `view`'s history entry at `history_index` — one settled command with its output.
The entry is read back from its post dump, which carries both the command and the output, and a
`history <i>/<n>` banner tops both panes to place the entry in the history — a `#` comment rule
in the command pane, a box-drawing rule in the output pane.
A no-op when the index holds no entry (empty history or gone dump file). ]]
local function render_history(view)
	local path = view.history[view.history_index]
	local d = path and read_json(path)
	if not d then
		return
	end
	local label = string.format(" history %d/%d ", view.history_index, #view.history)
	local lines = { divider(label, "#", pane_width(view.cmd_buf, 48)), "" }
	vim.list_extend(lines, command_lines(d))
	render(view.cmd_buf, lines)
	local rule = divider(label, "─", pane_width(view.out_buf, 48))
	show_output(view.out_buf, "\27[1;33m" .. rule .. "\27[0m\n\n" .. output_text(d))
end

--[[ Ensure `view`'s buffers exist, tagged with the session id, and show them in a vsplit column.
The buffers are named for `session`'s short id and both carry `b:claude_session_id`; unless already
visible they are shown stacked (cmd top, output bottom) via the default split commands, leaving
focus where it was. ]]
local function open_layout(view, session)
	local short = session.session_id:sub(1, 8)
	view.cmd_buf = scratch(view.cmd_buf, "claude://" .. short .. "/cmd")
	view.out_buf = scratch(view.out_buf, "claude://" .. short .. "/output")
	vim.b[view.cmd_buf].claude_session_id = session.session_id
	vim.b[view.out_buf].claude_session_id = session.session_id
	if #vim.fn.win_findbuf(view.cmd_buf) > 0 then
		return
	end
	local focus = vim.api.nvim_get_current_win()
	vim.cmd("vsplit") -- default split: a new column, becomes current
	vim.api.nvim_win_set_buf(0, view.out_buf)
	vim.bo.filetype = "claude-output" -- set while shown so window-local ftplugin settings apply
	vim.cmd("split") -- with 'splitbelow' off the new window is on top, for the command
	vim.api.nvim_win_set_buf(0, view.cmd_buf)
	vim.bo.filetype = "claude-cmd"
	vim.api.nvim_set_current_win(focus)
end

--[[ Follow `session`, opening its view; clear the buffers when `fresh`.
`fresh` marks a session seen for the first time, so re-opening or a compact/clear/resume of a live
session keeps its scrollback. ]]
local function monitor(session, fresh)
	local view = views[session.session_id]
	if not view then
		view = { session_id = session.session_id, history = {} }
		views[session.session_id] = view
	end
	open_layout(view, session)
	if fresh then
		render(view.cmd_buf, {})
		show_output(view.out_buf, "")
		view.command, view.output = nil, ""
	end
end

--[[ Close `session_id`'s view: delete its two buffers (closing their windows) and drop its state.
A no-op for a session that is not followed, so it never touches another session's windows. ]]
local function close(session_id)
	local view = views[session_id]
	if not view then
		return
	end
	for _, buf in ipairs({ view.cmd_buf, view.out_buf }) do
		if buf and vim.api.nvim_buf_is_valid(buf) then
			vim.api.nvim_buf_delete(buf, { force = true })
		end
	end
	views[session_id] = nil
end

----------------------------------------------------------------------------------------------------
-- Event listeners ---------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

--[[ `User ClaudeSessionStart`: auto-follow the session that just started and tag its terminal.
The buffers are cleared only the first time the session is seen, so a later compact/clear/resume
of the same session keeps its scrollback.
The event fires with the session's `claude-cli` terminal as the current buffer, so that terminal
is tagged with the session id here — except on a compact, the one source that can fire with
another buffer current (auto-compact) and that never changes the session id anyway. ]]
local function on_session_start(args)
	local d = read_json(DIR .. "/sessionstart/" .. args.data .. ".json")
	if not d or not d.session_id then
		return
	end
	if vim.bo.filetype == "claude-cli" and d.source ~= "compact" then
		vim.b.claude_session_id = d.session_id
	end
	monitor({ session_id = d.session_id, cwd = d.cwd }, views[d.session_id] == nil)
end

-- `User ClaudeSessionEnd`: close the ended session's view, leaving other sessions untouched.
local function on_session_end(args)
	local d = read_json(DIR .. "/sessionend/" .. args.data .. ".json")
	if d and d.session_id then
		close(d.session_id)
	end
end

--[[ `User ClaudePreToolUse`: show the Bash command about to run, in its session's command buffer.
In history mode nothing is rendered (no pending banners); the command is still tracked so the live
view is current when history mode is toggled off. ]]
local function on_pre(args)
	local d = dump_for("pretooluse", args.data)
	if not d then
		return
	end
	local view = views[d.session_id]
	local lines = command_lines(d)
	if not view.history_mode then
		-- new command on top; previous command and output stay as stale
		render_pending(view, lines)
	end
	view.command = lines -- becomes the previous command once the next one arrives
end

--[[ `User ClaudePostToolUse[Failure]`: show the finished command's output in its session's buffer.
Its stdout, stderr, and any error are concatenated into the output buffer.
In history mode the finished command instead becomes the newest history entry; the shown entry
stays put, with only its banner updating to the new total. ]]
local function on_post(args)
	local d = dump_for("posttooluse", args.data)
	if not d then
		return
	end
	local view = views[d.session_id]
	view.output = output_text(d)
	if view.history_mode then
		view.history[#view.history + 1] = DIR .. "/posttooluse/" .. args.data .. ".json"
		if view.history_index == 0 then
			-- an empty history was showing nothing; show the first entry
			view.history_index = 1
		end
		render_history(view) -- stay on the shown entry; its banner picks up the new total
		return
	end
	-- settled: drop the previous-command banner and old command
	render(view.cmd_buf, view.command or {})
	show_output(view.out_buf, view.output)
end

----------------------------------------------------------------------------------------------------
-- Session picker ----------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

-- The sessions shown by the open picker, indexed by line, for the select callback to resolve.
local picker_sessions = {}

-- Open the monitor for the session on the picker's `index`-th line, keeping any scrollback.
local function open_selected_session(index)
	local session = picker_sessions[index]
	if session then
		monitor(session, false)
	end
end

--[[ :ClaudeBashMonitor implementation: open a picker of the live sessions, newest first.
Each line is the session's `discovery.label`; selecting one opens its Bash view.
Warns and does nothing when no session is live. ]]
local function open_picker()
	local sessions = discovery.sessions()
	if #sessions == 0 then
		vim.notify("No live Claude sessions", vim.log.levels.INFO)
		return
	end
	local now = os.time()
	local lines = {}
	for i, session in ipairs(sessions) do
		lines[i] = discovery.label(session, now)
	end
	picker_sessions = sessions
	float.open(lines, { title = " Claude sessions ", on_select = open_selected_session })
end

----------------------------------------------------------------------------------------------------
-- Buffer-local shortcuts --------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

-- The view of the current buffer's session (`b:claude_session_id`), or nil plus a warning.
local function current_view()
	local view = views[vim.b.claude_session_id]
	if not view then
		vim.notify("No monitored Claude session for this buffer", vim.log.levels.WARN)
	end
	return view
end

-- Toggle line wrap in the window(s) showing the session's output buffer.
local function toggle_output_wrap()
	local view = current_view()
	if not view then
		return
	end
	for _, win in ipairs(vim.fn.win_findbuf(view.out_buf)) do
		vim.wo[win].wrap = not vim.wo[win].wrap
	end
end

--[[ :ClaudeBashHistory implementation: toggle the session between the live view and history mode.
Toggling history mode on synchronizes the session's on-disk index and shows its latest entry;
toggling it off restores the live settled view from the tracked command and output. ]]
local function toggle_history()
	local view = current_view()
	if not view then
		return
	end
	view.history_mode = not view.history_mode
	if view.history_mode then
		sync_history(view)
		view.history_index = #view.history
		render_history(view)
	else
		render(view.cmd_buf, view.command or {})
		show_output(view.out_buf, view.output or "")
	end
	local state = view.history_mode and "on" or "off"
	if view.history_mode and #view.history == 0 then
		state = state .. " (no completed commands yet)"
	end
	vim.notify("Claude Bash history mode " .. state, vim.log.levels.INFO)
end

--[[ :ClaudeBashHistoryNext/Previous implementation: move the history view by `delta` entries.
Negative moves toward older entries.
Requires history mode on; a step past either end changes nothing and says so. ]]
local function history_step(delta)
	local view = current_view()
	if not view then
		return
	end
	if not view.history_mode then
		vim.notify("Claude Bash history mode is off", vim.log.levels.WARN)
		return
	end
	local index = view.history_index + delta
	if index < 1 or index > #view.history then
		vim.notify(
			"No " .. (delta < 0 and "older" or "newer") .. " history entry",
			vim.log.levels.INFO
		)
		return
	end
	view.history_index = index
	render_history(view)
end

-- :ClaudeBashHistoryPrevious: show the previous (older) history entry.
local function history_previous()
	history_step(-1)
end

-- :ClaudeBashHistoryNext: show the next (newer) history entry.
local function history_next()
	history_step(1)
end

--[=[ `[[` implementation: the previous (older) history entry.
From the live view it first enters history mode (landing on the latest entry), so stepping back
into the history needs no explicit :ClaudeBashHistory. ]=]
local function history_back()
	local view = current_view()
	if not view then
		return
	end
	if not view.history_mode then
		toggle_history()
	end
	history_step(-1)
end

-- Entering terminal mode in the `claude-cli` terminal returns its session to the live view.
local function on_term_enter()
	local view = views[vim.b.claude_session_id]
	if view and view.history_mode then
		toggle_history()
	end
end

--[[ Set the monitor's normal-mode buffer-local keymaps and commands for every `claude-*` buffer.
The history commands act on the buffer's own session, so they too are buffer-local. ]]
local function init_monitor_buffer(args)
	-- stylua: ignore start
	vim.keymap.set("n", "<leader>w", toggle_output_wrap, { buffer = args.buf, desc = "Toggle Claude output wrap" })
	vim.keymap.set("n", "<leader>h", toggle_history, { buffer = args.buf, desc = "Toggle Claude Bash history mode" })
	vim.keymap.set("n", "[[", history_back, { buffer = args.buf, desc = "Previous Claude Bash history entry" })
	vim.keymap.set("n", "]]", history_next, { buffer = args.buf, desc = "Next Claude Bash history entry" })
	-- stylua: ignore end
	vim.api.nvim_buf_create_user_command(args.buf, "ClaudeBashHistory", toggle_history, {
		desc = "Toggle a Claude session's Bash history mode",
	})
	vim.api.nvim_buf_create_user_command(args.buf, "ClaudeBashHistoryNext", history_next, {
		desc = "Show the next Claude Bash history entry",
	})
	vim.api.nvim_buf_create_user_command(args.buf, "ClaudeBashHistoryPrevious", history_previous, {
		desc = "Show the previous Claude Bash history entry",
	})
end

----------------------------------------------------------------------------------------------------
-- Declarations ------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

vim.api.nvim_create_user_command("ClaudeBashMonitor", open_picker, {
	desc = "Pick a live Claude session to open its Bash view",
})

vim.api.nvim_create_autocmd("FileType", {
	pattern = { "claude-cli", "claude-cmd", "claude-output" },
	group = vim.g.dotfiles_augroup,
	callback = init_monitor_buffer,
	desc = "Set the Claude monitor's buffer-local mappings and commands",
})
vim.api.nvim_create_autocmd("TermEnter", {
	group = vim.g.dotfiles_augroup,
	callback = on_term_enter,
	desc = "Return a Claude session to the live view on terminal interaction",
})
vim.api.nvim_create_autocmd("User", {
	pattern = "ClaudeSessionStart",
	group = vim.g.dotfiles_augroup,
	callback = on_session_start,
	desc = "Auto-open the Bash monitor for a starting Claude session",
})
vim.api.nvim_create_autocmd("User", {
	pattern = "ClaudeSessionEnd",
	group = vim.g.dotfiles_augroup,
	callback = on_session_end,
	desc = "Close the Bash monitor when its session ends",
})
vim.api.nvim_create_autocmd("User", {
	pattern = "ClaudePreToolUse",
	group = vim.g.dotfiles_augroup,
	callback = on_pre,
	desc = "Show a monitored session's Bash command",
})
vim.api.nvim_create_autocmd("User", {
	pattern = { "ClaudePostToolUse", "ClaudePostToolUseFailure" },
	group = vim.g.dotfiles_augroup,
	callback = on_post,
	desc = "Show a monitored session's Bash output",
})

help.register("Claude Bash monitor", {
	commands = {
		{ "ClaudeBashMonitor", "Pick a live Claude session to open its Bash view" },
	},
})
help.register("Claude Bash monitor (buffer-local)", {
	maps = {
		{ "<leader>w", "Toggle Claude output wrap" },
		{ "<leader>h", "Toggle Claude Bash history mode" },
		{ "[[", "Previous Claude Bash history entry" },
		{ "]]", "Next Claude Bash history entry" },
	},
	commands = {
		{ "ClaudeBashHistory", "Toggle a Claude session's Bash history mode" },
		{ "ClaudeBashHistoryNext", "Show the next Claude Bash history entry" },
		{ "ClaudeBashHistoryPrevious", "Show the previous Claude Bash history entry" },
	},
})