feat(plugin): multi-line text input via ui.input-multiline (#1968)

* feat(plugin): multi-line text input via ui.input-multiline

Adds host::input_multiline: a multi-line text dialog pre-filled with a
value, dismissed to none, confirmed with Cmd/Ctrl+Enter. Same rules as
input (command or event of a window, no capability, wait not charged).

Prompt::Input gains a multiline flag the frontend renders as a
textarea; the jobs fixture, SDK (wasm + mock), WIT and the plugin API
table are updated, and all plugin wasms rebuilt.

Fixes #1946

* chore: regenerate bindings after merging detail-provider rename
This commit is contained in:
arookieofc authored and GitHub committed 2026-11-07 19:58:32 +08:00
1 parent e68b35f534
commit 2945d20208
54 files changed
+160 -43

No files matched your search

+7 -2
View File
@@ -2339,9 +2339,14 @@ fn commands_ask_the_user_and_reveal_their_views() {
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
assert_eq!(result.unwrap().as_deref(), Some("Other"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()), multiline: false });
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "input-multiline", Some("one\ntwo"), Some(&["a\nb"]), none);
assert_eq!(result.unwrap().as_deref(), Some("a\nb"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("one\ntwo".into()), multiline: true });
assert_eq!(run_and_answer(&f, "input-multiline", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
assert_eq!(result.unwrap().as_deref(), Some("true"));
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
@@ -2360,7 +2365,7 @@ fn commands_ask_the_user_and_reveal_their_views() {
// Without a window there is nothing to attach the dialog to.
f.take_events();
let no_window = Invocation { client: None, ..f.inv() };
for cmd in ["pick", "input", "confirm"] {
for cmd in ["pick", "input", "input-multiline", "confirm"] {
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
}
+3 -2
View File
@@ -77,14 +77,15 @@ pub struct PickItem {
pub description: Option<String>,
}
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
/// `ui.input-multiline`, `ui.confirm`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Prompt {
#[serde(rename_all = "camelCase")]
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
#[serde(rename_all = "camelCase")]
Input { prompt: String, value: Option<String> },
Input { prompt: String, value: Option<String>, multiline: bool },
#[serde(rename_all = "camelCase")]
Confirm { title: String, message: String },
}
+8 -1
View File
@@ -355,7 +355,14 @@ impl ui::Host for StoreData {
}
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
let prompt =
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: false };
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
}
fn input_multiline(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
let prompt =
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: true };
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
}
Binary file not shown.
@@ -1 +1 @@
8a4006a730cf82fa412e28c1d181cdb96b7d16e5fe9a635b735d96d06862a80b
19b672d86ff84edbbdb32ba06aefba08344e28b00310c09260d935a6fd2d6d92
Binary file not shown.
@@ -1 +1 @@
f576b03db2e7bfaad3d5fc8c562c678c245d096bfa0c52bcadba4663991012bc
5916e03cba741eea838c99757a50b86ecae020a75dc856d0301a5afcd4beb437
Binary file not shown.
@@ -1 +1 @@
52ae124ebab17dbbb182997efe13bdcd48943f3fcb7730f1cdf466cdbd1fc40b
cb9b6f980b976d04ff25289a7978fe6f0b1f77e99ac9bdbaa8fd0720e8306f29
Binary file not shown.
@@ -1 +1 @@
5bfac74cff6560738bbd639691211219cfe731e260cf02bb86aec3bd8f4edc06
e399272912a4664c9ce307ff128ce2059a2276e31b7806e1553870c4041c27b5
Binary file not shown.
@@ -1 +1 @@
4a9eadbf94cc03648fd51e2ed892d23bdae68084762098981dce2a15b97c3fbd
5ad3afb9518e1e2d06e05b5ba5d7339b8aafa7d03502cad00bdacbfe73419775
Binary file not shown.
@@ -1 +1 @@
2c3be2b558b10824cd2420df5de0b7dee30c68a63cffc3f03cfae16ee2d96574
93840a797e8f6d86e3d75432395370f19e6a632444d8441b346b9db6c32cd6f9
+1
View File
@@ -75,6 +75,7 @@ impl Plugin for Jobs {
Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(","))))
}
"input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
"input-multiline" => Ok(Some(host::input_multiline("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
"confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())),
"reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None),
"selection" => {
Binary file not shown.
@@ -1 +1 @@
e1cbbe2c814e554ef9622204ddcfce87c4d1919993d5e78f9739dfaa46ba128c
44a29b9c311e10a055625fc43940fb9ecd361639f8a11c992ac6817f39e95e36
Binary file not shown.
@@ -1 +1 @@
332840c4966244f309be9853c44caae56a5706bc102b9df0b4de4475a2a5d2fd
36a33de65d506d7753722d38aea7576722fba583ef615de502a5e341b781ef18
Binary file not shown.
@@ -1 +1 @@
ac8c4a1b4b4df9c3ffd4725e321df3f7836955a1c052387a276f0ad075c85527
a4f9b7a13a7c2aa1243359df865e659eb4b8f86fb6623d743dcc17988a10e183
+1
View File
@@ -381,6 +381,7 @@ then run `openidea-plugin check`.
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Asks the user to pick from `PickItem`s and waits: the chosen key(s), or `None` when dismissed. Only from a command or event of a window (else `invalid`); the wait isn't charged to the time limit. |
| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. |
| `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Same rules as `input`. |
| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. |
| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. |
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
e6d7996c21d41d1cfe585c1037fbe4b04ba523c5fdba676f0852ee8a8438f6c9
103165fc45ab6a995cdddd10848ae81f3f137438c1981375e6741185813ccdb3
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2cc22faa9f1a4b4c27d22f740144e5968083444898c36574d1b3e7780e003dc0
e0845829cfaa2d8f471ccfd8a41726757443df6591e1e06be9e499d6781c02fe
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2ed5f73c78e165a494d5f91f6ed2b55682e28a9d4e516553c9332b9d8ab84ab4
6490dcca17d4d87336c70bbf8fbfac90f711ed0079f43d16534703b179211069
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3274e8023091ec67b018d10e67e6180c3bc875747d51779a3e67ebfe76178907
aa807e1f712896e0ffe1f16115bd8ab991b59df5fcae73e97b6a40d6c94aaf8f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6060665c10f800787c9c7d03fbbd7dcdefb9fe475827b6b6902fdd72324f7d98
225ca906d21299fa45059c11d12468e6c3fc577aebb59441c51774a760e54a26
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
929cbc24e4c07131e8a46aa333632b703909b6d978b36b474207f6ca233cc9c1
189e378fc52adfb058243feef4e100835600a1d4751adc23a262d13e703ef5e9
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2f26af0ac018d56a0f93a7060ac0a4180b9760c20a98465ef9d4a6f06878455b
2b2e162de966b670918b1bbe937c34cf9bef014834fb38be820ec25f20b09159
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7aafd74237661ec2907a4c8950b685a17bed760a10a2fc1062139171636e7f2d
6d13cd671dfed3c9c73be0e40b81cc5f757e4f8ebf6a9eecba0001fa8c314d47
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
af6ba8a45e97858fbc9c530bdb8e9e56c6229c9715a91bd543399fa0bc7cc04f
a941b31f326427d41cf3e62373bc421d086789a0ac1cb8c3647eafafe29c5834
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
e9f8ce2cf2d1cc4bcd05f4fed106d11542a2fa4b12e256fe675cbb0aa41dd3af
86428f64ea11d4863794595f12a9c100423725981d66d6f2a862191a0951fecf
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
737d1a1eb063a6757e93f0359fc32ab2b624ef8c251a4cba31aa3c36f7587002
fc3b11c7b09f9a57c319af579efdd6f00d9ed51dccc6e86404c5164c6460d007
+10
View File
@@ -117,6 +117,10 @@ mod imp {
wit::ui::input(prompt, value).map_err(error)
}
pub fn input_multiline(prompt: &str, value: Option<&str>) -> Result<Option<String>, Error> {
wit::ui::input_multiline(prompt, value).map_err(error)
}
pub fn confirm(title: &str, body: &str) -> Result<bool, Error> {
wit::ui::confirm(title, body).map_err(error)
}
@@ -730,6 +734,12 @@ pub fn input(prompt: &str, value: Option<&str>) -> Result<Option<String>, Error>
imp::input(prompt, value)
}
/// Asks for text over several lines, pre-filled with `value`; `None` when
/// dismissed. Same rules as [`input`]; the dialog confirms on Cmd/Ctrl+Enter.
pub fn input_multiline(prompt: &str, value: Option<&str>) -> Result<Option<String>, Error> {
imp::input_multiline(prompt, value)
}
/// Asks for confirmation: `true` on OK, `false` on Cancel or Escape.
/// Same rules as [`pick`].
pub fn confirm(title: &str, body: &str) -> Result<bool, Error> {
+8
View File
@@ -491,6 +491,10 @@ pub mod current {
Ok(with(|s| s.answers.pop_front().flatten()).and_then(|a| a.into_iter().next()))
}
pub fn input_multiline(_prompt: &str, _value: Option<&str>) -> Result<Option<String>, Error> {
Ok(with(|s| s.answers.pop_front().flatten()).and_then(|a| a.into_iter().next()))
}
pub fn confirm(_title: &str, _body: &str) -> Result<bool, Error> {
Ok(with(|s| s.answers.pop_front().flatten()).is_some())
}
@@ -937,6 +941,10 @@ mod tests {
assert!(matches!(host::pick_many("Pick", &items), Err(Error::Invalid(_))));
mock.answer_next_prompt(Some(&["x.txt"]));
assert_eq!(host::input("Name", Some("a.txt")).unwrap().as_deref(), Some("x.txt"));
mock.answer_next_prompt(Some(&["a\nb"]));
assert_eq!(host::input_multiline("Notes", None).unwrap().as_deref(), Some("a\nb"));
mock.answer_next_prompt(None);
assert_eq!(host::input_multiline("Notes", None).unwrap(), None);
mock.answer_next_prompt(Some(&[]));
assert!(host::confirm("Sure?", "Really").unwrap());
mock.answer_next_prompt(None);
+3
View File
@@ -62,6 +62,9 @@ interface ui {
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for text over several lines, pre-filled with `value`; `none` when
/// dismissed. Same rules as `input`; the dialog confirms on Cmd/Ctrl+Enter.
input-multiline: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for confirmation with OK and Cancel: `true` on OK, `false` on
/// Cancel or Escape. Same rules as `pick`.
confirm: func(title: string, body: string) -> result<bool, error>;
+5 -2
View File
@@ -6284,8 +6284,11 @@ export type ProjectStructureSettings = {
excludeFiles?: string,
};
/** What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`). */
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null } | { kind: "confirm"; title: string; message: string };
/**
* What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
* `ui.input-multiline`, `ui.confirm`).
*/
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string };
export type ProxyKind = "http" | "socks";
@@ -536,13 +536,33 @@ describe("plugin prompts", () => {
type: "promptRequested",
pluginId: "p",
promptId: "2",
prompt: { kind: "input", prompt: "Name", value: "Main" },
prompt: { kind: "input", prompt: "Name", value: "Main", multiline: false },
});
});
await user.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("2", null));
});
it("asks multi-line text and confirms on Ctrl+Enter", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<InputDialogsHost />);
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId: "5",
prompt: { kind: "input", prompt: "Notes", value: "one", multiline: true },
});
});
const area = await screen.findByRole("textbox");
expect(area.tagName).toBe("TEXTAREA");
await user.clear(area);
await user.type(area, "a{Enter}b{Control>}{Enter}{/Control}");
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("5", ["a\nb"]));
});
it("returns the typed text untrimmed", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
@@ -553,7 +573,7 @@ describe("plugin prompts", () => {
type: "promptRequested",
pluginId: "p",
promptId: "4",
prompt: { kind: "input", prompt: "Name", value: " Main " },
prompt: { kind: "input", prompt: "Name", value: " Main ", multiline: false },
});
});
await user.click(await screen.findByRole("button", { name: "OK" }));
@@ -569,7 +589,7 @@ describe("plugin prompts", () => {
type: "promptRequested",
pluginId: "p",
promptId: "3",
prompt: { kind: "input", prompt: "Name", value: null },
prompt: { kind: "input", prompt: "Name", value: null, multiline: false },
});
});
await screen.findByRole("dialog");
+1
View File
@@ -327,6 +327,7 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] |
initial: prompt.value ?? "",
validate: () => null,
keepWhitespace: true,
multiline: prompt.multiline,
signal,
});
return text === null ? null : [text];
+28
View File
@@ -32,3 +32,31 @@
.activeRow {
color: var(--fg-strong);
}
.area {
resize: vertical;
min-height: 60px;
max-height: 240px;
padding: 4px 8px;
border: 0;
border-radius: var(--radius-control, 4px);
background: var(--bg-field, var(--bg-button));
box-shadow: inset 0 0 0 1px var(--border-button);
color: var(--fg-strong);
font-family: inherit;
font-size: var(--fs-ui);
line-height: 20px;
outline: none;
}
.area:focus {
box-shadow:
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
.area[aria-invalid="true"] {
box-shadow:
inset 0 0 0 1px var(--danger),
0 0 0 1px var(--danger);
}
.area::placeholder {
color: var(--fg-faint);
}
+39 -13
View File
@@ -31,6 +31,8 @@ export interface PromptInputOptions {
signal?: AbortSignal;
/** Resolve with the text as typed instead of trimmed. */
keepWhitespace?: boolean;
/** A multi-line field: Enter adds a line, Cmd/Ctrl+Enter confirms. */
multiline?: boolean;
}
export interface ChooseItemOptions {
@@ -171,19 +173,43 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v
<T>{options.label}</T>
</label>
)}
<Input
data-autofocus
aria-label={options.label ?? options.title}
value={value}
placeholder={options.placeholder}
disabled={pending}
invalid={(touched || !!serverError) && !!error}
onChange={(e) => {
setTouched(true);
setValue(e.target.value);
setServerError(null);
}}
/>
{options.multiline ? (
<textarea
data-autofocus
aria-label={options.label ?? options.title}
className={styles.area}
rows={5}
value={value}
placeholder={options.placeholder}
disabled={pending}
aria-invalid={(touched || !!serverError) && !!error}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
void submit();
}
}}
onChange={(e) => {
setTouched(true);
setValue(e.target.value);
setServerError(null);
}}
/>
) : (
<Input
data-autofocus
aria-label={options.label ?? options.title}
value={value}
placeholder={options.placeholder}
disabled={pending}
invalid={(touched || !!serverError) && !!error}
onChange={(e) => {
setTouched(true);
setValue(e.target.value);
setServerError(null);
}}
/>
)}
{(touched || !!serverError) && error && (
<div className={styles.error}>
<T>{error}</T>
+3
View File
@@ -62,6 +62,9 @@ interface ui {
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for text over several lines, pre-filled with `value`; `none` when
/// dismissed. Same rules as `input`; the dialog confirms on Cmd/Ctrl+Enter.
input-multiline: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for confirmation with OK and Cancel: `true` on OK, `false` on
/// Cancel or Escape. Same rules as `pick`.
confirm: func(title: string, body: string) -> result<bool, error>;