Skip to content

Commit 0e2a867

Browse files
committed
fix(codex): make wrapper slash commands mention-style with capture-aware submit
Rework slash wrapper command execution to match mention-style behavior while keeping queue ordering deterministic and preserving user input when possible. Design decisions: - Extract prompt capture + Enter-submit logic into context/prompt_submit.lua so mention and wrapper flows share one implementation. - Keep send_command() generic: send only /<command> payload, then submit with Enter in on_sent (no inline newline payload). - Route wrapper APIs (set_model, show_status, show_permissions, compact, review, show_diff) and active-session resume through a dedicated wrapper dispatcher that captures, clears, sends, and auto-submits. - For wrappers, copy non-empty captured prompt text to unnamed register ("), do not restore it after submit, and emit warning-level feedback. - Warn on capture_status == unavailable_buffer (clear may discard prompt content when buffer introspection is unavailable). - Keep mention restore semantics intact; wrappers intentionally remain non-restoring. - Normalize submit fallback behavior by using \r for channel-send Enter. - Improve prompt parsing/capture heuristics: support compact prompt forms like >draft, treat symbol-only markers (> , >> ) as empty input, and add prompt-like uncertainty helpers. Documentation/testing updates: - Update docs/command-interactions.md to split generic send_command() behavior from wrapper autosubmit behavior and document warning policy. - Expand unit coverage for wrapper autosubmit payload + ordering, register-save success/failure, unavailable-buffer warning behavior (including resume), and empty/symbol-only prompt cases.,
1 parent bce7144 commit 0e2a867

6 files changed

Lines changed: 646 additions & 170 deletions

File tree

docs/command-interactions.md

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,24 @@ provider collaborators.
99

1010
## Command Mapping
1111

12-
| User Command | Entry Function | Primary Path |
13-
| ------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------ |
14-
| `:Codex` | `codex.toggle()` | Toggle active terminal or open a focused session |
15-
| `:Codex!` | `codex.open(true)` | Force-open and focus terminal |
16-
| `:CodexFocus` | `codex.focus()` | Focus active session or open one |
17-
| `:CodexClose` | `codex.close()` | Close active session and reset queue |
18-
| `:CodexClearInput` | `codex.clear_input()` | Send `<C-c>` to active session |
19-
| `:CodexSend` | `codex.send_selection(opts)` | Collect selection, format, send via queue |
20-
| `:CodexMentionFile [path]` | `codex.mention_file(path)` | Build `/mention` payload for relative file and submit |
21-
| `:CodexMentionDirectory [path]` | `codex.mention_directory(path)` | Build `/mention` payload for relative directory (with trailing separator) and submit |
22-
| `:CodexResume` | `codex.resume({ last = false })` | In-process `/resume` or launch `codex resume` |
23-
| `:CodexResume!` | `codex.resume({ last = true })` | Launch `codex resume --last` when opening new process |
24-
| `:CodexModel` | `codex.set_model()` | Slash command wrapper (`/model`) |
25-
| `:CodexStatus` | `codex.show_status()` | Slash command wrapper (`/status`) |
26-
| `:CodexPermissions` | `codex.show_permissions()` | Slash command wrapper (`/permissions`) |
27-
| `:CodexCompact` | `codex.compact()` | Slash command wrapper (`/compact`) |
28-
| `:CodexReview [instructions]` | `codex.review(instructions)` | Slash command wrapper (`/review ...`) |
29-
| `:CodexDiff` | `codex.show_diff()` | Slash command wrapper (`/diff`) |
12+
| User Command | Entry Function | Primary Path |
13+
| ------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------- |
14+
| `:Codex` | `codex.toggle()` | Toggle active terminal or open a focused session |
15+
| `:Codex!` | `codex.open(true)` | Force-open and focus terminal |
16+
| `:CodexFocus` | `codex.focus()` | Focus active session or open one |
17+
| `:CodexClose` | `codex.close()` | Close active session and reset queue |
18+
| `:CodexClearInput` | `codex.clear_input()` | Send `<C-c>` to active session |
19+
| `:CodexSend` | `codex.send_selection(opts)` | Collect selection, format, send via queue |
20+
| `:CodexMentionFile [path]` | `codex.mention_file(path)` | Build `/mention` payload for relative file and submit |
21+
| `:CodexMentionDirectory [path]` | `codex.mention_directory(path)` | Build `/mention` payload for relative directory (with trailing separator) and submit |
22+
| `:CodexResume` | `codex.resume({ last = false })` | In-process `/resume` or launch `codex resume` |
23+
| `:CodexResume!` | `codex.resume({ last = true })` | Launch `codex resume --last` when opening new process |
24+
| `:CodexModel` | `codex.set_model()` | Mention-style slash wrapper (`/model`): capture->copy->clear->send->auto-submit |
25+
| `:CodexStatus` | `codex.show_status()` | Mention-style slash wrapper (`/status`): capture->copy->clear->send->auto-submit |
26+
| `:CodexPermissions` | `codex.show_permissions()` | Mention-style slash wrapper (`/permissions`): capture->copy->clear->send->auto-submit |
27+
| `:CodexCompact` | `codex.compact()` | Mention-style slash wrapper (`/compact`): capture->copy->clear->send->auto-submit |
28+
| `:CodexReview [instructions]` | `codex.review(instructions)` | Mention-style slash wrapper (`/review ...`): capture->copy->clear->send->auto-submit |
29+
| `:CodexDiff` | `codex.show_diff()` | Mention-style slash wrapper (`/diff`): capture->copy->clear->send->auto-submit |
3030

3131
## Setup Registration Flow
3232

@@ -187,27 +187,67 @@ commands.lua -> codex.resume({ last = opts.bang })
187187
init.lua resume(opts)
188188
|- ensure_setup()
189189
|- session_lifecycle.get_active_session_and_provider(deps, config)
190-
|- [active + alive session] -> send_command("resume") (in-process /resume)
190+
|- [active + alive session] -> dispatch_wrapper_command("resume") (in-process /resume)
191191
\- [no active/alive session]
192192
|- args = { "resume" }
193193
|- if opts.last then args += "--last"
194194
\- session_lifecycle.open_session(deps, config, args, focus=true)
195195
```
196196

197-
### Slash Command Wrappers
197+
### Generic `send_command()`
198198

199-
These commands all route through `send_command()`, which normalizes the slash
200-
command, builds `"/<command>\n"`, and calls:
199+
`send_command()` normalizes the slash command, sends `"/<command>"`, then
200+
submits with Enter in the same queue callback:
201201

202202
```text
203203
send_dispatch.dispatch_send(payload, {
204204
open_focus = true,
205205
pre_focus = true,
206206
command_path = "/<command>",
207+
on_sent = function()
208+
prompt_submit.submit_with_enter_key(..., "/<command>")
209+
end,
207210
})
208211
```
209212

210-
| User Command | API Method | `send_command(...)` Input | Command Path |
213+
Current command-facing usage:
214+
215+
- No built-in `:Codex*` user command calls `send_command()` directly.
216+
217+
### Wrapper Slash Commands (Mention-Style Autosubmit)
218+
219+
These wrappers (`set_model`, `show_status`, `show_permissions`, `compact`,
220+
`review`, `show_diff`, and active-session `resume`) use a mention-style pre-clear flow with an atomic
221+
send + submit path:
222+
223+
```text
224+
init.lua dispatch_wrapper_command(command)
225+
|- ensure_setup()
226+
|- normalize -> command_path ("/<command>")
227+
|- [active + alive session] provider.focus(handle) before capture
228+
|- capture prompt input (best effort)
229+
|- [captured input] vim.fn.setreg('"', captured_input)
230+
|- payload = clear_line_sequence + command_path
231+
\- send_dispatch.dispatch_send(payload, {
232+
open_focus = true,
233+
pre_focus = true,
234+
command_path = command_path,
235+
on_sent = function()
236+
prompt_submit.submit_with_enter_key(..., command_path)
237+
end,
238+
})
239+
```
240+
241+
Notes:
242+
243+
- Existing prompt input is copied to the unnamed register (`"`), then cleared.
244+
- Successful non-empty save emits a WARN notification.
245+
- If an active session buffer cannot be introspected (`unavailable_buffer`) before clear, a WARN notification is emitted.
246+
- Input is not restored after command submission.
247+
- Wrapper command submission is atomic per queue item, so rapid consecutive calls
248+
keep FIFO command ordering.
249+
250+
| User Command | API Method | Wrapper Input | Command Path |
211251
| ----------------------------- | ---------------------- | --------------------------- | ------------------------ |
212252
| `:CodexModel` | `set_model()` | `"model"` | `/model` |
213253
| `:CodexStatus` | `show_status()` | `"status"` | `/status` |
@@ -216,3 +256,4 @@ send_dispatch.dispatch_send(payload, {
216256
| `:CodexReview` | `review(nil)` | `"review"` | `/review` |
217257
| `:CodexReview <instructions>` | `review(instructions)` | `"review " .. instructions` | `/review <instructions>` |
218258
| `:CodexDiff` | `show_diff()` | `"diff"` | `/diff` |
259+
| `:CodexResume` | `resume()` | `"resume"` (active session) | `/resume` |

lua/codex/context/mention.lua

Lines changed: 4 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local terminal_io = require("codex.runtime.terminal_io")
22
local session_lifecycle = require("codex.state.session_lifecycle")
3+
local prompt_submit = require("codex.context.prompt_submit")
34

45
---@class codex.MentionOpts
56
---@field get_deps fun(): table
@@ -16,114 +17,6 @@ function M.create(opts)
1617
local get_config = opts.get_config
1718
local dispatch_send = opts.dispatch_send
1819

19-
---Best-effort capture of current terminal prompt input for post-mention restore.
20-
---@return string|nil
21-
local function capture_terminal_prompt_input()
22-
local deps = get_deps()
23-
local config = get_config()
24-
local session, provider = session_lifecycle.get_active_session_and_provider(deps, config)
25-
if
26-
not session_lifecycle.session_is_alive(session, provider)
27-
or type(provider.get_bufnr) ~= "function"
28-
then
29-
return nil
30-
end
31-
32-
local bufnr = provider.get_bufnr(session.handle)
33-
local api = deps.vim.api
34-
if type(bufnr) ~= "number" then
35-
return nil
36-
end
37-
local ok_valid, is_valid = pcall(api.nvim_buf_is_valid, bufnr)
38-
if not ok_valid or not is_valid then
39-
return nil
40-
end
41-
42-
local ok_count, line_count = pcall(api.nvim_buf_line_count, bufnr)
43-
if not ok_count or type(line_count) ~= "number" or line_count < 1 then
44-
return nil
45-
end
46-
47-
local candidates = {}
48-
local seen = {}
49-
local cursor_line = nil
50-
local cursor_col = nil
51-
52-
local ok_winid, winid = pcall(deps.vim.fn.bufwinid, bufnr)
53-
if ok_winid and type(winid) == "number" and winid > 0 then
54-
local ok_cursor, cursor = pcall(api.nvim_win_get_cursor, winid)
55-
if ok_cursor and type(cursor) == "table" then
56-
cursor_line = cursor[1]
57-
cursor_col = cursor[2]
58-
terminal_io.add_candidate_line(candidates, seen, cursor_line)
59-
end
60-
end
61-
62-
for offset = 0, terminal_io.PROMPT_CAPTURE_LOOKBACK_LINES do
63-
terminal_io.add_candidate_line(candidates, seen, line_count - offset)
64-
end
65-
66-
for _, line_number in ipairs(candidates) do
67-
local ok_lines, lines =
68-
pcall(api.nvim_buf_get_lines, bufnr, line_number - 1, line_number, false)
69-
if ok_lines and type(lines) == "table" then
70-
local line = lines[1]
71-
local parsed = terminal_io.parse_prompt_input(line)
72-
if parsed ~= nil then
73-
if
74-
line_number == cursor_line
75-
and type(cursor_col) == "number"
76-
and cursor_col <= parsed.input_start_col
77-
then
78-
-- Cursor is parked at input start; treat trailing ghost text as not-yet-accepted.
79-
return nil
80-
end
81-
if parsed.input ~= "" then
82-
return parsed.input
83-
end
84-
end
85-
end
86-
end
87-
88-
return nil
89-
end
90-
91-
---Submits the current prompt using Enter, with provider-send fallback.
92-
---@param target string
93-
---@return boolean ok
94-
---@return string|nil err
95-
local function submit_with_enter_key(target)
96-
local deps = get_deps()
97-
local config = get_config()
98-
local session, provider = session_lifecycle.get_active_session_and_provider(deps, config)
99-
if not session_lifecycle.session_is_alive(session, provider) then
100-
return false, "no active Codex session"
101-
end
102-
103-
provider.focus(session.handle)
104-
local enter_termcode = terminal_io.encode_termcode(deps, "<CR>")
105-
local feedkeys = deps.vim.api.nvim_feedkeys
106-
if type(feedkeys) == "function" then
107-
terminal_io.append_send_debug_entry(
108-
deps,
109-
string.format("%s[feedkeys_submit]", target),
110-
enter_termcode
111-
)
112-
local ok, feedkeys_err = pcall(feedkeys, enter_termcode, "nt", false)
113-
if ok then
114-
return true
115-
end
116-
deps.logger.warn("feedkeys submit failed, falling back to channel send: %s", feedkeys_err)
117-
end
118-
119-
terminal_io.append_send_debug_entry(
120-
deps,
121-
string.format("%s[channel_submit]", target),
122-
terminal_io.CODEX_ENTER_SEQUENCE
123-
)
124-
return provider.send(session.handle, terminal_io.CODEX_ENTER_SEQUENCE)
125-
end
126-
12720
---Sends `/mention` for an already-resolved relative path, auto-submits, and restores prompt input.
12821
---@param resolved_path string Relative path to mention.
12922
---@return codex.SendResult ok True when mention payload is sent.
@@ -139,15 +32,16 @@ function M.create(opts)
13932
provider.focus(session.handle)
14033
end
14134

142-
local existing_input = capture_terminal_prompt_input()
35+
local existing_input = prompt_submit.capture_prompt_input(get_deps, get_config)
14336
local mention_payload = terminal_io.encode_clear_line_for_mention(deps) .. mention
14437
return dispatch_send(mention_payload, {
14538
open_focus = true,
14639
pre_focus = true,
14740
command_path = "/mention",
14841
on_sent = function()
14942
deps.vim.defer_fn(function()
150-
local submit_ok, submit_err = submit_with_enter_key("/mention")
43+
local submit_ok, submit_err =
44+
prompt_submit.submit_with_enter_key(get_deps, get_config, "/mention")
15145
if not submit_ok then
15246
deps.logger.error("failed to submit /mention: %s", submit_err)
15347
return

0 commit comments

Comments
 (0)