feat(prompt): multiline prompt input #33371

Problem:
Cannot enter multiline prompts in a buftype=prompt buffer.

Solution:
- Support shift+enter (`<s-enter>`) to start a new line in the prompt.
- Pasting multiline text via OS paste, clipboard, "xp, etc.
- A/I in editable region works as usual.
- i/a/A/I outside of editable region moves cursor to end of current
  prompt.
- Support undo/redo in prompt buffer.
- Support o/O in prompt buffer.
- Expose prompt location as `':` mark.
This commit is contained in:
Shadman
2025-06-17 21:46:57 +06:00
committed by GitHub
parent 496691f985
commit 286371b4d2
13 changed files with 409 additions and 39 deletions

View File

@ -183,22 +183,23 @@ If you want to type input for the job in a Vim window you have a few options:
- Use a terminal window. This works well if what you type goes directly to
the job and the job output is directly displayed in the window.
See |terminal|.
- Use a window with a prompt buffer. This works well when entering a line for
- Use a window with a prompt buffer. This works well when entering lines for
the job in Vim while displaying (possibly filtered) output from the job.
A prompt buffer is created by setting 'buftype' to "prompt". You would
normally only do that in a newly created buffer.
The user can edit and enter one line of text at the very last line of the
buffer. When pressing Enter in the prompt line the callback set with
|prompt_setcallback()| is invoked. It would normally send the line to a job.
Another callback would receive the output from the job and display it in the
buffer, below the prompt (and above the next prompt).
The user can edit and enter text at the very last line of the buffer. When
pressing Enter in the prompt line the callback set with |prompt_setcallback()|
is invoked. To enter multiple lines user can use Shift+Enter that'd add a new
line. The final Enter submits the lines to |prompt_setcallback()|. It would
normally send the line to a job. Another callback would receive the output
from the job and display it in the buffer, below the prompt (and above the
next prompt).
Only the text in the last line, after the prompt, is editable. The rest of the
buffer is not modifiable with Normal mode commands. It can be modified by
calling functions, such as |append()|. Using other commands may mess up the
buffer.
Only the text after the last prompt, is editable. The rest of the buffer is
not modifiable with Normal mode commands. It can be modified by calling
functions, such as |append()|. Using other commands may mess up the buffer.
After setting 'buftype' to "prompt" Vim does not automatically start Insert
mode, use `:startinsert` if you want to enter Insert mode, so that the user

View File

@ -795,6 +795,14 @@ m< or m> Set the |'<| or |'>| mark. Useful to change what the
Note that the Visual mode cannot be set, only the
start and end position.
*m:*
m: Special mark for prompt buffers. It always shows the
line where current prompt starts. Text from this line
and below will be submitted when user submits.
Note: This mark is readonly. You can not modify it's
location. Also this mark is unique to prompt buffers as
a result not available in regular buffers.
*:ma* *:mark* *E191*
:[range]ma[rk] {a-zA-Z'}
Set mark {a-zA-Z'} at last line number in [range],

View File

@ -472,6 +472,12 @@ Variables:
instead of always being strings. |v:option_old| is now the old global value
for all global-local options, instead of just string global-local options.
Prompt-Buffer:
- supports multiline inputs.
- supports multiline paste.
- supports undo/redo on current prompt.
- supports normal o/O operations.
Vimscript:
- |:redir| nested in |execute()| works.

View File

@ -878,6 +878,7 @@ static void free_buffer(buf_T *buf)
clear_fmark(&buf->b_last_cursor, 0);
clear_fmark(&buf->b_last_insert, 0);
clear_fmark(&buf->b_last_change, 0);
clear_fmark(&buf->b_prompt_start, 0);
for (size_t i = 0; i < NMARKS; i++) {
free_fmark(buf->b_namedm[i]);
}
@ -2024,6 +2025,7 @@ buf_T *buflist_new(char *ffname_arg, char *sfname_arg, linenr_T lnum, int flags)
buf->b_prompt_callback.type = kCallbackNone;
buf->b_prompt_interrupt.type = kCallbackNone;
buf->b_prompt_text = NULL;
clear_fmark(&buf->b_prompt_start, 0);
return buf;
}

View File

@ -699,6 +699,7 @@ struct file_buffer {
Callback b_prompt_interrupt; // set by prompt_setinterrupt()
int b_prompt_insert; // value for restart_edit when entering
// a prompt buffer window.
fmark_T b_prompt_start; // Start of the editable area of a prompt buffer.
synblock_T b_s; // Info related to syntax highlighting. w_s
// normally points to this, but some windows

View File

@ -1746,7 +1746,27 @@ bool open_line(int dir, int flags, int second_line_indent, bool *did_do_comment)
curbuf_splice_pending++;
old_cursor = curwin->w_cursor;
int old_cmod_flags = cmdmod.cmod_flags;
char *prompt_moved = NULL;
if (dir == BACKWARD) {
// In case of prompt buffer, when we are applying 'normal O' operation on line of prompt,
// we can't add a new line before the prompt. In this case, we move the prompt text one
// line below and create a new prompt line as current line.
if (bt_prompt(curbuf) && curwin->w_cursor.lnum == curbuf->b_prompt_start.mark.lnum) {
char *prompt_line = ml_get(curwin->w_cursor.lnum);
char *prompt = prompt_text();
size_t prompt_len = strlen(prompt);
if (strncmp(prompt_line, prompt, prompt_len) == 0) {
STRMOVE(prompt_line, prompt_line + prompt_len);
// We are moving the lines but the b_prompt_start mark needs to stay in
// place so freezing marks before making the move.
cmdmod.cmod_flags = cmdmod.cmod_flags | CMOD_LOCKMARKS;
ml_replace(curwin->w_cursor.lnum, prompt_line, true);
prompt_moved = concat_str(prompt, p_extra);
p_extra = prompt_moved;
}
}
curwin->w_cursor.lnum--;
}
if ((State & VREPLACE_FLAG) == 0 || old_cursor.lnum >= orig_line_count) {
@ -1936,6 +1956,8 @@ theend:
xfree(saved_line);
xfree(next_line);
xfree(allocated);
xfree(prompt_moved);
cmdmod.cmod_flags = old_cmod_flags;
return retval;
}

View File

@ -1077,7 +1077,7 @@ check_pum:
cmdwin_result = CAR;
return 0;
}
if (bt_prompt(curbuf)) {
if ((mod_mask & MOD_MASK_SHIFT) == 0 && bt_prompt(curbuf)) {
invoke_prompt_callback();
if (!bt_prompt(curbuf)) {
// buffer changed to a non-prompt buffer, get out of
@ -1532,9 +1532,14 @@ static void init_prompt(int cmdchar_todo)
{
char *prompt = prompt_text();
curwin->w_cursor.lnum = curbuf->b_ml.ml_line_count;
if (curwin->w_cursor.lnum < curbuf->b_prompt_start.mark.lnum) {
curwin->w_cursor.lnum = curbuf->b_ml.ml_line_count;
coladvance(curwin, MAXCOL);
}
char *text = get_cursor_line_ptr();
if (strncmp(text, prompt, strlen(prompt)) != 0) {
if ((curbuf->b_prompt_start.mark.lnum == curwin->w_cursor.lnum
&& strncmp(text, prompt, strlen(prompt)) != 0)
|| curbuf->b_prompt_start.mark.lnum > curwin->w_cursor.lnum) {
// prompt is missing, insert it or append a line with it
if (*text == NUL) {
ml_replace(curbuf->b_ml.ml_line_count, prompt, true);
@ -1547,8 +1552,9 @@ static void init_prompt(int cmdchar_todo)
}
// Insert always starts after the prompt, allow editing text after it.
if (Insstart_orig.lnum != curwin->w_cursor.lnum || Insstart_orig.col != (colnr_T)strlen(prompt)) {
Insstart.lnum = curwin->w_cursor.lnum;
if (Insstart_orig.lnum != curbuf->b_prompt_start.mark.lnum
|| Insstart_orig.col != (colnr_T)strlen(prompt)) {
Insstart.lnum = curbuf->b_prompt_start.mark.lnum;
Insstart.col = (colnr_T)strlen(prompt);
Insstart_orig = Insstart;
Insstart_textlen = Insstart.col;
@ -1559,7 +1565,9 @@ static void init_prompt(int cmdchar_todo)
if (cmdchar_todo == 'A') {
coladvance(curwin, MAXCOL);
}
curwin->w_cursor.col = MAX(curwin->w_cursor.col, (colnr_T)strlen(prompt));
if (curbuf->b_prompt_start.mark.lnum == curwin->w_cursor.lnum) {
curwin->w_cursor.col = MAX(curwin->w_cursor.col, (colnr_T)strlen(prompt));
}
// Make sure the cursor is in a valid position.
check_cursor(curwin);
}
@ -1568,8 +1576,9 @@ static void init_prompt(int cmdchar_todo)
bool prompt_curpos_editable(void)
FUNC_ATTR_PURE
{
return curwin->w_cursor.lnum == curbuf->b_ml.ml_line_count
&& curwin->w_cursor.col >= (int)strlen(prompt_text());
return curwin->w_cursor.lnum > curbuf->b_prompt_start.mark.lnum
|| (curwin->w_cursor.lnum == curbuf->b_prompt_start.mark.lnum
&& curwin->w_cursor.col >= (int)strlen(prompt_text()));
}
// Undo the previous edit_putchar().

View File

@ -78,6 +78,7 @@
#include "nvim/strings.h"
#include "nvim/tag.h"
#include "nvim/types_defs.h"
#include "nvim/undo.h"
#include "nvim/version.h"
#include "nvim/vim_defs.h"
#include "nvim/window.h"
@ -8663,30 +8664,46 @@ void invoke_prompt_callback(void)
{
typval_T rettv;
typval_T argv[2];
linenr_T lnum = curbuf->b_ml.ml_line_count;
linenr_T lnum_start = curbuf->b_prompt_start.mark.lnum;
linenr_T lnum_last = curbuf->b_ml.ml_line_count;
// Add a new line for the prompt before invoking the callback, so that
// text can always be inserted above the last line.
ml_append(lnum, "", 0, false);
appended_lines_mark(lnum, 1);
curwin->w_cursor.lnum = lnum + 1;
ml_append(lnum_last, "", 0, false);
appended_lines_mark(lnum_last, 1);
curwin->w_cursor.lnum = lnum_last + 1;
curwin->w_cursor.col = 0;
if (curbuf->b_prompt_callback.type == kCallbackNone) {
return;
goto theend;
}
char *text = ml_get(lnum);
char *text = ml_get(lnum_start);
char *prompt = prompt_text();
if (strlen(text) >= strlen(prompt)) {
text += strlen(prompt);
}
char *full_text = xstrdup(text);
for (linenr_T i = lnum_start + 1; i <= lnum_last; i++) {
char *half_text = concat_str(full_text, "\n");
xfree(full_text);
full_text = concat_str(half_text, ml_get(i));
xfree(half_text);
}
argv[0].v_type = VAR_STRING;
argv[0].vval.v_string = xstrdup(text);
argv[0].vval.v_string = full_text;
argv[1].v_type = VAR_UNKNOWN;
callback_call(&curbuf->b_prompt_callback, 1, argv, &rettv);
tv_clear(&argv[0]);
tv_clear(&rettv);
theend:
// clear undo history on submit
u_clearallandblockfree(curbuf);
pos_T next_prompt = { .lnum = curbuf->b_ml.ml_line_count, .col = 1, .coladd = 0 };
RESET_FMARK(&curbuf->b_prompt_start, next_prompt, 0, ((fmarkv_T)INIT_FMARKV));
}
/// @return true when the interrupt callback was invoked.

View File

@ -469,6 +469,9 @@ fmark_T *mark_get_local(buf_T *buf, win_T *win, int name)
// to where last change was made
} else if (name == '.') {
mark = &buf->b_last_change;
// prompt start location
} else if (name == ':' && bt_prompt(buf)) {
mark = &(buf->b_prompt_start);
// Mark that are actually not marks but motions, e.g {, }, (, ), ...
} else {
mark = mark_get_motion(buf, win, name);
@ -908,6 +911,9 @@ void ex_marks(exarg_T *eap)
show_one_mark(']', arg, &curbuf->b_op_end, NULL, true);
show_one_mark('^', arg, &curbuf->b_last_insert.mark, NULL, true);
show_one_mark('.', arg, &curbuf->b_last_change.mark, NULL, true);
if (bt_prompt(curbuf)) {
show_one_mark(':', arg, &curbuf->b_prompt_start.mark, NULL, true);
}
// Show the marks as where they will jump to.
pos_T *startp = &curbuf->b_visual.vi_start;
@ -1030,6 +1036,9 @@ void ex_delmarks(exarg_T *eap)
case '^':
clear_fmark(&curbuf->b_last_insert, timestamp);
break;
case ':':
// Readonly mark. No deletion allowed.
break;
case '.':
clear_fmark(&curbuf->b_last_change, timestamp);
break;
@ -1223,6 +1232,11 @@ void mark_adjust_buf(buf_T *buf, linenr_T line1, linenr_T line2, linenr_T amount
ONE_ADJUST(&(buf->b_last_cursor.mark.lnum));
}
// on prompt buffer adjust the last prompt start location mark
if (bt_prompt(curbuf)) {
ONE_ADJUST_NODEL(&(buf->b_prompt_start.mark.lnum));
}
// list of change positions
for (int i = 0; i < buf->b_changelistlen; i++) {
ONE_ADJUST_NODEL(&(buf->b_changelist[i].mark.lnum));
@ -1712,6 +1726,9 @@ bool mark_set_local(const char name, buf_T *const buf, const fmark_T fm, const b
fm_tgt = &(buf->b_last_cursor);
} else if (name == '^') {
fm_tgt = &(buf->b_last_insert);
} else if (name == ':') {
// Readonly mark for prompt buffer. Can't be edited on user side.
return false;
} else if (name == '.') {
fm_tgt = &(buf->b_last_change);
} else {

View File

@ -4458,10 +4458,6 @@ static void nv_kundo(cmdarg_T *cap)
return;
}
if (bt_prompt(curbuf)) {
clearopbeep(cap->oap);
return;
}
u_undo(cap->count1);
curwin->w_set_curswant = true;
}
@ -6481,8 +6477,15 @@ static void nv_put_opt(cmdarg_T *cap, bool fix_indent)
}
if (bt_prompt(curbuf) && !prompt_curpos_editable()) {
clearopbeep(cap->oap);
return;
if (curwin->w_cursor.lnum == curbuf->b_prompt_start.mark.lnum) {
curwin->w_cursor.col = (int)strlen(prompt_text());
// Since we've shifted the cursor to the first editable char. We want to
// paste before that.
cap->cmdchar = 'P';
} else {
clearopbeep(cap->oap);
return;
}
}
if (fix_indent) {
@ -6613,7 +6616,7 @@ static void nv_open(cmdarg_T *cap)
} else if (VIsual_active) {
// switch start and end of visual/
v_swap_corners(cap->cmdchar);
} else if (bt_prompt(curbuf)) {
} else if (bt_prompt(curbuf) && curwin->w_cursor.lnum < curbuf->b_prompt_start.mark.lnum) {
clearopbeep(cap->oap);
} else {
n_opencmd(cap);

View File

@ -28,6 +28,7 @@
#include "nvim/indent_c.h"
#include "nvim/insexpand.h"
#include "nvim/macros_defs.h"
#include "nvim/mark.h"
#include "nvim/mbyte.h"
#include "nvim/memline.h"
#include "nvim/memory.h"
@ -695,6 +696,11 @@ const char *did_set_buftype(optset_T *args)
|| opt_strings_flags(buf->b_p_bt, opt_bt_values, NULL, false) != OK) {
return e_invarg;
}
// buftype=prompt: set the prompt start position to lastline.
if (buf->b_p_bt[0] == 'p') {
pos_T next_prompt = { .lnum = buf->b_ml.ml_line_count, .col = 1, .coladd = 0 };
RESET_FMARK(&buf->b_prompt_start, next_prompt, 0, ((fmarkv_T)INIT_FMARKV));
}
if (win->w_status_height || global_stl_height()) {
win->w_redr_status = true;
redraw_later(win, UPD_VALID);

View File

@ -3,6 +3,7 @@ local n = require('test.functional.testnvim')()
local Screen = require('test.functional.ui.screen')
local feed = n.feed
local fn = n.call
local source = n.source
local clear = n.clear
local command = n.command
@ -31,7 +32,7 @@ describe('prompt buffer', function()
close
else
" Add the output above the current prompt.
call append(line("$") - 1, 'Command: "' . a:text . '"')
call append(line("$") - 1, split('Command: "' . a:text . '"', '\n'))
" Reset &modified to allow the buffer to be closed.
set nomodified
call timer_start(20, {id -> TimerFunc(a:text)})
@ -40,7 +41,7 @@ describe('prompt buffer', function()
func TimerFunc(text)
" Add the output above the current prompt.
call append(line("$") - 1, 'Result: "' . a:text .'"')
call append(line("$") - 1, split('Result: "' . a:text .'"', '\n'))
" Reset &modified to allow the buffer to be closed.
set nomodified
endfunc
@ -245,4 +246,280 @@ describe('prompt buffer', function()
Leave
Close]])
end)
it('can insert mutli line text', function()
source_script()
feed('line 1<s-cr>line 2<s-cr>line 3')
screen:expect([[
cmd: line 1 |
line 2 |
line 3^ |
{1:~ }|
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<cr>')
-- submiting multiline text works
screen:expect([[
Result: "line 1 |
line 2 |
line 3" |
cmd: ^ |
{3:[Prompt] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
end)
it('can paste multiline text', function()
source_script()
fn('setreg', 'a', 'line 1\nline 2\nline 3')
feed('<esc>"ap')
screen:expect([[
cmd: ^line 1 |
line 2 |
line 3 |
{1:~ }|
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
|
]])
feed('i<cr>')
screen:expect([[
Result: "line 1 |
line 2 |
line 3" |
cmd: ^ |
{3:[Prompt] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
end)
it('undo works for current prompt', function()
source_script()
-- text editiing alowed in current prompt
feed('tests-initial<esc>')
feed('bimiddle-<esc>')
screen:expect([[
cmd: tests-middle^-initial|
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
|
]])
feed('Fdx')
screen:expect([[
cmd: tests-mid^le-initial |
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
|
]])
-- can undo edits until prompt has been submitted
feed('u')
screen:expect([[
cmd: tests-mid^dle-initial|
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
1 change; {MATCH:.*} |
]])
feed('u')
screen:expect([[
cmd: tests-^initial |
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
1 change; {MATCH:.*} |
]])
feed('i<cr><esc>')
screen:expect([[
cmd: tests-initial |
Command: "tests-initial" |
Result: "tests-initial" |
cmd:^ |
{3:[Prompt] }|
other buffer |
{1:~ }|*3
|
]])
-- after submit undo does nothing
feed('u')
screen:expect([[
cmd: tests-initial |
Command: "tests-initial" |
cmd:^ |
{1:~ }|
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
1 line {MATCH:.*} |
]])
end)
it('o/O can create new lines', function()
source_script()
feed('line 1<s-cr>line 2<s-cr>line 3')
screen:expect([[
cmd: line 1 |
line 2 |
line 3^ |
{1:~ }|
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<esc>koafter')
screen:expect([[
cmd: line 1 |
line 2 |
after^ |
line 3 |
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<esc>kObefore')
screen:expect([[
cmd: line 1 |
before^ |
line 2 |
after |
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<cr>')
screen:expect([[
line 2 |
after |
line 3" |
cmd: ^ |
{3:[Prompt] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('line 4<s-cr>line 5')
feed('<esc>k0oafter prompt')
screen:expect([[
after |
line 3" |
cmd: line 4 |
after prompt^ |
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<esc>k0Oat prompt')
screen:expect([[
after |
line 3" |
cmd: at prompt^ |
line 4 |
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<cr>')
screen:expect([[
line 4 |
after prompt |
line 5" |
cmd: ^ |
{3:[Prompt] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
end)
it('deleting prompt adds it back on insert', function()
source_script()
feed('asdf')
screen:expect([[
cmd: asdf^ |
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<esc>ddi')
screen:expect([[
cmd: ^ |
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('asdf')
screen:expect([[
cmd: asdf^ |
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
feed('<esc>cc')
screen:expect([[
cmd: ^ |
{1:~ }|*3
{3:[Prompt] [+] }|
other buffer |
{1:~ }|*3
{5:-- INSERT --} |
]])
end)
it(': mark follows current prompt', function()
source_script()
feed('asdf')
eq({ 1, 1 }, api.nvim_buf_get_mark(0, ':'))
feed('<cr>')
eq({ 3, 1 }, api.nvim_buf_get_mark(0, ':'))
end)
it(': mark only available in prompt buffer', function()
source_script()
feed('asdf')
eq({ 1, 1 }, api.nvim_buf_get_mark(0, ':'))
source('set buftype=')
eq(false, pcall(api.nvim_buf_get_mark, 0, ':'))
end)
end)

View File

@ -166,11 +166,12 @@ func Test_prompt_buffer_edit()
normal! i
call assert_beeps('normal! dd')
call assert_beeps('normal! ~')
call assert_beeps('normal! o')
call assert_beeps('normal! O')
call assert_beeps('normal! p')
call assert_beeps('normal! P')
call assert_beeps('normal! u')
" Nvim: these operations are supported
" call assert_beeps('normal! o')
" call assert_beeps('normal! O')
" call assert_beeps('normal! p')
" call assert_beeps('normal! P')
" call assert_beeps('normal! u')
call assert_beeps('normal! ra')
call assert_beeps('normal! s')
call assert_beeps('normal! S')