You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/15 07:56:25 UTC

(maka) branch rust-preview updated: fix(tui): prioritize copy and draft clearing before double interrupt exit

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/rust-preview by this push:
     new ebec40f611 fix(tui): prioritize copy and draft clearing before double interrupt exit
ebec40f611 is described below

commit ebec40f611b57effeb2d3ae2a5fbd573155f59bf
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Oct 15 15:56:06 2026 +0800

    fix(tui): prioritize copy and draft clearing before double interrupt exit
    
    Show submitted messages immediately with muted pending styling and reconcile accepted messages by canonical identity. Replace the current-run queue notice with history feedback.
---
 packages/plugin-sdk/src/contracts.ts               |  10 +-
 packages/plugin-sdk/src/generated/runtime.ts       |   1 +
 platform/render-terminal/src/application.rs        |  31 ++-
 .../src/application/context_menu.rs                |   2 +-
 platform/render-terminal/src/application/input.rs  |   8 +
 .../render-terminal/src/application/resources.rs   |   3 +-
 platform/render-terminal/src/application/run.rs    | 141 ++++++++++++--
 platform/render-terminal/src/application/source.rs |   2 +-
 .../render-terminal/src/application/terminal.rs    |   9 +-
 .../render-terminal/src/application/tests/input.rs |   5 +
 .../src/application/tests/textarea.rs              |  71 +++++++
 .../render-terminal/src/application/textarea.rs    |   2 +-
 platform/render-terminal/src/editor.rs             |  10 +
 plugins/agent/agent-screen/component-events.ts     |  33 ++++
 plugins/agent/agent-screen/conversation-view.ts    |  39 ++--
 plugins/agent/agent-screen/messages.ts             |  39 +++-
 plugins/agent/agent-screen/state.ts                |   3 +
 plugins/agent/api/src/contract.rs                  |   1 +
 plugins/agent/composer/component.ts                |  39 +++-
 plugins/agent/native/src/owner.rs                  |   1 +
 plugins/shared/components.ts                       |  14 +-
 plugins/shared/contracts.ts                        |  49 +++--
 plugins/shared/tui-messages.ts                     |   6 +-
 products/maka/bundle/tests/composer.test.mjs       |  71 ++++++-
 products/maka/bundle/tests/task-workbench.test.mjs | 207 +++++++++++++++++++++
 products/maka/bundle/tests/timeline.test.mjs       |  57 ++++++
 .../cli/tests/integration/terminal/application.rs  |  23 ++-
 .../tests/integration/terminal/product/executor.rs |   2 +-
 .../integration/terminal/product/executor_setup.rs |   2 +-
 29 files changed, 797 insertions(+), 84 deletions(-)

diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts
index cce025d353..39de88b2b2 100644
--- a/packages/plugin-sdk/src/contracts.ts
+++ b/packages/plugin-sdk/src/contracts.ts
@@ -9428,13 +9428,16 @@ export const contracts = {
                   invocationId: {
                     type: 'string',
                   },
+                  messageId: {
+                    type: 'string',
+                  },
                   kind: {
                     type: 'string',
                     const: 'started',
                   },
                 },
                 additionalProperties: false,
-                required: ['kind', 'sessionId', 'invocationId'],
+                required: ['kind', 'sessionId', 'invocationId', 'messageId'],
               },
               {
                 type: 'object',
@@ -21083,13 +21086,16 @@ export const contracts = {
                   invocationId: {
                     type: 'string',
                   },
+                  messageId: {
+                    type: 'string',
+                  },
                   kind: {
                     type: 'string',
                     const: 'started',
                   },
                 },
                 additionalProperties: false,
-                required: ['kind', 'sessionId', 'invocationId'],
+                required: ['kind', 'sessionId', 'invocationId', 'messageId'],
               },
               {
                 type: 'object',
diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts
index c14178cb31..388891ab67 100644
--- a/packages/plugin-sdk/src/generated/runtime.ts
+++ b/packages/plugin-sdk/src/generated/runtime.ts
@@ -4421,6 +4421,7 @@ export type AgentSendResult =
   | {
       sessionId: string;
       invocationId: string;
+      messageId: string;
       kind: 'started';
     }
   | {
diff --git a/platform/render-terminal/src/application.rs b/platform/render-terminal/src/application.rs
index 8f7736a185..29c9d1cd4b 100644
--- a/platform/render-terminal/src/application.rs
+++ b/platform/render-terminal/src/application.rs
@@ -156,7 +156,7 @@ pub trait Widget: std::any::Any {
     fn pointer_capture(&self, _: &dyn std::any::Any) -> bool {
         false
     }
-    fn copy(&self, _: &dyn std::any::Any) -> maka_ui_runtime::Result<Option<String>> {
+    fn copy(&self, _: &dyn std::any::Any, _: bool) -> maka_ui_runtime::Result<Option<String>> {
         Ok(None)
     }
     fn capture(
@@ -1020,6 +1020,26 @@ impl Presenter {
                     && self.application.mount_visible(&target.mount)
                     && !self.application.is_retiring(&target.mount) =>
             {
+                // Native editing gets the first opportunity to consume Ctrl+C.
+                // This uses the same owner and observation path as ordinary edits.
+                if run::is_interrupt(event) && !target.area.is_empty() {
+                    match self.resources.input(&target, event)? {
+                        Some(WidgetInput::Consumed(observed)) => {
+                            if let Some(observed) = observed {
+                                self.resources.observe(&target.mount, observed)?;
+                            }
+                            self.sync_native()?;
+                            self.resources.redraw = true;
+                            return Ok(true);
+                        }
+                        Some(WidgetInput::Emitted(input)) => {
+                            self.accept_native_input(&target.mount, input, None)?;
+                            self.sync_native()?;
+                            return Ok(true);
+                        }
+                        _ => {}
+                    }
+                }
                 target.ancestors
             }
             Some(_) => return Ok(false),
@@ -1101,6 +1121,13 @@ impl Presenter {
     /// Copy only the focused original live native scope through the bounded
     /// terminal writer. An OSC52 write is not an OS clipboard acknowledgement.
     pub fn copy(&mut self, writer: &mut impl std::io::Write) -> maka_ui_runtime::Result<bool> {
+        self.copy_scope(writer, false)
+    }
+    fn copy_scope(
+        &mut self,
+        writer: &mut impl std::io::Write,
+        selection_only: bool,
+    ) -> maka_ui_runtime::Result<bool> {
         if !self.physical_admitted() {
             return Ok(false);
         }
@@ -1113,7 +1140,7 @@ impl Presenter {
         if !self.target_admitted(&target) {
             return Ok(false);
         }
-        let text = match self.resources.copy(&target)? {
+        let text = match self.resources.copy(&target, selection_only)? {
             Some(text) => Some(text),
             None => match self.item_reading_scope(&target) {
                 Some((scope, _)) => scope.copy()?,
diff --git a/platform/render-terminal/src/application/context_menu.rs b/platform/render-terminal/src/application/context_menu.rs
index 199240feaa..e649cf4e73 100644
--- a/platform/render-terminal/src/application/context_menu.rs
+++ b/platform/render-terminal/src/application/context_menu.rs
@@ -143,7 +143,7 @@ impl Presenter {
                 .clone()
                 .filter(|focus| self.target_admitted(focus))
         {
-            opening.selection = match self.resources.copy(&focus)? {
+            opening.selection = match self.resources.copy(&focus, false)? {
                 Some(text) => Some(text.into()),
                 None => self
                     .item_reading_scope(&focus)
diff --git a/platform/render-terminal/src/application/input.rs b/platform/render-terminal/src/application/input.rs
index 034889dade..6553f65633 100644
--- a/platform/render-terminal/src/application/input.rs
+++ b/platform/render-terminal/src/application/input.rs
@@ -188,6 +188,14 @@ impl Widget for Recipe {
             .capture(target)
     }
 
+    fn copy(&self, state: &dyn Any, _: bool) -> maka_ui_runtime::Result<Option<String>> {
+        let owner = state.downcast_ref::<Owner>().unwrap();
+        if self.secret || self.props.disabled {
+            return Ok(None);
+        }
+        let text = owner.editor.selected_text();
+        Ok((!text.is_empty()).then(|| text.to_owned()))
+    }
     fn measure(&self, slots: &Slots<'_>, width: u16) -> maka_ui_runtime::Result<(u16, u32)> {
         let _ = slots.native_state::<Owner>()?;
         Ok((
diff --git a/platform/render-terminal/src/application/resources.rs b/platform/render-terminal/src/application/resources.rs
index cb826c6072..3593030ade 100644
--- a/platform/render-terminal/src/application/resources.rs
+++ b/platform/render-terminal/src/application/resources.rs
@@ -552,8 +552,9 @@ impl Resources {
     pub fn copy(
         &mut self,
         target: &paint::TargetIdentity,
+        selection_only: bool,
     ) -> maka_ui_runtime::Result<Option<String>> {
-        self.read_native(target, |recipe, state| recipe.copy(state))?
+        self.read_native(target, |recipe, state| recipe.copy(state, selection_only))?
             .unwrap_or(Ok(None))
     }
     pub fn present_focus(&mut self, target: &paint::TargetIdentity) -> bool {
diff --git a/platform/render-terminal/src/application/run.rs b/platform/render-terminal/src/application/run.rs
index ff99eef036..fcf3ad214c 100644
--- a/platform/render-terminal/src/application/run.rs
+++ b/platform/render-terminal/src/application/run.rs
@@ -176,6 +176,7 @@ pub fn run(
     let mut issued_sequence = 0u64;
     let mut written_sequence = 0u64;
     let mut exit_key = ExitKey::default();
+    let mut interrupt = InterruptWindow::default();
     let mut queued_input: Option<QueuedInput> = None;
     loop {
         if let Err(error) = poll_output(
@@ -408,6 +409,7 @@ pub fn run(
                 if exit_key.ready(Some(&input), Instant::now()) {
                     return Ok(true);
                 }
+                interrupt.observe(&input);
                 if let Some(activation) = presenter.capture_activation(&input)
                     && presenter.activation_admission(&activation) == Admission::Pending
                 {
@@ -431,16 +433,23 @@ pub fn run(
                     wake.0.notify_one();
                     return Ok(false);
                 }
-                if is_copy(&input) {
+                if let Some(selection_only) = copy_gesture(&input, cfg!(target_os = "macos")) {
                     let mut bytes = Vec::new();
-                    if let Err(error) = presenter.copy(&mut bytes) {
-                        presenter.report(&error);
-                    }
+                    let copied = match presenter.copy_scope(&mut bytes, selection_only) {
+                        Ok(copied) => copied,
+                        Err(error) => {
+                            presenter.report(&error);
+                            true
+                        }
+                    };
                     if let Err(error) = retain_clipboard(&mut clipboard_output, bytes) {
                         presenter.report(&error);
                     }
-                    wake.0.notify_one();
-                    return Ok(false);
+                    if copied || !is_interrupt(&input) {
+                        interrupt.0 = None;
+                        wake.0.notify_one();
+                        return Ok(false);
+                    }
                 }
                 if is_exit(&input) && !presenter.captures_keyboard().map_err(|e| e.to_string())? {
                     let mut captured = None;
@@ -456,12 +465,20 @@ pub fn run(
                     let Event::Key(key) = input else {
                         unreachable!()
                     };
-                    return Ok(!handled
-                        && exit_key.request(
-                            key.code,
-                            guard.reports_key_releases(),
-                            Instant::now(),
-                        ));
+                    if handled {
+                        interrupt.0 = None;
+                        return Ok(false);
+                    }
+                    let now = Instant::now();
+                    if matches!(key.code, KeyCode::Char('c' | 'C')) && !interrupt.request(now) {
+                        return Ok(false);
+                    }
+                    return Ok(exit_key.request(key.code, guard.reports_key_releases(), now));
+                }
+                // Captured terminal input belongs to its child process, never
+                // to a previously armed application exit gesture.
+                if is_interrupt(&input) {
+                    interrupt.0 = None;
                 }
                 let scrolling = is_wheel(&input);
                 let captured = if scrolling {
@@ -568,11 +585,62 @@ fn is_wheel(event: &Event) -> bool {
     matches!(event, Event::Mouse(mouse) if matches!(mouse.kind,
         crossterm::event::MouseEventKind::ScrollUp | crossterm::event::MouseEventKind::ScrollDown))
 }
-fn is_copy(event: &Event) -> bool {
-    matches!(event,Event::Key(key) if key.kind!=KeyEventKind::Release && matches!(key.code,KeyCode::Char('c'|'C')) && key.modifiers==KeyModifiers::CONTROL|KeyModifiers::SHIFT)
+// The primary copy chord only copies an actual selection. The explicit
+// terminal Ctrl+Shift+C command retains its whole-screen copy behavior.
+fn copy_gesture(event: &Event, macos: bool) -> Option<bool> {
+    let Event::Key(key) = event else { return None };
+    if key.kind != KeyEventKind::Press || !matches!(key.code, KeyCode::Char('c' | 'C')) {
+        return None;
+    }
+    if key.modifiers == KeyModifiers::CONTROL | KeyModifiers::SHIFT {
+        Some(false)
+    } else if key.modifiers
+        == if macos {
+            KeyModifiers::SUPER
+        } else {
+            KeyModifiers::CONTROL
+        }
+    {
+        Some(true)
+    } else {
+        None
+    }
+}
+pub(super) fn is_interrupt(event: &Event) -> bool {
+    matches!(event,Event::Key(key) if key.kind==KeyEventKind::Press && matches!(key.code,KeyCode::Char('c' | 'C')) && key.modifiers==KeyModifiers::CONTROL)
 }
 fn is_exit(event: &Event) -> bool {
-    matches!(event,Event::Key(key) if key.kind==KeyEventKind::Press && matches!(key.code,KeyCode::Char('c' | 'q')) && key.modifiers==KeyModifiers::CONTROL)
+    is_interrupt(event)
+        || matches!(event,Event::Key(key) if key.kind==KeyEventKind::Press && key.code==KeyCode::Char('q') && key.modifiers==KeyModifiers::CONTROL)
+}
+
+#[derive(Default)]
+struct InterruptWindow(Option<Instant>);
+impl InterruptWindow {
+    fn request(&mut self, now: Instant) -> bool {
+        if self.0.take().is_some_and(|until| now < until) {
+            return true;
+        }
+        self.0 = Some(now + Duration::from_secs(2));
+        false
+    }
+    fn observe(&mut self, event: &Event) {
+        if !is_interrupt(event)
+            && matches!(event,
+            Event::Key(key) if key.kind == KeyEventKind::Press)
+            || matches!(
+                event,
+                Event::Paste(_)
+                    | Event::FocusLost
+                    | Event::Mouse(event::MouseEvent {
+                        kind: event::MouseEventKind::Down(_),
+                        ..
+                    })
+            )
+        {
+            self.0 = None;
+        }
+    }
 }
 
 /// Keep a negotiated physical quit gesture inside its original raw terminal.
@@ -611,6 +679,38 @@ mod tests {
     use super::*;
     use crossterm::event::KeyEvent;
     #[test]
+    fn interrupt_requires_two_unused_presses_within_two_seconds() {
+        let now = Instant::now();
+        let mut window = InterruptWindow::default();
+        assert!(!window.request(now));
+        assert!(window.request(now + Duration::from_millis(1999)));
+        assert!(!window.request(now + Duration::from_secs(3)));
+        assert!(!window.request(now + Duration::from_secs(5)));
+        let other = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
+        window.observe(&other);
+        assert!(!window.request(now + Duration::from_millis(5500)));
+        for kind in [KeyEventKind::Release, KeyEventKind::Repeat] {
+            let event = Event::Key(KeyEvent::new_with_kind(
+                KeyCode::Char('c'),
+                KeyModifiers::CONTROL,
+                kind,
+            ));
+            assert!(!is_interrupt(&event));
+            window.observe(&event);
+        }
+        assert!(window.request(now + Duration::from_secs(6)));
+    }
+    #[test]
+    fn primary_copy_uses_command_on_mac_and_control_elsewhere() {
+        let command = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER));
+        let control = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
+        assert_eq!(copy_gesture(&command, true), Some(true));
+        assert_eq!(copy_gesture(&control, true), None);
+        assert_eq!(copy_gesture(&control, false), Some(true));
+        assert_eq!(copy_gesture(&command, false), None);
+        assert!(!is_exit(&command));
+    }
+    #[test]
     fn quit_release_keeps_original_key_and_cancels_on_a_new_gesture() {
         let now = Instant::now();
         let mut exit = ExitKey::default();
@@ -647,20 +747,21 @@ mod tests {
             KeyCode::Char('c'),
             KeyModifiers::CONTROL | KeyModifiers::SHIFT,
         ));
-        assert!(is_copy(&copy));
+        assert_eq!(copy_gesture(&copy, true), Some(false));
         assert!(!is_exit(&copy));
         let uppercase = Event::Key(KeyEvent::new(
             KeyCode::Char('C'),
             KeyModifiers::CONTROL | KeyModifiers::SHIFT,
         ));
-        assert!(is_copy(&uppercase));
+        assert_eq!(copy_gesture(&uppercase, false), Some(false));
         assert!(!is_exit(&uppercase));
         let exit = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
         assert!(is_exit(&exit));
-        assert!(!is_copy(&exit));
+        assert_eq!(copy_gesture(&exit, true), None);
+        assert_eq!(copy_gesture(&exit, false), Some(true));
         let quit = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL));
         assert!(is_exit(&quit));
-        assert!(!is_copy(&quit));
+        assert_eq!(copy_gesture(&quit, false), None);
         let mut held = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
         held.kind = KeyEventKind::Repeat;
         assert!(!is_exit(&Event::Key(held)));
@@ -669,7 +770,7 @@ mod tests {
             KeyModifiers::CONTROL | KeyModifiers::SHIFT,
         );
         released.kind = KeyEventKind::Release;
-        assert!(!is_copy(&Event::Key(released)));
+        assert_eq!(copy_gesture(&Event::Key(released), false), None);
         assert!(!is_exit(&Event::Key(released)));
     }
 }
diff --git a/platform/render-terminal/src/application/source.rs b/platform/render-terminal/src/application/source.rs
index d71e33f32a..18cf022fbf 100644
--- a/platform/render-terminal/src/application/source.rs
+++ b/platform/render-terminal/src/application/source.rs
@@ -175,7 +175,7 @@ impl Widget for Recipe {
             && SelectionScope::active(&owner.document)
                 .is_some_and(|scope| scope.pointer_capture(&owner.document))
     }
-    fn copy(&self, state: &dyn Any) -> maka_ui_runtime::Result<Option<String>> {
+    fn copy(&self, state: &dyn Any, _: bool) -> maka_ui_runtime::Result<Option<String>> {
         let owner = state.downcast_ref::<Owner>().unwrap();
         let doc = owner.document.borrow();
         if !self.2
diff --git a/platform/render-terminal/src/application/terminal.rs b/platform/render-terminal/src/application/terminal.rs
index 3e3bb41bb0..4d3048fd13 100644
--- a/platform/render-terminal/src/application/terminal.rs
+++ b/platform/render-terminal/src/application/terminal.rs
@@ -145,7 +145,14 @@ impl Widget for Recipe {
             WidgetInput::Unhandled
         })
     }
-    fn copy(&self, state: &dyn Any) -> maka_ui_runtime::Result<Option<String>> {
+    fn copy(
+        &self,
+        state: &dyn Any,
+        selection_only: bool,
+    ) -> maka_ui_runtime::Result<Option<String>> {
+        if selection_only {
+            return Ok(None);
+        }
         Ok(state
             .downcast_ref::<Owner>()
             .unwrap()
diff --git a/platform/render-terminal/src/application/tests/input.rs b/platform/render-terminal/src/application/tests/input.rs
index a7d4d02c06..95abf03a9b 100644
--- a/platform/render-terminal/src/application/tests/input.rs
+++ b/platform/render-terminal/src/application/tests/input.rs
@@ -1003,6 +1003,11 @@ fn declared_tab_and_interrupt_keep_original_focus_and_cannot_run_after_hiding()
     assert!(!tui.captures_keyboard().unwrap());
     assert!(tui.dispatch_focused_shortcut(&interrupt).unwrap());
     tui.settle().unwrap();
+    assert_eq!(tui.application.frame(&child).unwrap().state["commands"], 3);
+    // The original nonempty textarea consumes the first interrupt as an edit.
+    draw(&mut tui, 80, 24);
+    assert!(tui.dispatch_focused_shortcut(&interrupt).unwrap());
+    tui.settle().unwrap();
     assert_eq!(tui.application.frame(&child).unwrap().state["commands"], 4);
     draw(&mut tui, 80, 24);
     let quit = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL));
diff --git a/platform/render-terminal/src/application/tests/textarea.rs b/platform/render-terminal/src/application/tests/textarea.rs
index 272c5c932b..68275e42cd 100644
--- a/platform/render-terminal/src/application/tests/textarea.rs
+++ b/platform/render-terminal/src/application/tests/textarea.rs
@@ -1822,3 +1822,74 @@ fn blocked_partial_output_keeps_written_geometry_and_native_input_until_the_next
     );
     close(&mut tui);
 }
+
+#[test]
+fn interrupt_clears_native_draft_before_yielding_to_exit_and_keeps_undo() {
+    let (_runtime, mut tui) = presenter();
+    draw(&mut tui, 80, 24);
+    tui.input_settled(Event::Key(KeyEvent::new(
+        KeyCode::Char('a'),
+        KeyModifiers::CONTROL,
+    )))
+    .unwrap();
+    let mount = draft(&tui);
+    tui.input_settled(Event::Paste("draft to clear".into()))
+        .unwrap();
+    draw(&mut tui, 80, 24);
+    let interrupt = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
+    assert!(tui.dispatch_focused_shortcut(&interrupt).unwrap());
+    tui.settle().unwrap();
+    assert_eq!(
+        editor_owner(&*tui.resources.owners[&mount.id()].state)
+            .editor
+            .text(),
+        ""
+    );
+    assert_eq!(observation(&tui)["snapshot"]["text"]["bytes"], 0);
+    draw(&mut tui, 80, 24);
+    assert!(!tui.dispatch_focused_shortcut(&interrupt).unwrap());
+    tui.input_settled(Event::Key(KeyEvent::new(
+        KeyCode::Char('z'),
+        KeyModifiers::CONTROL,
+    )))
+    .unwrap();
+    assert_eq!(
+        editor_owner(&*tui.resources.owners[&mount.id()].state)
+            .editor
+            .text(),
+        "draft to clear"
+    );
+    close(&mut tui);
+}
+
+#[test]
+fn primary_copy_requires_selection_and_preserves_the_draft() {
+    let (_runtime, mut tui) = presenter();
+    draw(&mut tui, 80, 24);
+    tui.input_settled(Event::Key(KeyEvent::new(
+        KeyCode::Char('a'),
+        KeyModifiers::CONTROL,
+    )))
+    .unwrap();
+    tui.input_settled(Event::Paste("copy this".into())).unwrap();
+    draw(&mut tui, 80, 24);
+    let mut bytes = Vec::new();
+    assert!(!tui.copy_scope(&mut bytes, true).unwrap());
+    tui.input_settled(Event::Key(KeyEvent::new(
+        KeyCode::Char('a'),
+        KeyModifiers::CONTROL,
+    )))
+    .unwrap();
+    assert!(tui.copy_scope(&mut bytes, true).unwrap());
+    let mut expected = Vec::new();
+    crate::terminal::copy(&mut expected, "copy this").unwrap();
+    assert_eq!(bytes, expected);
+    let mount = draft(&tui);
+    assert_eq!(
+        editor_owner(&*tui.resources.owners[&mount.id()].state)
+            .editor
+            .text(),
+        "copy this"
+    );
+    close(&mut tui);
+}
diff --git a/platform/render-terminal/src/application/textarea.rs b/platform/render-terminal/src/application/textarea.rs
index 6cd8fa8f5f..26dc17e62e 100644
--- a/platform/render-terminal/src/application/textarea.rs
+++ b/platform/render-terminal/src/application/textarea.rs
@@ -500,7 +500,7 @@ impl Widget for Recipe {
         owner.edit.presented = Some(geometry);
         None
     }
-    fn copy(&self, state: &dyn Any) -> maka_ui_runtime::Result<Option<String>> {
+    fn copy(&self, state: &dyn Any, _: bool) -> maka_ui_runtime::Result<Option<String>> {
         let owner = state.downcast_ref::<Owner>().unwrap();
         if !owner.visible || self.props.disabled {
             return Ok(None);
diff --git a/platform/render-terminal/src/editor.rs b/platform/render-terminal/src/editor.rs
index 5564b01577..733cdfc612 100644
--- a/platform/render-terminal/src/editor.rs
+++ b/platform/render-terminal/src/editor.rs
@@ -446,6 +446,16 @@ impl Editor {
             .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
         if control {
             match key.code {
+                KeyCode::Char('c' | 'C')
+                    if !self.single_line
+                        && key.modifiers == KeyModifiers::CONTROL
+                        && !self.text.is_empty() =>
+                {
+                    if key.kind == crossterm::event::KeyEventKind::Press {
+                        self.clear();
+                    }
+                    return true;
+                }
                 KeyCode::Char('j')
                     if !self.single_line && key.modifiers == KeyModifiers::CONTROL =>
                 {
diff --git a/plugins/agent/agent-screen/component-events.ts b/plugins/agent/agent-screen/component-events.ts
index 7698a99e0e..c4a266e0f5 100644
--- a/plugins/agent/agent-screen/component-events.ts
+++ b/plugins/agent/agent-screen/component-events.ts
@@ -36,6 +36,7 @@ import type {
 } from '../../../packages/plugin-sdk/src/index.js';
 import type {
   ComposerSent,
+  ComposerSubmission,
   MessageCopyObservation,
   ModelSetup,
   PageChrome,
@@ -536,6 +537,38 @@ export function handleComponent<N extends Node>(
     state.catalogEpoch++;
     state.modelSetup = 'loading';
     state.modelRefreshing = !!state.model;
+  } else if (input.child === 'composer' && input.name === 'submission') {
+    const submission = input.event as ComposerSubmission;
+    const visit = state.visits.find(
+      (entry) =>
+        entry.id === submission.context && sameValue(entry.dataSource, submission.dataSource),
+    );
+    if (visit) {
+      const entries = (visit.submissions ??= []);
+      const index = entries.findIndex((entry) => entry.key === submission.key);
+      if (submission.status === 'removed') {
+        if (index >= 0) entries.splice(index, 1);
+      } else if (index >= 0) Object.assign(entries[index]!, submission);
+      else if (submission.status === 'pending') {
+        entries.push({
+          ...submission,
+          attachmentNames: state.attachments.files
+            .filter(
+              (file) =>
+                file.sessionId === submission.context &&
+                sameValue(file.dataSource, submission.dataSource) &&
+                submission.attachments.includes(file.id),
+            )
+            .map((file) => file.name),
+        });
+        if (state.selected?.id === visit.id) {
+          state.searchOpen = false;
+          state.browse = null;
+          visit.following = true;
+          state.landing = { sequence: state.landing.sequence + 1, edge: 'end' };
+        }
+      }
+    }
   } else if (input.child === 'composer' && input.name === 'sent') {
     sentDraft(state, actions, input.event as ComposerSent);
   } else if (input.child === 'composer' && input.name === 'setup') {
diff --git a/plugins/agent/agent-screen/conversation-view.ts b/plugins/agent/agent-screen/conversation-view.ts
index e3f31d4572..31f09af589 100644
--- a/plugins/agent/agent-screen/conversation-view.ts
+++ b/plugins/agent/agent-screen/conversation-view.ts
@@ -302,6 +302,7 @@ export function conversationView<N extends Node>(
     },
     {
       sent: 'sent',
+      submission: 'submission',
       setup: 'setup',
       readiness: 'readiness',
       restored: 'restored',
@@ -310,12 +311,13 @@ export function conversationView<N extends Node>(
       files: 'files',
     },
   );
+  const queued = (state.queue?.entries ?? []).filter((entry) => entry.kind !== 'in_flight');
   const queueProps: RowsProps = {
     terminal: theme.terminal,
-    entry: state.queue?.entries.at(-1)?.messageId ?? null,
+    entry: queued.at(-1)?.messageId ?? null,
     revision: state.queue?.revision ?? 0,
-    rows: (state.queue?.entries ?? []).map((entry): RowsProps['rows'][number] => {
-      const lane = state.queue!.entries.filter((row) => row.kind === entry.kind);
+    rows: queued.map((entry): RowsProps['rows'][number] => {
+      const lane = queued.filter((row) => row.kind === entry.kind);
       const index = lane.findIndex((row) => row.messageId === entry.messageId);
       const enabled = !busy && !executionPending(state);
       const up = {
@@ -336,23 +338,21 @@ export function conversationView<N extends Node>(
       };
       return {
         key: entry.messageId,
-        text: `${tuiText(locale, entry.kind === 'steering' ? 'task-steering' : entry.kind === 'in_flight' ? 'task-current' : 'task-followup')} · ${entry.preview}`,
+        text: `${tuiText(locale, entry.kind === 'steering' ? 'task-steering' : 'task-followup')} · ${entry.preview}`,
         actions:
-          entry.kind === 'in_flight'
-            ? []
-            : entry.kind === 'followup'
-              ? [
-                  {
-                    key: 'promote',
-                    label: theme.ascii ? '^' : '↗',
-                    enabled: enabled && !!state.run,
-                  },
-                  up,
-                  down,
-                  edit,
-                  retract,
-                ]
-              : [up, down, edit, retract],
+          entry.kind === 'followup'
+            ? [
+                {
+                  key: 'promote',
+                  label: theme.ascii ? '^' : '↗',
+                  enabled: enabled && !!state.run,
+                },
+                up,
+                down,
+                edit,
+                retract,
+              ]
+            : [up, down, edit, retract],
       };
     }),
     shortcuts: [
@@ -483,6 +483,7 @@ export function conversationView<N extends Node>(
     !state.window.page.before &&
     !state.window.page.after &&
     !state.live.length &&
+    !state.visits.find((entry) => entry.id === state.selected?.id)?.submissions?.length &&
     !state.run &&
     !state.source &&
     !state.image &&
diff --git a/plugins/agent/agent-screen/messages.ts b/plugins/agent/agent-screen/messages.ts
index 999adf5818..979cd655d6 100644
--- a/plugins/agent/agent-screen/messages.ts
+++ b/plugins/agent/agent-screen/messages.ts
@@ -138,8 +138,8 @@ function chunkId(chunk: NonNullable<State['window']>['page']['chunks'][number])
   );
 }
 
-export function messages(state: State): Message[] {
-  const canonical: Message[] = (state.window?.page.chunks ?? []).map((chunk) => ({
+function historyMessage(chunk: NonNullable<State['window']>['page']['chunks'][number]): Message {
+  return {
     id: chunkId(chunk),
     contentKey: 'body-' + chunkId(chunk),
     role: chunk.field === 'thinking' ? 'thinking' : chunk.role,
@@ -149,8 +149,35 @@ export function messages(state: State): Message[] {
     continued: chunk.offset !== 0 || utf8Bytes(chunk.text) !== chunk.totalBytes,
     source: chunk.sequence,
     image: chunk.tool?.result?.image ?? false,
+  };
+}
+
+export function messages(state: State): Message[] {
+  const visit = state.visits.find((entry) => entry.id === state.selected?.id);
+  const chunks = state.window?.page.chunks ?? [];
+  if (visit?.submissions)
+    visit.submissions = visit.submissions.filter(
+      (entry) => !entry.messageId || !chunks.some((chunk) => chunk.messageId === entry.messageId),
+    );
+  const submissions = visit?.submissions ?? [];
+  const canonical = chunks.map(historyMessage);
+  const previews: Message[] = submissions.map((entry) => ({
+    id: 'submission-' + entry.key,
+    contentKey: 'submission-' + entry.key,
+    role: 'user',
+    timestamp: null,
+    text: [entry.text, ...entry.attachmentNames]
+      .filter(Boolean)
+      .join('\n')
+      .slice(0, 24000)
+      .replace(/[\uD800-\uDBFF]$/u, ''),
+    tool: null,
+    continued: false,
+    source: null,
+    image: false,
+    pending: entry.status !== 'accepted',
   }));
-  if (!state.window?.latest) return canonical;
+  if (!state.window?.latest) return [...canonical, ...previews];
   const live = new Map<string, Message>();
   for (const fragment of state.live) {
     if (
@@ -180,7 +207,7 @@ export function messages(state: State): Message[] {
     }
     message.text += fragment.text;
   }
-  return [...canonical, ...live.values()];
+  return [...canonical, ...previews, ...live.values()];
 }
 
 export function turnTimings(state: State, displayed: Message[]): TimelineProps['timings'] {
@@ -222,9 +249,7 @@ export function rememberActivity(state: State, page: NonNullable<State['window']
     .filter((entry) => entry.turnId === turnId && entry.role !== 'user')
     .at(-1);
   const sequence = chunk?.sequence ?? 0;
-  const message = chunk
-    ? messages({ ...state, window: { page: { ...page, chunks: [chunk] }, latest: false } })[0]!
-    : null;
+  const message = chunk ? historyMessage(chunk) : null;
   visit.activity = {
     through: Math.max(page.through, previous?.through ?? 0),
     sequence: Math.max(sequence, previous?.sequence ?? 0),
diff --git a/plugins/agent/agent-screen/state.ts b/plugins/agent/agent-screen/state.ts
index e4be278b1b..57313fb62c 100644
--- a/plugins/agent/agent-screen/state.ts
+++ b/plugins/agent/agent-screen/state.ts
@@ -197,6 +197,9 @@ export function navigationKey(state: State): string {
 // Bounded visited workspaces keep their original native reading owners. These
 // are presentation snapshots only; run, queue and permission facts are refreshed.
 export interface Visit {
+  submissions?: (import('../../shared/contracts.js').ComposerSubmission & {
+    attachmentNames: string[];
+  })[];
   detached?: boolean;
   activityWanted?: number;
   prompt?: { anchor: string; value: { source: number; text: string } | null };
diff --git a/plugins/agent/api/src/contract.rs b/plugins/agent/api/src/contract.rs
index 9d53f05f43..860b803a4f 100644
--- a/plugins/agent/api/src/contract.rs
+++ b/plugins/agent/api/src/contract.rs
@@ -646,6 +646,7 @@ pub enum SendResult {
     Started {
         session_id: String,
         invocation_id: String,
+        message_id: String,
     },
     Queued {
         session_id: String,
diff --git a/plugins/agent/composer/component.ts b/plugins/agent/composer/component.ts
index c210db33cb..f28cb21b85 100644
--- a/plugins/agent/composer/component.ts
+++ b/plugins/agent/composer/component.ts
@@ -35,7 +35,7 @@ import type {
   TextChunk,
   TextareaProps,
 } from '../../../packages/plugin-sdk/src/index.js';
-import type { ComposerBackend, ComposerProps } from '../../shared/contracts.js';
+import type { ComposerBackend, ComposerProps, ComposerSubmission } from '../../shared/contracts.js';
 import {
   observeDraft,
   nextDraftStep,
@@ -52,6 +52,7 @@ import { output, fill, pixels, type Presentation, type Node } from '../../shared
 import { sameValue } from '../../shared/value.js';
 
 interface Submission {
+  preview?: { key: string; text: string };
   context: string;
   dataSource: AgentDataSource;
   backend: ComposerBackend;
@@ -99,6 +100,27 @@ function advance(entry: DraftEntry, caller: CallerConfirmation | null) {
       scope: JSON.stringify([caller.caller.stateDomain, caller.caller.deployment, caller.consumer]),
     };
 }
+function submissionEvent(
+  submission: Submission,
+  status: ComposerSubmission['status'],
+  messageId: string | null = null,
+): Emission[] {
+  return submission.preview
+    ? [
+        {
+          name: 'submission',
+          value: {
+            context: submission.context,
+            dataSource: submission.dataSource,
+            ...submission.preview,
+            attachments: submission.attachments,
+            status,
+            messageId,
+          } satisfies ComposerSubmission,
+        },
+      ]
+    : [];
+}
 function admitted(
   state: Draft,
   entry: DraftEntry,
@@ -113,7 +135,15 @@ function admitted(
       if (result.kind === 'rejected') {
         entry.delivery = null;
         entry.notice = 'skillInputUnavailable';
+        events.push(...submissionEvent(submission, 'removed'));
       } else {
+        events.push(
+          ...submissionEvent(
+            submission,
+            result.kind === 'started' ? 'accepted' : 'removed',
+            result.messageId,
+          ),
+        );
         entry.notice = null;
         entry.delivery = {
           kind: 'clear',
@@ -137,6 +167,7 @@ function admitted(
       return;
     }
   }
+  events.push(...submissionEvent(submission, 'unknown'));
   entry.delivery = {
     kind: 'unknown',
     submission,
@@ -544,6 +575,11 @@ export function composer<N extends Node>(
                 else {
                   const key = `send-${++state.serial}`,
                     backend = submission.backend;
+                  submission.preview = {
+                    key,
+                    text: chunk.text.slice(0, 24000).replace(/[\uD800-\uDBFF]$/u, ''),
+                  };
+                  events.push(...submissionEvent(submission, 'pending'));
                   entry.delivery = { kind: 'send', key, submission };
                   actions.push({
                     ...action(key, 'agent', backend.kind === 'executor' ? 'execute' : 'send', {
@@ -571,6 +607,7 @@ export function composer<N extends Node>(
             if (outcome.status === 'operation')
               admitted(state, entry, delivery.submission, outcome.reply, outcome.caller, events);
             else {
+              events.push(...submissionEvent(delivery.submission, 'removed'));
               entry.delivery = null;
               entry.notice = 'draftKept';
             }
diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs
index d6c794955c..d0508e26a2 100644
--- a/plugins/agent/native/src/owner.rs
+++ b/plugins/agent/native/src/owner.rs
@@ -1051,6 +1051,7 @@ impl Owner {
         Ok(crate::contract::SendResult::Started {
             session_id: invocation.session_id.clone(),
             invocation_id: invocation.invocation_id,
+            message_id: format!("m_{identity}"),
         })
     }
 }
diff --git a/plugins/shared/components.ts b/plugins/shared/components.ts
index b71f02186c..cdbf92ea31 100644
--- a/plugins/shared/components.ts
+++ b/plugins/shared/components.ts
@@ -623,14 +623,22 @@ export function timeline<N extends Node>(
               {
                 source: message.text,
                 focusOnPointer: !search,
-                appearance: props.theme.markdown.body,
+                appearance: message.pending
+                  ? { ...props.theme.markdown.body, foreground: props.theme.muted }
+                  : props.theme.markdown.body,
                 selection: props.theme.markdown.selection,
-                terminal: props.theme.terminal,
+                terminal:
+                  message.pending && props.theme.terminal
+                    ? { ...props.theme.terminal, foreground: props.theme.terminal.muted }
+                    : props.theme.terminal,
                 disabled: false,
               },
               {},
             );
-      const messageNode = messageView(presentation, target, props.theme, props.locale, {
+      const messageTheme = message.pending
+        ? { ...props.theme, accent: props.theme.muted, text: props.theme.muted }
+        : props.theme;
+      const messageNode = messageView(presentation, target, messageTheme, props.locale, {
         ...message,
         browse: browsing
           ? { key: 'browse-' + message.id, current: browsing.id === message.id }
diff --git a/plugins/shared/contracts.ts b/plugins/shared/contracts.ts
index 62a5538d97..29862bde50 100644
--- a/plugins/shared/contracts.ts
+++ b/plugins/shared/contracts.ts
@@ -86,6 +86,7 @@ export interface Theme {
   markdown: MarkdownAppearance;
 }
 export interface Message {
+  pending?: boolean;
   id: string;
   // Stable message field/byte-segment identity, including the streaming prefix.
   contentKey: string;
@@ -314,6 +315,15 @@ export interface QueueEditorProps {
 export type ComposerBackend =
   | { kind: 'model'; model: ModelSelection; thinkingLevel: ThinkingLevel | null }
   | { kind: 'executor'; selection: AgentExecutorSelection };
+export interface ComposerSubmission {
+  context: string;
+  dataSource: AgentDataSource;
+  key: string;
+  text: string;
+  attachments: string[];
+  status: 'pending' | 'unknown' | 'accepted' | 'removed';
+  messageId: string | null;
+}
 export interface ComposerSent {
   context: string;
   dataSource: AgentDataSource;
@@ -387,19 +397,23 @@ const themeSchema = object({
   menu: { $ref: '#/$defs/MenuAppearance' },
   markdown: { $ref: '#/$defs/MarkdownAppearance' },
 });
-const messageSchema = object({
-  id: string,
-  contentKey: { type: 'string', minLength: 1, maxLength: 123, pattern: '^[A-Za-z0-9_-]+$' },
-  role: { enum: ['user', 'assistant', 'thinking', 'tool_call', 'tool_result'] },
-  timestamp: {
-    anyOf: [{ type: 'integer', minimum: 0, maximum: 9007199254740991 }, { type: 'null' }],
+const messageSchema = object(
+  {
+    pending: { type: 'boolean' },
+    id: string,
+    contentKey: { type: 'string', minLength: 1, maxLength: 123, pattern: '^[A-Za-z0-9_-]+$' },
+    role: { enum: ['user', 'assistant', 'thinking', 'tool_call', 'tool_result'] },
+    timestamp: {
+      anyOf: [{ type: 'integer', minimum: 0, maximum: 9007199254740991 }, { type: 'null' }],
+    },
+    text: { type: 'string', maxLength: 24000 },
+    tool: { anyOf: [{ $ref: '#/$defs/HistoryTool' }, { type: 'null' }] },
+    continued: { type: 'boolean' },
+    image: { type: 'boolean' },
+    source: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }] },
   },
-  text: { type: 'string', maxLength: 24000 },
-  tool: { anyOf: [{ $ref: '#/$defs/HistoryTool' }, { type: 'null' }] },
-  continued: { type: 'boolean' },
-  image: { type: 'boolean' },
-  source: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }] },
-});
+  ['id', 'contentKey', 'role', 'timestamp', 'text', 'tool', 'continued', 'image', 'source'],
+);
 const withDefs = (schema: Schema, additional: Schema = {}): Schema => {
   const { $defs: local, ...body } = schema;
   const available: Record<string, unknown> = {
@@ -1185,6 +1199,17 @@ export const composerContract = component(
     ],
   ),
   {
+    submission: withDefs(
+      object({
+        context: string,
+        dataSource: { $ref: '#/$defs/AgentDataSource' },
+        key: string,
+        text: { type: 'string', maxLength: 24000 },
+        attachments: { type: 'array', maxItems: 8, items: string },
+        status: { enum: ['pending', 'unknown', 'accepted', 'removed'] },
+        messageId: { anyOf: [string, { type: 'null' }] },
+      }),
+    ),
     sent: withDefs(
       object({
         context: string,
diff --git a/plugins/shared/tui-messages.ts b/plugins/shared/tui-messages.ts
index d8bfcaa066..484afdc053 100644
--- a/plugins/shared/tui-messages.ts
+++ b/plugins/shared/tui-messages.ts
@@ -522,9 +522,9 @@ export const tui_language_auto: Message = [
 ];
 
 export const tui_help: Message = [
-  'Tab / Shift+Tab     Move focus between controls\nUp / Down          Select a navigation, page or command item\nEnter              Activate the focused item\nEnter              Send from composer / queue for next turn\nShift+Enter        New line in composer\nCtrl+J             New line in composer (all terminals)\nCtrl+Enter         Submit answers / run message revision\nCtrl+K / Ctrl+P     Open command palette\nCtrl+F             Find in conversation\nCtrl+N             New session\n [...]
-  'Tab / Shift+Tab     在控件间移动焦点\n上 / 下             选择导航、页面或命令项\nEnter              激活焦点项\nEnter              发送消息 / 运行中排入下一轮\nShift+Enter        输入框内换行\nCtrl+J             消息输入框换行(所有终端)\nCtrl+Enter         提交问答 / 运行消息修订\nCtrl+K / Ctrl+P     打开命令面板\nCtrl+F             查找对话内容\nCtrl+N             新建会话\nCtrl+A / Ctrl+Z    编辑器内全选 / 撤销\nF4                 检查产物与执行证据\nCtrl+G             查看待审批请求或问题\nCtrl+B             展开 / 折叠导航\nCtrl+PgUp/PgDn     切换会话页签\nCtrl+W             关闭页签(保留草稿与任务)\nF11     [...]
-  'Tab / Shift+Tab     在控制項間移動焦點\n上 / 下             選擇導覽、頁面或命令項\nEnter              啟用焦點項\nEnter              傳送訊息 / 執行中排入下一輪\nShift+Enter        輸入框內換行\nCtrl+J             訊息輸入框換行(所有終端機)\nCtrl+Enter         提交問答 / 執行訊息修訂\nCtrl+K / Ctrl+P     開啟命令面板\nCtrl+F             尋找對話內容\nCtrl+N             建立對話\nCtrl+A / Ctrl+Z    編輯器內全選 / 復原\nF4                 檢查產物與執行證據\nCtrl+G             查看待審批請求或問題\nCtrl+B             展開 / 收合導覽\nCtrl+PgUp/PgDn     切換對話分頁\nCtrl+W             關閉分頁(保留草稿與工作)\nF11   [...]
+  'Tab / Shift+Tab     Move focus between controls\nUp / Down          Select a navigation, page or command item\nEnter              Activate the focused item\nEnter              Send from composer / queue for next turn\nShift+Enter        New line in composer\nCtrl+J             New line in composer (all terminals)\nCtrl+Enter         Submit answers / run message revision\nCtrl+K / Ctrl+P     Open command palette\nCtrl+F             Find in conversation\nCtrl+N             New session\n [...]
+  'Tab / Shift+Tab     在控件间移动焦点\n上 / 下             选择导航、页面或命令项\nEnter              激活焦点项\nEnter              发送消息 / 运行中排入下一轮\nShift+Enter        输入框内换行\nCtrl+J             消息输入框换行(所有终端)\nCtrl+Enter         提交问答 / 运行消息修订\nCtrl+K / Ctrl+P     打开命令面板\nCtrl+F             查找对话内容\nCtrl+N             新建会话\nCtrl+A / Ctrl+Z    编辑器内全选 / 撤销\nF4                 检查产物与执行证据\nCtrl+G             查看待审批请求或问题\nCtrl+B             展开 / 折叠导航\nCtrl+PgUp/PgDn     切换会话页签\nCtrl+W             关闭页签(保留草稿与任务)\nF11     [...]
+  'Tab / Shift+Tab     在控制項間移動焦點\n上 / 下             選擇導覽、頁面或命令項\nEnter              啟用焦點項\nEnter              傳送訊息 / 執行中排入下一輪\nShift+Enter        輸入框內換行\nCtrl+J             訊息輸入框換行(所有終端機)\nCtrl+Enter         提交問答 / 執行訊息修訂\nCtrl+K / Ctrl+P     開啟命令面板\nCtrl+F             尋找對話內容\nCtrl+N             建立對話\nCtrl+A / Ctrl+Z    編輯器內全選 / 復原\nF4                 檢查產物與執行證據\nCtrl+G             查看待審批請求或問題\nCtrl+B             展開 / 收合導覽\nCtrl+PgUp/PgDn     切換對話分頁\nCtrl+W             關閉分頁(保留草稿與工作)\nF11   [...]
 ];
 export const tui_command_agent_screen: Message = [
   'Open Agent screen',
diff --git a/products/maka/bundle/tests/composer.test.mjs b/products/maka/bundle/tests/composer.test.mjs
index 8184eb69e9..1e72bf48c7 100644
--- a/products/maka/bundle/tests/composer.test.mjs
+++ b/products/maka/bundle/tests/composer.test.mjs
@@ -73,7 +73,14 @@ const scope = JSON.stringify([
   caller.caller.deployment,
   caller.consumer,
 ]);
-const returned = (value = { kind: 'started', sessionId: 'session', invocationId: 'run' }) => ({
+const returned = (
+  value = {
+    kind: 'started',
+    sessionId: 'session',
+    invocationId: 'run',
+    messageId: 'canonical-message',
+  },
+) => ({
   reference,
   receipt: {
     kind: 'recorded',
@@ -215,7 +222,16 @@ for (const target of ['tui', 'desktop']) {
       model: backend.model,
       thinkingLevel: null,
     });
-    assert.deepEqual(c.out.events, [], 'dispatch does not wait for any parent event');
+    assert.equal(c.out.events[0].name, 'submission');
+    assert.deepEqual(c.out.events[0].value, {
+      context: 'session',
+      dataSource,
+      attachments: [],
+      key: send.key,
+      text: 'hello',
+      status: 'pending',
+      messageId: null,
+    });
     assert.deepEqual(send.recovery, {
       format: 'maka.agent.draft-send@2',
       sessionId: 'session',
@@ -223,9 +239,12 @@ for (const target of ['tui', 'desktop']) {
       attachments: [],
     });
     c.reply({ status: 'operation', reply: returned(), caller });
-    assert.deepEqual(c.out.events, [
-      { name: 'sent', value: { context: 'session', dataSource, attachments: [] } },
-    ]);
+    assert.equal(c.out.events[0].value.status, 'accepted');
+    assert.equal(c.out.events[0].value.messageId, 'canonical-message');
+    assert.deepEqual(
+      c.out.events.filter((event) => event.name === 'sent'),
+      [{ name: 'sent', value: { context: 'session', dataSource, attachments: [] } }],
+    );
     assert.equal(c.editor().command.owner, 'editor');
     assert.equal(
       c.editor().persistence.metadata.historyAfter,
@@ -287,7 +306,7 @@ for (const target of ['tui', 'desktop']) {
     assert.equal(c.editor().command, null);
     assert.equal(c.editor().persistence.metadata.historyAfter, 0);
     assert.equal(c.state.entries[0].notice, 'skillInputUnavailable');
-    assert.deepEqual(c.out.events, []);
+    assert.equal(c.out.events[0].value.status, 'removed');
     c.emit('submitted', observed(text));
     assert.equal(c.out.actions[0].binding, 'reader');
     c.reply({ status: 'failed', reason: 'capture retired' });
@@ -443,3 +462,43 @@ for (const target of ['tui', 'desktop']) {
     assert.equal(invalid.state.records[0].work.status, 'failed');
   });
 }
+
+for (const target of ['tui', 'desktop']) {
+  test(`${target} pending submissions settle only on original admission`, async () => {
+    const c = await client(target);
+    c.ready();
+    c.send();
+    const pending = c.out.events.find((event) => event.name === 'submission').value;
+    assert.equal(pending.status, 'pending');
+    c.reply({ status: 'failed', reason: 'offline' });
+    assert.equal(c.out.events[0].value.status, 'removed');
+    assert.equal(c.editor().command, null);
+    c.send();
+    c.reply({
+      status: 'operation',
+      caller,
+      reply: { reference, receipt: { kind: 'unavailable', diagnostic: 'unknown' } },
+    });
+    assert.equal(c.out.events[0].value.status, 'unknown');
+    assert.equal(
+      c.out.events.some((event) => event.name === 'sent'),
+      false,
+    );
+    assert.equal(c.editor().command, null);
+    c.raw('draft-retry');
+    c.reply({
+      status: 'succeeded',
+      value: returned({
+        kind: 'queued',
+        sessionId: 'session',
+        messageId: 'queued-message',
+        revision: 1,
+      }),
+    });
+    assert.equal(c.out.events[0].value.status, 'removed');
+    assert.equal(
+      c.out.events.some((event) => event.name === 'sent'),
+      true,
+    );
+  });
+}
diff --git a/products/maka/bundle/tests/task-workbench.test.mjs b/products/maka/bundle/tests/task-workbench.test.mjs
index 3583546e7e..19c8108f7a 100644
--- a/products/maka/bundle/tests/task-workbench.test.mjs
+++ b/products/maka/bundle/tests/task-workbench.test.mjs
@@ -1063,3 +1063,210 @@ test('footer sandbox changes use the current revision and settle the original re
   assert.equal(c.action('metadata').input.expectedRevision, 2);
   assert.equal(c.action('metadata').input.permissions.settings.access, 'read_only');
 });
+
+const submission = (context, extra = {}) => ({
+  context,
+  dataSource,
+  key: 'send-2',
+  text: 'same text',
+  attachments: [],
+  status: 'pending',
+  messageId: null,
+  ...extra,
+});
+test('submitted history replaces the current-run queue notice while queued messages keep their actions', () => {
+  const c = client().observe(0).history(page(0, []));
+  c.state.queue = {
+    revision: 1,
+    entries: [
+      { messageId: 'next', kind: 'followup', preview: 'next message' },
+      { messageId: 'current', kind: 'in_flight', preview: 'same text' },
+    ],
+  };
+  c.child('composer', 'submission', submission(c.state.selected.id));
+  const queue = c.find('queue').props;
+  assert.equal(c.find(c.reader()).props.messages[0].pending, true);
+  assert.deepEqual(
+    queue.rows.map((row) => row.key),
+    ['next'],
+  );
+  assert.equal(queue.entry, 'next');
+  assert.ok(queue.rows[0].actions.some((action) => action.key === 'edit'));
+  c.state.queue.entries = c.state.queue.entries.filter((entry) => entry.kind === 'in_flight');
+  c.child('composer', 'submission', submission(c.state.selected.id));
+  assert.deepEqual(c.find('queue').props.rows, []);
+  assert.equal(c.find('queue').props.entry, null);
+});
+test('submitted message is muted until admission and reconciles by canonical identity, including overtaking history', () => {
+  const c = client()
+    .observe(1)
+    .history(page(1, [chunk(1, 'same text', { role: 'user' })]));
+  const id = c.state.selected.id;
+  c.child('composer', 'submission', submission(id));
+  let messages = c.find(c.reader()).props.messages;
+  assert.equal(messages.length, 2);
+  assert.equal(messages[1].pending, true);
+  c.state.window = {
+    latest: true,
+    page: page(3, [
+      chunk(1, 'same text', { role: 'user' }),
+      chunk(2, 'same text', { role: 'user', messageId: 'canonical-message' }),
+      chunk(3, 'reply'),
+    ]),
+  };
+  c.child('composer', 'submission', submission(id));
+  assert.equal(
+    c.find(c.reader()).props.messages[2].text,
+    'reply',
+    'pending send cannot hide canonical assistant output',
+  );
+  c.child('composer', 'submission', submission(id, { status: 'unknown' }));
+  messages = c.find(c.reader()).props.messages;
+  assert.equal(
+    messages.length,
+    4,
+    'canonical facts stay visible while the original receipt is unknown',
+  );
+  assert.equal(messages[2].text, 'reply');
+  assert.equal(messages[3].pending, true);
+  c.child(
+    'composer',
+    'submission',
+    submission(id, { status: 'accepted', messageId: 'canonical-message' }),
+  );
+  messages = c.find(c.reader()).props.messages;
+  assert.equal(messages.length, 3);
+  assert.deepEqual(
+    messages.map((message) => message.id),
+    ['message-1', 'canonical-message', 'message-3'],
+  );
+  assert.equal(
+    messages.some((message) => message.pending),
+    false,
+  );
+  assert.equal(c.state.visits.find((visit) => visit.id === id).submissions.length, 0);
+});
+test('accepted preview turns normal before history; session switches and failures keep original ownership', () => {
+  const c = client().observe(1).history(page(1, []));
+  const id = c.state.selected.id,
+    reader = c.reader();
+  c.child('composer', 'submission', submission(id));
+  c.child(
+    'composer',
+    'submission',
+    submission(id, { status: 'accepted', messageId: 'canonical-message' }),
+  );
+  assert.equal(c.find(reader).props.messages[0].pending, false);
+  c.select('Task B').observe(1).history(page(1, []));
+  assert.equal(c.find(c.reader()).props.messages.length, 0);
+  assert.equal(c.find(reader).props.messages[0].text, 'same text');
+  c.child('composer', 'submission', submission('Task B'));
+  c.child('composer', 'submission', submission('Task B', { status: 'removed' }));
+  assert.equal(c.find(c.reader()).props.messages.length, 0);
+  c.child(
+    'composer',
+    'submission',
+    submission(id, { status: 'removed', dataSource: { ...dataSource, deployment: 'foreign' } }),
+  );
+  assert.equal(
+    c.find(reader).props.messages.length,
+    1,
+    'foreign delivery cannot change original session',
+  );
+});
+
+test('attachment preview captures only original identities and survives attachment cleanup', () => {
+  const c = client().observe(1).history(page(1, []));
+  const id = c.state.selected.id;
+  const file = {
+    sessionId: id,
+    dataSource,
+    id: 'attachment',
+    name: 'original.txt',
+    mimeType: 'text/plain',
+    bytes: 3,
+  };
+  c.state.attachments.files = [
+    file,
+    { ...file, dataSource: { ...dataSource, deployment: 'foreign' }, name: 'foreign.txt' },
+    { ...file, id: 'later', name: 'later.txt' },
+  ];
+  c.child('composer', 'submission', submission(id, { text: '', attachments: ['attachment'] }));
+  assert.equal(c.find(c.reader()).props.messages[0].text, 'original.txt');
+  c.child(
+    'composer',
+    'submission',
+    submission(id, {
+      text: '',
+      attachments: ['attachment'],
+      status: 'accepted',
+      messageId: 'canonical',
+    }),
+  );
+  c.child('composer', 'sent', { context: id, dataSource, attachments: ['attachment'] });
+  assert.equal(c.find(c.reader()).props.messages[0].text, 'original.txt');
+  assert.deepEqual(
+    c.state.attachments.files.map((file) => file.name),
+    ['foreign.txt', 'later.txt'],
+  );
+});
+
+test('pending preview truncation preserves complete Unicode attachment names', () => {
+  const c = client().observe(1).history(page(1, []));
+  const id = c.state.selected.id;
+  c.state.attachments.files = [
+    {
+      sessionId: id,
+      dataSource,
+      id: 'attachment',
+      name: '🙂.txt',
+      mimeType: 'text/plain',
+      bytes: 3,
+    },
+  ];
+  c.child(
+    'composer',
+    'submission',
+    submission(id, { text: 'a'.repeat(23998), attachments: ['attachment'] }),
+  );
+  const text = c.find(c.reader()).props.messages[0].text;
+  assert.equal(text, 'a'.repeat(23998) + '\n');
+  assert.equal(text.isWellFormed(), true);
+});
+
+test('pending and unknown submissions never replace canonical activity output', () => {
+  for (const status of ['pending', 'unknown']) {
+    const c = client()
+      .observe(1, { invocationId: 'run-a', turnId: 'turn-1', state: 'running' })
+      .history(page(1, []));
+    const id = c.state.selected.id,
+      reader = c.reader();
+    c.child('composer', 'submission', submission(id));
+    if (status === 'unknown') c.child('composer', 'submission', submission(id, { status }));
+    c.child(reader, 'detached', { landing: c.state.landing.sequence, value: true });
+    c.event({
+      kind: 'action',
+      key: c.state.watch.key,
+      event: {
+        status: 'updated',
+        value: {
+          kind: 'events',
+          through: 2,
+          nextAfter: 2,
+          events: [
+            {
+              sequence: 2,
+              invocationId: 'run-a',
+              turnId: 'turn-1',
+              fact: { kind: 'transcript_changed' },
+            },
+          ],
+        },
+      },
+    });
+    c.reply(c.action('history'), page(2, [chunk(2, 'Canonical agent output')]));
+    assert.equal(c.find(reader).props.activeOutput.role, 'assistant');
+    assert.equal(c.find(reader).props.activeOutput.text, 'Canonical agent output');
+    assert.equal(c.state.visits.find((visit) => visit.id === id).submissions[0].status, status);
+  }
+});
diff --git a/products/maka/bundle/tests/timeline.test.mjs b/products/maka/bundle/tests/timeline.test.mjs
index 1cde368635..8172dc2e0b 100644
--- a/products/maka/bundle/tests/timeline.test.mjs
+++ b/products/maka/bundle/tests/timeline.test.mjs
@@ -423,3 +423,60 @@ test('prompt header follows the visible answer, yields to user rows, and never f
     'header is never copied or searched as transcript text',
   );
 });
+
+for (const target of ['tui', 'desktop']) {
+  test(`${target} pending user message uses muted text and normalizes on admission`, async () => {
+    const { component } = await import(`../dist/packages/timeline/${target}.js`);
+    const props = {
+      theme,
+      locale: 'en',
+      copyRequest: null,
+      browse: null,
+      timings: [],
+      markReadEnabled: false,
+      toolContext: null,
+      waitingTools: [],
+      liveTools: [],
+      source: null,
+      search: null,
+      loading: false,
+      older: false,
+      newer: false,
+      latest: false,
+      landing: { sequence: 0, edge: 'end' },
+      live: true,
+      messages: [
+        {
+          id: 'submission-send-1',
+          contentKey: 'submission-send-1',
+          role: 'user',
+          text: 'Hello',
+          timestamp: null,
+          source: null,
+          image: false,
+          continued: false,
+          tool: null,
+          pending: true,
+        },
+      ],
+    };
+    const nodes = (node) =>
+      node && typeof node === 'object'
+        ? [
+            node,
+            ...Object.values(node).flatMap((value) =>
+              Array.isArray(value) ? value.flatMap(nodes) : nodes(value),
+            ),
+          ]
+        : [];
+    let out = component.mount(props, {});
+    const source = () =>
+      nodes(out.frame.view).find((node) => node.key === 'text-submission-send-1');
+    assert.equal(source().props.appearance.foreground, theme.muted);
+    assert.equal(source().props.terminal.foreground, theme.terminal.muted);
+    props.messages[0].pending = false;
+    out = component.update(out.frame.state, props, {});
+    assert.equal(source().props.appearance.foreground, theme.markdown.body.foreground);
+    assert.equal(source().props.terminal.foreground, theme.terminal.foreground);
+  });
+}
diff --git a/products/maka/cli/tests/integration/terminal/application.rs b/products/maka/cli/tests/integration/terminal/application.rs
index 912f86b3ef..3cbe88c8bc 100644
--- a/products/maka/cli/tests/integration/terminal/application.rs
+++ b/products/maka/cli/tests/integration/terminal/application.rs
@@ -20,6 +20,7 @@
 use super::{AsRawFd, Command, Pty};
 use local_fixture::counter as fixture;
 use maka_plugin_core::composition::PresentationTarget;
+use std::time::{Duration, Instant};
 fn packages(directory: &std::path::Path, poisoned: bool) -> Vec<String> {
     let (mut input, mut packages) =
         fixture::dual_fixture("return frame({count:state.count+1,revision:state.revision+1});");
@@ -125,6 +126,24 @@ fn plugin_application_uses_actual_foreground_transactions_and_restores_the_real_
     tui.send(b"\r");
     tui.wait_until(|screen| screen.lines().any(|line| line.trim() == "1"));
     tui.send(b"\x03");
+    let expired = Instant::now() + Duration::from_millis(2100);
+    while Instant::now() < expired {
+        tui.read();
+        assert!(
+            tui.child.try_wait().unwrap().is_none(),
+            "first interrupt must not quit on timeout"
+        );
+    }
+    tui.send(b"\x03");
+    let armed = Instant::now() + Duration::from_millis(100);
+    while Instant::now() < armed {
+        tui.read();
+        assert!(
+            tui.child.try_wait().unwrap().is_none(),
+            "expired press must start a new window"
+        );
+    }
+    tui.send(b"\x03");
     tui.finish();
     let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
     assert_eq!(
@@ -326,7 +345,7 @@ fn check_terminal_connections(unrelated_failure: bool) {
     tui.wait_for("\"effects\":1");
     tui.send(b"\t\r");
     tui.wait_for("Original receipt recorded");
-    tui.send(b"\x03");
+    tui.send(b"\x11");
     tui.finish();
     let ns = maka_event_log::root::RootNamespaces::for_current_account().unwrap();
     drop(maka_event_log::root::RootOwner::open(&directory.path().join("caller"), &ns).unwrap());
@@ -404,6 +423,6 @@ fn bundled_authoring_starter_generates_checked_presenters_and_runs_native_counte
     tui.wait_for("Count 1");
     tui.send(b"\r");
     tui.wait_for("Count 2");
-    tui.send(b"\x03");
+    tui.send(b"\x11");
     tui.finish();
 }
diff --git a/products/maka/cli/tests/integration/terminal/product/executor.rs b/products/maka/cli/tests/integration/terminal/product/executor.rs
index af490c61ca..5b8b83b186 100644
--- a/products/maka/cli/tests/integration/terminal/product/executor.rs
+++ b/products/maka/cli/tests/integration/terminal/product/executor.rs
@@ -284,7 +284,7 @@ fn executor_selection_and_composer_reach_production_timeline_before_backend_comp
         .unwrap();
         std::fs::write(Path::new(&path).join("terminal.ansi"), &tui.output).unwrap();
     }
-    tui.send(b"\x03");
+    tui.send(b"\x11");
     tui.finish();
     drop(RootOwner::open(&agent_root, &ns).unwrap());
 }
diff --git a/products/maka/cli/tests/integration/terminal/product/executor_setup.rs b/products/maka/cli/tests/integration/terminal/product/executor_setup.rs
index f3da1ecf08..78170096f3 100644
--- a/products/maka/cli/tests/integration/terminal/product/executor_setup.rs
+++ b/products/maka/cli/tests/integration/terminal/product/executor_setup.rs
@@ -307,7 +307,7 @@ fn executor_setup_shows_live_login_url_cancels_and_reads_original_receipt() {
         }
         std::fs::write(Path::new(&path).join("terminal.ansi"), &tui.output).unwrap();
     }
-    tui.send(b"\x03");
+    tui.send(b"\x11");
     tui.finish();
     runtime.block_on(async {
         let store = EventLog::for_root(Arc::new(RootOwner::open(&agent_root, &ns).unwrap()))


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org