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(©));
+ assert_eq!(copy_gesture(©, true), Some(false));
assert!(!is_exit(©));
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