Skip to content

Commit ea3041a

Browse files
committed
fix(cli-rs): take back a paste whose mark undo restores
Since OpenTUI #2094 the edit buffer keeps the prompt's marks, and undo brings a removed paste label back with its mark and original id. The prompt forgot the id with the paste, so it skipped the restored mark, refused its own restore over it, and a submit sent the literal label. Now sync keeps each removed paste by mark id and makes it a paste again when that mark returns over its label, in mark order, as TS's syncExtmarksWithPromptParts does with the extmark's data. The undo-only fallback that added a new mark goes, with the history_edit flag that fed it: TS has none. Expanding a paste no longer removes its mark first (as TS's expandPastedText), so undoing an expand brings the paste back.
1 parent 62d8b30 commit ea3041a

5 files changed

Lines changed: 44 additions & 52 deletions

File tree

‎packages/cli-rs/crates/tui/src/app.rs‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use opencode_ui::theme::{Effect, ModeSetting, TerminalColors, ThemeMode, ThemeSt
1616
use opencode_ui::toast::Toasts;
1717
use opencode_ui::{help, palette};
1818
use opentui::app::ProxyError;
19-
use opentui::widget::{EditorAction, Textarea};
19+
use opentui::widget::Textarea;
2020

2121
use crate::features::startup::{Args, Startup};
2222
use crate::prelude::*;
@@ -385,7 +385,6 @@ pub fn mount_with(app: &App, deps: Deps, args: Args) -> Rc<Tui> {
385385
let theme = ThemeState::new(theme_options(app, &deps.config.info, files.as_ref()));
386386

387387
let textarea: Rc<RefCell<Option<Textarea>>> = Rc::default();
388-
let history_edit = Rc::new(Cell::new(false));
389388
let keybinds = Keybinds::resolve(&deps.config.keybinds(), !cfg!(windows)).unwrap_or_else(|error| {
390389
log::warn!("invalid keybinds config: {error}");
391390
Keybinds::defaults()
@@ -396,14 +395,11 @@ pub fn mount_with(app: &App, deps: Deps, args: Args) -> Rc<Tui> {
396395
leader_timeout: deps.config.leader_timeout,
397396
textarea_enabled: textarea_focused(app, &textarea),
398397
textarea_run: {
399-
let (textarea, history_edit) = (textarea.clone(), history_edit.clone());
398+
let textarea = textarea.clone();
400399
Rc::new(move |action| {
401400
let current = textarea.borrow().clone();
402401
current.is_some_and(|textarea| {
403-
// Undo and redo may put back a paste label whose mark an edit removed.
404-
history_edit.set(matches!(action, EditorAction::Undo | EditorAction::Redo));
405402
textarea.perform(action);
406-
history_edit.set(false);
407403
true
408404
})
409405
})
@@ -436,11 +432,7 @@ pub fn mount_with(app: &App, deps: Deps, args: Args) -> Rc<Tui> {
436432
theme: RefCell::new(theme),
437433
directory: deps.directory,
438434
textarea,
439-
prompt: crate::prompt::editor::Shared::new(
440-
app,
441-
deps.roots.as_ref().map(|roots| roots.paths.state.clone()),
442-
history_edit,
443-
),
435+
prompt: crate::prompt::editor::Shared::new(app, deps.roots.as_ref().map(|roots| roots.paths.state.clone())),
444436
settings,
445437
startup: Startup::new(args),
446438
plugins,

‎packages/cli-rs/crates/tui/src/prompt/autocomplete/parts.rs‎

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,13 @@ impl Kind {
5757
#[derive(Default)]
5858
pub struct Parts {
5959
marks: RefCell<Vec<(ExtmarkId, Kind, usize)>>,
60-
/// Pastes whose marks an edit removed. TS keeps a paste's text on its extmark, which undo brings back; the
61-
/// textarea here does not restore marks, so [`restore_pastes`] does after an undo or redo.
60+
/// Pastes whose marks an edit removed. TS keeps a paste's text on its extmark; here [`sync`] keeps it by mark id
61+
/// until undo or redo brings the mark back.
6262
dropped: RefCell<Vec<Dropped>>,
6363
}
6464

6565
struct Dropped {
66-
start: u32,
67-
end: u32,
66+
id: ExtmarkId,
6867
label: String,
6968
text: String,
7069
}
@@ -165,19 +164,35 @@ pub fn sync(prompt: &Inner) {
165164
let mut marks = prompt.textarea.extmarks();
166165
// Marks in creation order, as TS lists them.
167166
marks.sort_by_key(|mark| mark.id);
168-
let mapped = prompt.parts.marks.borrow().clone();
167+
let mut mapped = prompt.parts.marks.borrow().clone();
169168
let mut info = prompt.prompt.borrow_mut();
169+
let mut dropped = prompt.parts.dropped.borrow_mut();
170170
let gone = mapped.iter().filter(|(id, kind, _)| *kind == Kind::Pasted && !marks.iter().any(|mark| mark.id == *id));
171-
prompt.parts.dropped.borrow_mut().extend(gone.filter_map(|&(_, _, index)| {
171+
dropped.extend(gone.filter_map(|&(id, _, index)| {
172172
let paste = &info[Kind::Pasted.key()][index];
173-
let offset = |key: &str| paste["source"][key].as_u64().map(|offset| offset as u32);
174173
Some(Dropped {
175-
start: offset("start")?,
176-
end: offset("end")?,
174+
id,
177175
label: paste["source"]["text"].as_str()?.to_owned(),
178176
text: paste["text"].as_str()?.to_owned(),
179177
})
180178
}));
179+
// Undo and redo bring back a removed paste's mark with its id; it is the paste again while its label is there.
180+
for mark in &marks {
181+
let Some(at) = dropped.iter().position(|paste| paste.id == mark.id) else { continue };
182+
if prompt.textarea.buffer().range(mark.start, mark.end).unwrap_or_default() != dropped[at].label {
183+
continue;
184+
}
185+
let paste = dropped.remove(at);
186+
if !info[Kind::Pasted.key()].is_array() {
187+
info[Kind::Pasted.key()] = json!([]);
188+
}
189+
let Some(pasted) = info[Kind::Pasted.key()].as_array_mut() else { continue };
190+
pasted.push(
191+
json!({ "text": paste.text, "source": { "start": mark.start, "end": mark.end, "text": paste.label } }),
192+
);
193+
mapped.push((mark.id, Kind::Pasted, pasted.len() - 1));
194+
}
195+
drop(dropped);
181196
let mut lists: [Vec<(Option<ExtmarkId>, Value)>; 4] = Default::default();
182197
for mark in &marks {
183198
let Some(&(_, kind, index)) = mapped.iter().find(|(id, _, _)| *id == mark.id) else { continue };
@@ -222,30 +237,6 @@ pub fn sync(prompt: &Inner) {
222237
*prompt.parts.marks.borrow_mut() = next;
223238
}
224239

225-
/// After an undo or redo, brings back each dropped paste whose label is again where its mark was, so a submit sends
226-
/// its text (#63265). A fragment of the label stays plain text.
227-
pub fn restore_pastes(prompt: &Inner) {
228-
let textarea = &prompt.textarea;
229-
let marks = textarea.extmarks();
230-
let dropped = std::mem::take(&mut *prompt.parts.dropped.borrow_mut());
231-
let (back, kept): (Vec<_>, Vec<_>) = dropped.into_iter().partition(|paste| {
232-
textarea.buffer().range(paste.start, paste.end).unwrap_or_default() == paste.label
233-
&& !marks.iter().any(|mark| mark.start < paste.end && paste.start < mark.end)
234-
});
235-
*prompt.parts.dropped.borrow_mut() = kept;
236-
let mut info = prompt.prompt.borrow_mut();
237-
for paste in back {
238-
let mark = textarea.add_extmark(paste.start..paste.end, options(prompt, Kind::Pasted));
239-
if !info[Kind::Pasted.key()].is_array() {
240-
info[Kind::Pasted.key()] = json!([]);
241-
}
242-
let Some(pasted) = info[Kind::Pasted.key()].as_array_mut() else { return };
243-
let source = json!({ "start": paste.start, "end": paste.end, "text": paste.label });
244-
pasted.push(json!({ "text": paste.text, "source": source }));
245-
prompt.parts.track(mark, Kind::Pasted, pasted.len() - 1);
246-
}
247-
}
248-
249240
/// Whether the prompt has a `kind` part whose `field` is `value` (`hasSkill`).
250241
pub fn has(prompt: &Inner, kind: Kind, field: &str, value: &Value) -> bool {
251242
prompt.prompt.borrow()[kind.key()].as_array().is_some_and(|list| list.iter().any(|part| &part[field] == value))

‎packages/cli-rs/crates/tui/src/prompt/editor.rs‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,6 @@ pub struct Shared {
6262
pub stash: super::stash::Stash,
6363
/// The prompt last mounted, whose pastes a copy expands (E.4).
6464
pub(crate) current: RefCell<Weak<Inner>>,
65-
/// Set while the keymap runs an undo or redo in the focused textarea.
66-
pub(crate) history_edit: Rc<Cell<bool>>,
6765
/// Each session's last pending interrupt request, which waits for the ones before it. It outlives the composer
6866
/// that sent it, as a later prompt from another composer must still wait for it.
6967
interrupts: RefCell<std::collections::HashMap<SessionID, Interrupt>>,
@@ -72,13 +70,12 @@ pub struct Shared {
7270
type Interrupt = futures_util::future::Shared<BoxFuture<'static, ()>>;
7371

7472
impl Shared {
75-
pub fn new(app: &App, state: Option<PathBuf>, history_edit: Rc<Cell<bool>>) -> Self {
73+
pub fn new(app: &App, state: Option<PathBuf>) -> Self {
7674
Self {
7775
history: History::load(app, state.clone()),
7876
drafts: Drafts::default(),
7977
stash: super::stash::Stash::load(app, state),
8078
current: RefCell::new(Weak::new()),
81-
history_edit,
8279
complete: autocomplete::Shared::default(),
8380
editor: editor_context::Context::default(),
8481
interrupts: RefCell::default(),
@@ -877,9 +874,6 @@ fn connect(inner: &Rc<Inner>) {
877874
inner.textarea.on_change(bind!(inner, |prompt, _, text| {
878875
prompt.prompt.borrow_mut()["text"] = Value::String(text.to_owned());
879876
parts::sync(&prompt);
880-
if prompt.tui.upgrade().is_some_and(|tui| tui.prompt.history_edit.get()) {
881-
parts::restore_pastes(&prompt);
882-
}
883877
prompt.parts_changed();
884878
if let Some(complete) = prompt.autocomplete.get() {
885879
complete.on_input(text);

‎packages/cli-rs/crates/tui/src/prompt/paste.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ fn expand(prompt: &Inner, mark: opentui::widget::ExtmarkId) -> bool {
293293
let Some(extmark) = textarea.extmark(mark) else { return false };
294294
let Some((Kind::Pasted, index)) = prompt.parts.get(mark) else { return false };
295295
let Some(text) = prompt.prompt.borrow()["pasted"][index]["text"].as_str().map(str::to_owned) else { return false };
296-
textarea.remove_extmark(mark);
296+
// The edit removes the mark, so undo brings it back with the label.
297297
parts::replace(textarea, extmark.start, extmark.end, &text);
298298
true
299299
}

‎packages/cli-rs/crates/tui/tests/all/attach.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,21 @@ fn undo_submits_the_restored_paste_and_a_fragment_as_text() {
174174
setup.wait_text(restored);
175175
assert_eq!(setup.submit()["text"], sent, "fragment: {fragment}");
176176
}
177+
// An expanded paste comes back on undo as the paste.
178+
let setup = Setup::new();
179+
setup.app.paste("a\nb\nc");
180+
setup.app.type_text("and ");
181+
setup.app.paste("d\ne\nf");
182+
setup.wait_text("[Pasted ~3 lines] and [Pasted ~3 lines] ");
183+
let (x, y) = setup.find("[Pasted");
184+
setup.app.click(x, y);
185+
setup.wait_text("a\nb\nc and [Pasted ~3 lines] ");
186+
// One undo for the inserted text, one for the deleted label.
187+
setup.app.press("super+z");
188+
setup.wait_text(" and [Pasted ~3 lines] ");
189+
setup.app.press("super+z");
190+
setup.wait_text("[Pasted ~3 lines] and [Pasted ~3 lines] ");
191+
assert_eq!(setup.submit()["text"], "a\nb\nc and d\ne\nf ");
177192
}
178193

179194
#[test]

0 commit comments

Comments
 (0)