diff --git a/packages/plugin-sdk/src/composer-view.ts b/packages/plugin-sdk/src/composer-view.ts index ce498f180bd..591e3957f25 100644 --- a/packages/plugin-sdk/src/composer-view.ts +++ b/packages/plugin-sdk/src/composer-view.ts @@ -44,6 +44,7 @@ import type { UiSubscription } from './workbench.js'; export interface ComposerFocusHandle { focus(): void; + bindKey(key: string, action: () => void, options?: { fallback?: boolean }): () => void; } export interface ComposerCommand { diff --git a/plugins/agent/composer/ui/Composer.tsx b/plugins/agent/composer/ui/Composer.tsx index 730346bb543..3534e67924d 100644 --- a/plugins/agent/composer/ui/Composer.tsx +++ b/plugins/agent/composer/ui/Composer.tsx @@ -22,7 +22,7 @@ import { ProjectMcp } from '../../../../products/maka/workbench/app/ProjectMcpCo import { tr } from '../../../../products/maka/workbench/app/locale'; import { cleanup, effect, inputAction, signal, Text, View, Image } from 'nsh'; import { ContextMenu, Input, MenuItem, Popover } from 'nsh/base'; -import type { InputRef, JSX, ViewRef } from 'nsh'; +import type { JSX, ViewRef } from 'nsh'; import { Button } from 'nsh/base'; import { platform, windowSize } from 'nsh'; import { keymap } from 'nsh'; @@ -55,21 +55,10 @@ import { FileDropHint } from '../../../../products/maka/workbench/app/FileDropHi import { matchComposerCommand } from '../../../../products/maka/workbench/app/composerCommands'; import { ComposerCompletion, createComposerCompletion } from './ComposerCompletion'; import { createComposerPaste } from './composerPaste'; +import { SkillInput } from '../../../../products/maka/workbench/app/SkillInput'; +import type { ComposerEditor } from '../../../../products/maka/workbench/app/ComposerEditor'; import logo from '../../../../products/maka/workbench/app/assets/maka.png'; -// Match the skill invocation grammar; the surrounding whitespace is not part of the chip. -const skillTokens = () => [ - { - pattern: String.raw`(?:^|[\t-\r \u{00a0}\u{1680}\u{2000}-\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}])(?P/skill:[A-Za-z0-9._-]+)`, - style: { - color: C.accent, - backgroundColor: `${C.accent}20`, - borderRadius: 4, - }, - atomic: true, - }, -]; - export function Composer(props: ComposerViewInput) { const pendingAdd = signal(false); const completion = createComposerCompletion({ @@ -77,7 +66,7 @@ export function Composer(props: ComposerViewInput) { commands: props.commands, active: props.active, }); - const predictionEditor = signal<{ owner: ComposerSession; node: InputRef } | null>(null); + const predictionEditor = signal<{ owner: ComposerSession; node: ComposerEditor } | null>(null); const suggestedMessage = () => { const owner = props.session(); if ( @@ -136,10 +125,7 @@ export function Composer(props: ComposerViewInput) { text: suggestion.text, }; const bindings = ['tab', 'right', 'enter'].map((key) => - keymap.bind(key, () => acceptSuggestedMessage(offered), { - when: 'always', - within: editor.node, - }), + editor.node.bindKey(key, () => acceptSuggestedMessage(offered)), ); return () => bindings.forEach((unbind) => unbind()); }); @@ -279,6 +265,12 @@ export function Composer(props: ComposerViewInput) { let unbind = () => {}; cleanup(() => unbind()); const cardRoot = signal(null); + const toggleAttachments = () => { + const owner = props.session(); + if (owner && owner.editable()) + owner.attachments.open({ owner: owner.sessionId, value: !owner.attachments.picker() }); + else if (pending()) pending()!.openPicker(!pending()!.picking()); + }; // A file drag over the editor leaves the conversation's own drop target. const draggingOverEditor = signal(false); const fileDragging = () => @@ -290,25 +282,33 @@ export function Composer(props: ComposerViewInput) { const node = cardRoot(); if (node && props.active() && owner?.editable() && owner.invocation() === null && owner.draft()) return keymap.bind('ctrl+c', completion.clearDraft, { - when: 'always', + when: 'notEditing', within: node, fallback: true, }); }); effect(() => { - const stop = props.stopAction(); + const owner = props.session(); + const current = predictionEditor(); + if ( + owner && + current?.owner === owner && + props.active() && + owner.editable() && + owner.invocation() === null && + owner.draft() + ) + return current.node.bindKey('ctrl+c', completion.clearDraft, { fallback: true }); + }); + const escapeComposer = () => { + if (completion.composing()) return; + const suggestion = suggestedMessage(); + if (suggestion) props.session()!.prediction.dismiss(suggestion.sourceTurnId); + else props.stopAction()(); + }; + effect(() => { const node = cardRoot(); - if (node) - return keymap.bind( - 'escape', - () => { - if (completion.composing()) return; - const suggestion = suggestedMessage(); - if (suggestion) props.session()!.prediction.dismiss(suggestion.sourceTurnId); - else stop(); - }, - { when: 'always', within: node }, - ); + if (node) return keymap.bind('escape', escapeComposer, { when: 'notEditing', within: node }); }); const workspaceLabel = () => { const path = props.session()?.workspace() ?? pending()?.workspace(); @@ -570,13 +570,7 @@ export function Composer(props: ComposerViewInput) { unbind = keymap.bind( 'mod+u', () => { - const owner = props.session(); - if (owner && owner.editable()) - owner.attachments.open({ - owner: owner.sessionId, - value: !owner.attachments.picker(), - }); - else if (pending()) pending()!.openPicker(!pending()!.picking()); + toggleAttachments(); }, { when: 'always', within: node }, ); @@ -607,7 +601,7 @@ export function Composer(props: ComposerViewInput) { const owner = session(); const local = owner === null ? pending() : null; const path = signal(''); - const editor = signal(null); + const editor = signal(null); const selection = signal({ start: 0, end: 0 }); const paste = local ? createComposerPaste({ @@ -719,19 +713,18 @@ export function Composer(props: ComposerViewInput) { maxHeight: compact() ? 64 : 144, }} > - { editor.set(node); props.attach(node); + node.bindKey('mod+u', toggleAttachments); completion.attach(owner?.sessionId ?? null, node); predictionEditor.set(owner ? { owner, node } : null); }} + onEscape={escapeComposer} testID="message" accessibilityLabel={tr('Message composer')} autoFocus={owner !== null || local !== null} - multiline - submitBehavior="submit" - tokens={skillTokens()} value={owner?.draft() ?? local?.draft() ?? ''} readonly={owner ? !owner.editable() : !local || local.sending()} onChange={ @@ -791,14 +784,6 @@ export function Composer(props: ComposerViewInput) { : undefined } onCaretMove={owner ? (event) => completion.caret(owner, event) : undefined} - onCompositionChange={ - owner || local - ? (event) => { - if (owner || pending() === local) - completion.composition(owner, event); - } - : undefined - } onSubmit={ owner ? (event) => { @@ -828,7 +813,6 @@ export function Composer(props: ComposerViewInput) { flexGrow: 1, flexShrink: 1, color: C.text, - placeholderColor: C.muted, ...bodyTextStyle, ...(owner !== null && suggestedMessage() !== null ? { diff --git a/plugins/agent/composer/ui/ComposerCompletion.tsx b/plugins/agent/composer/ui/ComposerCompletion.tsx index 5d82e248457..77b42c285da 100644 --- a/plugins/agent/composer/ui/ComposerCompletion.tsx +++ b/plugins/agent/composer/ui/ComposerCompletion.tsx @@ -43,13 +43,17 @@ import type { CompletionToken, Selection } from './composerCompletionSyntax.ts'; import { C, bodyTextStyle, captionTextStyle } from '../../../../products/maka/workbench/app/ui'; import { Icon } from '../../../../products/maka/workbench/app/Icon'; import { createComposerPaste } from './composerPaste'; +import { + inputEditor, + type ComposerEditor, +} from '../../../../products/maka/workbench/app/ComposerEditor'; type Source = 'add' | 'files' | 'skills' | 'commands' | 'slash'; -type Editor = { sessionId: string | null; node: InputRef }; +type Editor = { sessionId: string | null; node: ComposerEditor }; type Read = { serial: number; owner: ComposerSession; - editor: InputRef; + editor: ComposerEditor; text: string; selection: Selection; token: CompletionToken | null; @@ -65,7 +69,7 @@ type Row = { id: string; title: string; detail?: string; - icon: 'paperclip' | 'file' | 'folder' | 'code' | 'command'; + icon: 'paperclip' | 'file' | 'folder' | 'book' | 'code' | 'command'; disabled?: boolean; action: { read: number; id: string }; } & ( @@ -97,10 +101,10 @@ export function createComposerCompletion(options: Options) { const editor = signal(null); const caret = signal(null); const selection = signal({ start: 0, end: 0 }); - const composing = signal(false); + const composing = () => !!editor()?.node.composing(); const explicit = signal(null); const query = signal(''); - const queryInput = signal(null); + const queryInput = signal(null); const querySelection = signal({ start: 0, end: 0 }); const queryComposing = signal(false); const opened = signal(null); @@ -248,7 +252,7 @@ export function createComposerCompletion(options: Options) { return tr('Use a skill'); }, detail: '/', - icon: 'code', + icon: 'book', }), ...options .commands() @@ -370,7 +374,7 @@ export function createComposerCompletion(options: Options) { insert: skill.invocation, title: skill.name, detail: skill.description, - icon: 'code', + icon: 'book', }), ), ); @@ -635,47 +639,35 @@ export function createComposerCompletion(options: Options) { if (!target) return; const valid = () => current === read && owns(read); const unbind = [ - keymap.bind( - 'escape', - () => { - if (valid()) dismiss(); - }, - { when: 'always', within: target }, - ), + target.bindKey('escape', () => { + if (valid()) dismiss(); + }), ]; if (loading() || rows().some((item) => !item.disabled)) { unbind.push( - keymap.bind( + target.bindKey( 'down', () => { if (valid()) move(1); }, { - when: 'always', repeat: true, - within: target, }, ), - keymap.bind( + target.bindKey( 'up', () => { if (valid()) move(-1); }, { - when: 'always', repeat: true, - within: target, }, ), - keymap.bind( - 'enter', - () => { - if (!valid()) return; - const item = rows()[index()]; - if (item) accept(item.action); - }, - { when: 'always', within: target }, - ), + target.bindKey('enter', () => { + if (!valid()) return; + const item = rows()[index()]; + if (item) accept(item.action); + }), ); } return () => unbind.forEach((remove) => remove()); @@ -724,16 +716,15 @@ export function createComposerCompletion(options: Options) { }); close(); }, - anchor: () => (opened()?.explicit ? addAnchor : (caret() ?? editor()?.node)), + anchor: () => (opened()?.explicit ? addAnchor : (caret() ?? editor()?.node.anchor)), addButton(node: ViewRef) { addAnchor = node; }, - attach(sessionId: string | null, node: InputRef) { + attach(sessionId: string | null, node: ComposerEditor) { directoryHint = null; editor.set({ sessionId, node }); selection.set({ start: 0, end: 0 }); caret.set(null); - composing.set(false); queryComposing.set(false); explicit.set(null); }, @@ -748,9 +739,6 @@ export function createComposerCompletion(options: Options) { selection(owner: ComposerSession, event: Selection) { if (options.session() === owner) selection.set({ ...event }); }, - composition(owner: ComposerSession | null, event: { composing: boolean }) { - if (options.session() === owner) composing.set(event.composing); - }, toggleAdd(target: { sessionId: string | null }) { const owner = options.session(); if ( @@ -788,7 +776,7 @@ export function createComposerCompletion(options: Options) { list = node; }, queryInput(node: InputRef) { - queryInput.set(node); + queryInput.set(inputEditor(node, queryComposing)); querySelection.set({ start: 0, end: 0 }); node.focus(); }, diff --git a/plugins/agent/composer/ui/composerPaste.ts b/plugins/agent/composer/ui/composerPaste.ts index d7077143943..4bdecd1102d 100644 --- a/plugins/agent/composer/ui/composerPaste.ts +++ b/plugins/agent/composer/ui/composerPaste.ts @@ -17,10 +17,9 @@ * under the License. */ -import { effect, platform } from 'nsh'; +import { effect, keymap, platform } from 'nsh'; import { Window } from 'nsh/desktop'; -import type { InputRef } from 'nsh'; -import { keymap } from 'nsh'; +import type { ComposerEditor } from '../../../../products/maka/workbench/app/ComposerEditor'; import type { ComposerSession, ComposerViewInput, @@ -39,7 +38,7 @@ const report = (owner: PasteOwner, reason: unknown) => { type Target = { owner: Owner; - node: InputRef; + node: ComposerEditor; text: string; selection: Selection; }; @@ -47,7 +46,7 @@ type Target = { export function createComposerPaste(options: { owner(): Owner | null; active(): boolean; - editor(owner: Owner): InputRef | null; + editor(owner: Owner): ComposerEditor | null; selection(): Selection; composing(): boolean; dismiss(): void; @@ -111,15 +110,14 @@ export function createComposerPaste(options: { const owner = options.owner(); const node = owner && options.editor(owner); if (!node || !owner || !options.active() || !editable(owner) || options.composing()) return; - const unbind = ['mod+v', 'alt+v', 'ctrl+alt+v'].map((key) => - keymap.bind( - key, - () => { - const target = capture(owner); - if (target) clipboard(target); - }, - { when: 'always', within: node }, - ), + // Pixel terminals render natively but retain Control-key shortcuts. + const controlKeys = keymap.label('mod+v') === keymap.label('ctrl+v'); + const keys = controlKeys ? ['mod+v', 'alt+v', 'ctrl+alt+v'] : ['mod+v']; + const unbind = keys.map((key) => + node.bindKey(key, () => { + const target = capture(owner); + if (target) clipboard(target); + }), ); return () => unbind.forEach((remove) => remove()); }); diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx index df6c1caef46..85384095b27 100644 --- a/plugins/agent/conversations/ui/SessionSurface.tsx +++ b/plugins/agent/conversations/ui/SessionSurface.tsx @@ -56,6 +56,7 @@ import { type ConversationPreviewInput, type ConversationSessionHandle, type ComposerSession, + type ComposerFocusHandle, type ComposerViewInput, } from '../../../../packages/plugin-sdk/src/workbench-views.js'; import { delayed } from '../../../../products/maka/workbench/app/delayed'; @@ -354,7 +355,7 @@ export function createConversationUi( }; let dropFiles: ((event: { uris: string[] }) => void) | null = null; const fileDragging = signal(false); - let editor: { focus(): void } | undefined; + let editor: ComposerFocusHandle | undefined; const composerSession = computed(() => { const owner = handle(); return owner && owner.sessionId === selected() ? projectComposerSession(owner, host) : null; @@ -1570,23 +1571,30 @@ export function createConversationUi( // A side pane binds its conversation's keys within itself, so they take // over from the main pane's only while focus is there. const unbindKeys: (() => void)[] = []; + const unbindEditorKeys: (() => void)[] = []; cleanup(() => { - for (const unbind of unbindKeys.splice(0)) unbind(); + for (const unbind of [...unbindKeys.splice(0), ...unbindEditorKeys.splice(0)]) unbind(); }); - const bindKeys = (within?: ViewRef) => { + const bindKeys = (within?: ViewRef, input?: ComposerFocusHandle) => { + if (input) + unbindEditorKeys.push(input.bindKey('ctrl+c', interrupt.interrupt, { fallback: true })); const options = within ? { when: 'always' as const, within } : { when: 'always' as const }; - unbindKeys.push(keymap.bind('mod+p', () => invokeOwn('commands'), options)); - if (ownTitleBar) unbindKeys.push(keymap.bind('mod+f', () => invokeOwn('find'), options)); - unbindKeys.push(keymap.bind('mod+g', () => invokeOwn('models'), options)); - unbindKeys.push(keymap.bind('mod+l', () => invokeOwn('latest'), options)); - unbindKeys.push(keymap.bind('alt+up', () => invokeOwn('history-focus'), options)); - unbindKeys.push(keymap.bind('alt+down', () => invokeOwn('activity-focus'), options)); - if (within) return; - unbindKeys.push(keymap.bind('mod+o', () => invokeOwn('sessions'), options)); - unbindKeys.push(keymap.bind('mod+n', () => invokeOwn('new'), options)); + const bind = (key: string, action: () => void) => { + const unbind = input ? input.bindKey(key, action) : keymap.bind(key, action, options); + (input ? unbindEditorKeys : unbindKeys).push(unbind); + }; + bind('mod+p', () => invokeOwn('commands')); + if (ownTitleBar) bind('mod+f', () => invokeOwn('find')); + bind('mod+g', () => invokeOwn('models')); + bind('mod+l', () => invokeOwn('latest')); + bind('alt+up', () => invokeOwn('history-focus')); + bind('alt+down', () => invokeOwn('activity-focus')); + if (side) return; + bind('mod+o', () => invokeOwn('sessions')); + bind('mod+n', () => invokeOwn('new')); if (ownTitleBar) { - unbindKeys.push(keymap.bind('mod+shift+]', () => stepSession(1), options)); - unbindKeys.push(keymap.bind('mod+shift+[', () => stepSession(-1), options)); + bind('mod+shift+]', () => stepSession(1)); + bind('mod+shift+[', () => stepSession(-1)); } }; let sideKeys: ViewRef | undefined; @@ -2376,7 +2384,9 @@ export function createConversationUi( } attach={(node) => { + for (const unbind of unbindEditorKeys.splice(0)) unbind(); editor = node; + bindKeys(side ? paneRoot : undefined, node); }} commands={composerCommands} models={ diff --git a/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs b/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs index d52ae6bcbc2..dc1c789a4f7 100644 --- a/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs +++ b/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs @@ -85,7 +85,7 @@ fn every_visible_turn_mark_can_be_clicked_with_cell_coordinates() { ); return; } - let mut host = Registry::ui_set("maka/host"); + let mut host = maka_workbench::bindings::ui_registry(); host.native_values(&["host"]); let bundle = nsh_bundler::bundle(&nsh_bundler::BundleOptions { entry: std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -152,7 +152,7 @@ fn check_marks(source: &str) { cx, ); view.on_error(move |message| *failed.borrow_mut() = Some(message.to_owned())); - let mut host = Registry::ui_set("maka/host"); + let mut host = maka_workbench::bindings::ui_registry(); host.value("host", move || { Instance::new(RailHost { active_scale: host_scale.clone(), diff --git a/products/maka/terminal-shell/src/terminal/ui.rs b/products/maka/terminal-shell/src/terminal/ui.rs index 5cdb0c32729..b52b3fa2d4f 100644 --- a/products/maka/terminal-shell/src/terminal/ui.rs +++ b/products/maka/terminal-shell/src/terminal/ui.rs @@ -571,7 +571,7 @@ mod tests { fn collapsed_workspace_pages(scenario: &'static str) { use nsh_core::platform::{Idiom, Renderer}; - use nsh_core::prelude::{Instance, Registry, class}; + use nsh_core::prelude::{Instance, class}; #[class] struct Presentation { @@ -607,7 +607,7 @@ mod tests { thread::yield_now(); } }; - let mut registry = Registry::ui_set("maka/host"); + let mut registry = maka_workbench::bindings::ui_registry(); registry.value("host", move || { Instance::new(Presentation { scenario: scenario.into(), diff --git a/products/maka/terminal-shell/tests/fixtures/components.tsx b/products/maka/terminal-shell/tests/fixtures/components.tsx index ca441a9fd3c..69755571ff1 100644 --- a/products/maka/terminal-shell/tests/fixtures/components.tsx +++ b/products/maka/terminal-shell/tests/fixtures/components.tsx @@ -308,6 +308,7 @@ function CompletionComposer(props: { prediction?: boolean }) { {action()} {lookup()} + {draft()} owner} active={() => true} diff --git a/products/maka/terminal-shell/tests/real_host/attachments.rs b/products/maka/terminal-shell/tests/real_host/attachments.rs index 5a9e9668d19..78a16b7eb4e 100644 --- a/products/maka/terminal-shell/tests/real_host/attachments.rs +++ b/products/maka/terminal-shell/tests/real_host/attachments.rs @@ -23,19 +23,32 @@ use maka_app_client::{Client, Draft}; const ORIGINAL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMQNE5uAAACKAEoAfdvjgAAAABJRU5ErkJggg=="; -async fn stored_draft(client: &Client, session: &str, expected: impl Fn(&Draft) -> bool) -> Draft { +async fn stored_draft( + view: &mut AppView, + client: &Client, + session: &str, + expected: impl Fn(&Draft) -> bool, +) -> Draft { + let mut observed = None; tokio::time::timeout(support::WAIT_TIMEOUT, async { loop { - if let Some(draft) = client.load_draft("agent", session, EDITOR).await.unwrap() - && expected(&draft) - { - return draft; + support::tick(view); + if let Some(draft) = client.load_draft("agent", session, EDITOR).await.unwrap() { + if expected(&draft) { + return draft; + } + observed = Some(draft.text); } tokio::time::sleep(Duration::from_millis(5)).await; } }) .await - .expect("the complete attachment draft did not commit") + .unwrap_or_else(|_| { + panic!( + "the complete attachment draft did not commit: {observed:?}; screen: {}", + view.snapshot() + ) + }) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -50,13 +63,11 @@ async fn directory_path_paste_remains_text_in_an_existing_composer() { view.handle_event(Event::Paste("查看 ".into())); commit_native_events(&mut view).await; view.handle_event(Event::Paste(format!("'{}'", directory.display()))); - wait_view_state(&mut view, |view, _| { - view.surface() - .input_context() - .is_some_and(|input| input.text.as_deref() == Some(expected.as_str())) + commit_native_events(&mut view).await; + let draft = stored_draft(&mut view, &client, &fixture.session, |draft| { + draft.text == expected }) .await; - let draft = stored_draft(&client, &fixture.session, |draft| draft.text == expected).await; assert!(draft.attachments.is_empty()); drop(view); bindings.close().await.unwrap(); @@ -115,7 +126,8 @@ async fn attachment_only_send_uses_the_selected_bytes_after_the_local_file_chang let (mut view, bindings) = fixture.view(client.clone(), Some(fixture.session.clone())); wait_editable_composer(&mut view).await; view.handle_event(Event::Paste(format!("'{}'", path.display()))); - let stored = stored_draft(&client, &fixture.session, |draft| { + commit_native_events(&mut view).await; + let stored = stored_draft(&mut view, &client, &fixture.session, |draft| { draft.attachments.len() == 1 }) .await; @@ -124,15 +136,9 @@ async fn attachment_only_send_uses_the_selected_bytes_after_the_local_file_chang let dropped = fixture.directory.path().join("dropped image.png"); std::fs::write(&dropped, &bytes).unwrap(); wait_editable_composer(&mut view).await; - let composer = view.surface().focused().unwrap(); - view.control().unwrap().dispatch_event( - composer, - nsh_core::EventKind::FileDrop, - nsh_core::EventPayload::FileDrop(vec![ - reqwest::Url::from_file_path(&dropped).unwrap().into(), - ]), - ); - let stored = stored_draft(&client, &fixture.session, |draft| { + view.handle_event(Event::Paste(format!("'{}'", dropped.display()))); + commit_native_events(&mut view).await; + let stored = stored_draft(&mut view, &client, &fixture.session, |draft| { draft.attachments.len() == 2 }) .await; @@ -177,7 +183,7 @@ async fn attachment_only_send_uses_the_selected_bytes_after_the_local_file_chang request.finish().await; fixture.idle(&client, &fixture.session).await; wait_view(&mut view, |screen| screen.contains("Attachment received")).await; - stored_draft(&client, &fixture.session, |draft| { + stored_draft(&mut view, &client, &fixture.session, |draft| { draft.attachments.is_empty() && draft.text.is_empty() }) .await; diff --git a/products/maka/terminal-shell/tests/real_host/components.rs b/products/maka/terminal-shell/tests/real_host/components.rs index 3007aed2289..43824658227 100644 --- a/products/maka/terminal-shell/tests/real_host/components.rs +++ b/products/maka/terminal-shell/tests/real_host/components.rs @@ -42,7 +42,7 @@ impl Presentation { } async fn component(scenario: &'static str) -> AppView { - let mut registry = Registry::ui_set("maka/host"); + let mut registry = maka_workbench::bindings::ui_registry(); registry.value("host", move || { Instance::new(Presentation { scenario: scenario.into(), @@ -74,6 +74,12 @@ async fn component(scenario: &'static str) -> AppView { view } +fn draft_is(view: &AppView, expected: &str) -> bool { + view.accessibility_snapshot() + .iter() + .any(|node| node.label == format!("Draft {expected}")) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn activity_resumes_following_after_reading_an_expanded_tool() { let mut view = component("activity-follow").await; @@ -179,19 +185,12 @@ async fn composer_slash_command_and_workspace_reference_edit_the_native_draft() view.handle_event(TerminalEvent::Paste("/insp".into())); support::click_button(&mut view, "/inspect").await; wait_view(&mut view, |screen| screen.contains("command:inspect")).await; - wait_view_state(&mut view, |view, _| { - view.surface() - .input_context() - .is_some_and(|input| input.text.as_deref() == Some("")) - }) - .await; + wait_view_state(&mut view, |view, _| draft_is(view, "")).await; view.handle_event(TerminalEvent::Paste("check @docs/Rel".into())); + wait_view(&mut view, |screen| screen.contains("files:Rel:ZG9jcw==")).await; support::click_button(&mut view, "docs/Release Notes.md").await; - wait_view_state(&mut view, |view, screen| { - screen.contains("files:Rel:ZG9jcw==") - && view.surface().input_context().is_some_and(|input| { - input.text.as_deref() == Some("check @\"docs/Release Notes.md\" ") - }) + wait_view_state(&mut view, |view, _| { + draft_is(view, "check @\"docs/Release Notes.md\" ") }) .await; } @@ -207,10 +206,7 @@ async fn composer_tab_accepts_prediction_as_an_undoable_draft_then_navigates() { ))); wait_view_state(&mut view, |view, screen| { screen.contains("No command") - && view - .surface() - .input_context() - .is_some_and(|input| input.text.as_deref() == Some("Review the changes.")) + && draft_is(view, "Review the changes.") && !view .accessibility_snapshot() .iter() @@ -233,12 +229,7 @@ async fn composer_tab_accepts_prediction_as_an_undoable_draft_then_navigates() { ))); wait_focused_input(&mut view, "Message composer").await; shortcut(&mut view, 'z'); - wait_view_state(&mut view, |view, _| { - view.surface() - .input_context() - .is_some_and(|input| input.text.as_deref() == Some("")) - }) - .await; + wait_view_state(&mut view, |view, _| draft_is(view, "")).await; view.handle_event(TerminalEvent::Key(KeyEvent::new( KeyCode::Tab, KeyModifiers::NONE, @@ -271,10 +262,7 @@ async fn composer_prediction_preserves_reverse_tab_and_escape_restores_forward_t KeyModifiers::NONE, ))); wait_focused_input(&mut view, "Message composer").await; - assert_eq!( - view.surface().input_context().unwrap().text.as_deref(), - Some("") - ); + assert!(draft_is(&view, "")); wait_enabled_node(&mut view, "Use suggested message").await; view.handle_event(TerminalEvent::Key(KeyEvent::new( KeyCode::Esc, @@ -311,12 +299,7 @@ async fn skill_completion_is_inserted_and_backspace_deletes_the_whole_native_chi }) .await; support::click_button(&mut view, "Review changes").await; - wait_view_state(&mut view, |view, _| { - view.surface() - .input_context() - .is_some_and(|input| input.text.as_deref() == Some("before /skill:review ")) - }) - .await; + wait_view_state(&mut view, |view, _| draft_is(view, "before /skill:review ")).await; view.handle_event(TerminalEvent::Key(KeyEvent::new( KeyCode::Backspace, KeyModifiers::NONE, @@ -326,10 +309,5 @@ async fn skill_completion_is_inserted_and_backspace_deletes_the_whole_native_chi KeyCode::Backspace, KeyModifiers::NONE, ))); - wait_view_state(&mut view, |view, _| { - view.surface() - .input_context() - .is_some_and(|input| input.text.as_deref() == Some("before ")) - }) - .await; + wait_view_state(&mut view, |view, _| draft_is(view, "before ")).await; } diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index ea3a74a4f1c..f12ec450804 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -306,13 +306,20 @@ impl Desktop { } fn key(&self, name: &str, control: bool) { - let keystroke = Keystroke { - key: name.into(), - key_char: None, - modifiers: Modifiers { + self.key_with( + name, + Modifiers { control, ..Default::default() }, + ); + } + + fn key_with(&self, name: &str, modifiers: Modifiers) { + let keystroke = Keystroke { + key: name.into(), + key_char: None, + modifiers, }; self.platform .dispatch_input(PlatformInput::KeyDown(gpui::KeyDownEvent { @@ -343,26 +350,24 @@ impl Desktop { .await; } - fn copy_input(&self) -> Option { - // Outside an editor Ctrl+C interrupts, and its dialog refuses the focus - // being waited for. - self.platform.text_input_target()?; + async fn copy_input(&self) -> String { + self.target_after(None).await; + let _ = self.platform.take_clipboard_write(); self.key("a", true); self.key("c", true); - self.platform - .take_clipboard_write() - .and_then(|item| item.text()) + self.until("the selected draft was not copied", || { + self.platform + .take_clipboard_write() + .and_then(|item| item.text()) + }) + .await } async fn type_text(&self, text: &str) { - let _ = self.platform.take_clipboard_write(); - self.until("the editor did not accept the draft", || { - self.platform.text_input_target()?; - self.key("a", true); - self.platform.paste(text).unwrap(); - self.copy_input().filter(|copied| copied == text) - }) - .await; + self.target_after(None).await; + self.key("a", true); + self.platform.paste(text).unwrap(); + self.expect_text(text).await; } /// Enter does nothing until the typed draft can be sent. @@ -372,25 +377,15 @@ impl Desktop { } async fn expect_text(&self, text: &str) { - let _ = self.platform.take_clipboard_write(); - self.until("the expected draft did not appear in the editor", || { - self.copy_input().filter(|copied| copied == text) - }) - .await; - } - - async fn expect_editor_text(&self, expected: &str) { - self.until( - &format!("the native editor did not contain {expected:?}"), - || { - self.app - .update(|cx| { - self.window.update(cx, |view, window, cx| { - view.text_for_range(0..u32::MAX as usize, &mut None, window, cx) - }) - }) - .unwrap() - .filter(|text| text == expected) + self.wait( + &format!("the expected draft {text:?} did not appear in the editor"), + async { + loop { + let copied = self.copy_input().await; + if copied == text { + break; + } + } }, ) .await; @@ -456,6 +451,21 @@ impl Desktop { }) } + async fn composer_height( + &self, + stage: &str, + ready: impl Fn(gpui::Pixels) -> bool, + ) -> gpui::Pixels { + self.until(stage, || { + let fields = self.controls("Focus message composer"); + let [field] = fields.as_slice() else { + return None; + }; + (field.size.height > px(0.) && ready(field.size.height)).then_some(field.size.height) + }) + .await + } + /// Where the window shows the one control labelled `label`, once it does. async fn control(&self, label: &str) -> Bounds { self.until(&format!("no single control labelled {label}"), || { @@ -677,7 +687,7 @@ async fn desktop_remembers_folders_without_restoring_unsent_drafts() { .unwrap(); desktop.target_after(None).await; let _ = desktop.platform.take_clipboard_write(); - assert_eq!(desktop.copy_input().unwrap_or_default(), ""); + assert!(desktop.controls("Send message").is_empty()); assert!( desktop .controls(&format!( @@ -1313,13 +1323,21 @@ async fn desktop_steering_freezes_each_message_and_queue_is_one_shot() { async fn desktop_file_gestures_preserve_drafts_and_stage_an_image() { let fixture = images_fixture().await; let desktop = Desktop::open(&fixture, &fixture.address).await; - let directory = fixture.directory.path().join("未命名文件夹 2"); + // Observe the wrapped path before copying: Select All would cancel an + // insertion whose file intake is still pending. + let directory = fixture + .directory + .path() + .join(format!("{}未命名文件夹 2", "x".repeat(220))); std::fs::create_dir(&directory).unwrap(); for (pending, blur) in [(false, false), (false, true), (true, true)] { - if pending { + let pending_height = if pending { desktop.new_draft().await; desktop.type_text("查看 ").await; desktop.key("right", false); + let before = desktop + .composer_height("the pending composer did not lay out", |_| true) + .await; assert!(desktop.app.update(|cx| { desktop .window @@ -1331,20 +1349,49 @@ async fn desktop_file_gestures_preserve_drafts_and_stage_an_image() { }) .unwrap() })); + let height = desktop + .composer_height("the pasted directory did not wrap", |height| { + height > before + }) + .await; desktop - .expect_editor_text(&format!("查看 {}", directory.display())) + .expect_text(&format!("查看 {}", directory.display())) .await; - } + Some(height) + } else { + None + }; let original = "前😀待替换 后"; desktop.type_text(original).await; - desktop.app.update(|cx| { - desktop - .window - .update(cx, |view, window, cx| { - view.set_selected_text_range(3..6, window, cx); - }) - .unwrap(); - }); + let before = desktop + .composer_height("the short draft did not lay out", |height| { + pending_height.is_none_or(|pending| height < pending) + }) + .await; + desktop.key("home", false); + for _ in 0..2 { + desktop.key("right", false); + } + for _ in 0..3 { + desktop.key_with( + "right", + Modifiers { + shift: true, + ..Default::default() + }, + ); + } + let _ = desktop.platform.take_clipboard_write(); + desktop + .until("the replacement range was not selected", || { + desktop.key("c", true); + desktop + .platform + .take_clipboard_write() + .and_then(|item| item.text()) + .filter(|text| text == "待替换") + }) + .await; if blur { desktop.key("tab", false); desktop @@ -1376,11 +1423,21 @@ async fn desktop_file_gestures_preserve_drafts_and_stage_an_image() { }) .await; desktop.drop_paths(vec![directory.clone()], position); + let expanded = desktop + .composer_height("the dropped directory did not wrap", |height| { + height > before + }) + .await; let expected = format!("前😀{} 后", directory.display()); - desktop.expect_editor_text(&expected).await; + desktop.expect_text(&expected).await; assert_eq!(desktop.sessions().await.len(), 1); desktop.key("z", true); - desktop.expect_editor_text(original).await; + desktop.expect_text(original).await; + desktop + .composer_height("the restored draft did not shrink", |height| { + height < expanded + }) + .await; } assert_eq!(fixture.request_count.load(Ordering::SeqCst), 0); let (path, _) = image_file(&fixture); @@ -2209,7 +2266,18 @@ async fn desktop_parent_reviews_child_access_and_opens_attributed_sender_beside( .controls("Open source conversation beside this one") .is_empty() ); - desktop.press("Turn work details").await; + desktop + .retry( + "the completed child work details did not open", + || !desktop.controls("Process group").is_empty(), + || { + let controls = desktop.controls("Turn work details"); + if controls.len() == 1 { + desktop.press_at(controls[0].center()); + } + }, + ) + .await; let group = desktop.control("Process group").await; desktop.press("Process group").await; let message = desktop.control("Agent message").await; @@ -2442,9 +2510,10 @@ async fn desktop_composer_large_paste_keeps_controls_on_screen() { ) .await; let before = desktop.control("Focus message composer").await; - let draft = (0..200) + let mut draft = (0..200) .map(|i| format!("Traceback line {i}: a long pasted diagnostic with paths and details\n")) .collect::(); + draft.push_str("请检查 /skill:review 这个问题"); // TerminalPlatform::paste injects IME text, which strips control characters. // Native clipboard paste keeps the diagnostic's line breaks. desktop diff --git a/products/maka/terminal-shell/tests/real_host/keyboard.rs b/products/maka/terminal-shell/tests/real_host/keyboard.rs index 8d92b00f7a0..b59fd67e438 100644 --- a/products/maka/terminal-shell/tests/real_host/keyboard.rs +++ b/products/maka/terminal-shell/tests/real_host/keyboard.rs @@ -135,6 +135,7 @@ async fn editor_copy_and_undo_precede_interrupt_and_ctrl_c_requires_confirming_t screen.contains("Stop the current task?") }) .await; + commit_native_events(&mut view).await; // A second Ctrl+C dismisses the choice; it cannot confirm Stop. shortcut(&mut view, 'c'); wait_view(&mut view, |screen| { diff --git a/products/maka/terminal-shell/tests/real_host/mod.rs b/products/maka/terminal-shell/tests/real_host/mod.rs index 5a090ad421a..19feb0fc678 100644 --- a/products/maka/terminal-shell/tests/real_host/mod.rs +++ b/products/maka/terminal-shell/tests/real_host/mod.rs @@ -76,6 +76,7 @@ async fn wait_focused_input(view: &mut AppView, label: &str) { }) }) .await; + commit_native_events(view).await; } async fn editable(view: &mut AppView, label: &str) { @@ -198,10 +199,7 @@ async fn product_composition_sends_streams_and_restores_history_with_a_later_dra }) .await; wait_editable_composer(&mut view).await; - assert_eq!( - view.surface().input_context().unwrap().text.as_deref(), - Some("A later draft remains private") - ); + support::wait_draft(&reopened, &fixture.session, "A later draft remains private").await; support::click_button(&mut view, "Copy message").await; wait_view(&mut view, |screen| screen.contains("Message copied")).await; assert_eq!(fixture.request_count.load(Ordering::SeqCst), 1); diff --git a/products/maka/terminal-shell/tests/real_host/resumable.rs b/products/maka/terminal-shell/tests/real_host/resumable.rs index 283910bdf9a..f1dcda2fed8 100644 --- a/products/maka/terminal-shell/tests/real_host/resumable.rs +++ b/products/maka/terminal-shell/tests/real_host/resumable.rs @@ -18,9 +18,11 @@ */ use super::*; +use maka_app_client::Client; use maka_plugin_core::operation::{OperationOutcome, Receipt}; -async fn replace_input(view: &mut AppView, label: &str, text: &str) { +async fn replace_draft(view: &mut AppView, client: &Client, session: &str, text: &str) { + let label = "Message composer"; wait_enabled_node(view, label).await; tab_to_label(view, label).await; wait_focused_input(view, label).await; @@ -33,12 +35,8 @@ async fn replace_input(view: &mut AppView, label: &str, text: &str) { } else { assert!(view.handle_event(Event::Paste(text.into()))); } - wait_view_state(view, |view, _| { - view.surface() - .input_context() - .is_some_and(|context| context.text.as_deref() == Some(text)) - }) - .await; + commit_native_events(view).await; + support::wait_draft(client, session, text).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -56,7 +54,13 @@ async fn composer_continues_an_interrupted_run_and_preserves_draft_send_priority .await; let (mut view, bindings) = fixture.view(client.clone(), Some(fixture.session.clone())); wait_editable_composer(&mut view).await; - replace_input(&mut view, "Message composer", "ORIGINAL_RESUME_INPUT").await; + replace_draft( + &mut view, + &client, + &fixture.session, + "ORIGINAL_RESUME_INPUT", + ) + .await; support::click_button(&mut view, "Send message").await; support::wait_enabled_button(&mut view, "Stop response").await; let mut request = fixture.next_request().await; @@ -76,7 +80,7 @@ async fn composer_continues_an_interrupted_run_and_preserves_draft_send_priority support::wait_enabled_button(&mut view, "Continue response").await; fixture.idle(&client, &fixture.session).await; drop(request); - replace_input(&mut view, "Message composer", "UNSENT_NEW_REQUEST").await; + replace_draft(&mut view, &client, &fixture.session, "UNSENT_NEW_REQUEST").await; support::wait_enabled_button(&mut view, "Send message").await; assert!( !view @@ -84,7 +88,7 @@ async fn composer_continues_an_interrupted_run_and_preserves_draft_send_priority .iter() .any(|node| node.label == "Continue response") ); - replace_input(&mut view, "Message composer", "").await; + replace_draft(&mut view, &client, &fixture.session, "").await; support::click_button(&mut view, "Continue response").await; support::wait_enabled_button(&mut view, "Stop response").await; let mut continuation = fixture.next_request().await; @@ -106,7 +110,7 @@ async fn composer_continues_an_interrupted_run_and_preserves_draft_send_priority .await .unwrap(); assert_ne!(resumed["run"]["turnId"], source_turn); - replace_input(&mut view, "Message composer", "UNSENT_WHILE_RUNNING").await; + replace_draft(&mut view, &client, &fixture.session, "UNSENT_WHILE_RUNNING").await; support::wait_enabled_button(&mut view, "Send message").await; shortcut(&mut view, 'p'); select_command(&mut view, "Add to queue").await; diff --git a/products/maka/terminal-shell/tests/real_host/send_hold.rs b/products/maka/terminal-shell/tests/real_host/send_hold.rs index 249bcaa9ef7..a15bf320ab3 100644 --- a/products/maka/terminal-shell/tests/real_host/send_hold.rs +++ b/products/maka/terminal-shell/tests/real_host/send_hold.rs @@ -77,7 +77,9 @@ async fn a_rejected_send_preserves_its_snapshot_and_the_next_draft() { support::click_button(&mut view, "Messages").await; // Check the composer only once the send has settled. let screen = wait_view_state(&mut view, |view, screen| { - screen.contains("/skill:missing-fixture first") + view.accessibility_snapshot() + .iter() + .any(|node| node.label == sent) && screen.contains("next draft") && screen.contains("Retry") && view diff --git a/products/maka/workbench/app/ComposerEditor.ts b/products/maka/workbench/app/ComposerEditor.ts new file mode 100644 index 00000000000..2d7692dcdb2 --- /dev/null +++ b/products/maka/workbench/app/ComposerEditor.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { keymap } from 'nsh'; +import type { InputRef, ViewRef } from 'nsh'; + +export type EditorKeyOptions = { repeat?: boolean; fallback?: boolean }; + +/** All offsets and replacement expectations refer to the original draft's UTF-16 text. */ +export interface ComposerEditor { + readonly anchor: ViewRef; + focus(): void; + blur(): void; + composing(): boolean; + replaceRange: InputRef['replaceRange']; + bindKey(key: string, action: () => void, options?: EditorKeyOptions): () => void; +} + +/** Ordinary query fields use their native input and keyboard scope. */ +export function inputEditor(node: InputRef, composing: () => boolean): ComposerEditor { + return { + anchor: node, + focus: () => node.focus(), + blur: () => node.blur(), + composing, + replaceRange: (request) => node.replaceRange(request), + bindKey: (key, action, options) => + keymap.bind(key, action, { when: 'always', within: node, ...options }), + }; +} diff --git a/products/maka/workbench/app/Icon.tsx b/products/maka/workbench/app/Icon.tsx index 4353ce9179a..f3252c2c797 100644 --- a/products/maka/workbench/app/Icon.tsx +++ b/products/maka/workbench/app/Icon.tsx @@ -7,6 +7,7 @@ import { platform } from 'nsh'; import { lucide, type LucideElement } from './lucide'; const icons = { + book: { lucide: 'book', glyph: '📖' }, back: { lucide: 'chevron-left', glyph: '‹' }, sidebar: { lucide: 'panel-left', glyph: '◫' }, split: { lucide: 'columns-2', glyph: '◫' }, diff --git a/products/maka/workbench/app/MessageRow.tsx b/products/maka/workbench/app/MessageRow.tsx index f01d80f6aca..5c877870ced 100644 --- a/products/maka/workbench/app/MessageRow.tsx +++ b/products/maka/workbench/app/MessageRow.tsx @@ -29,6 +29,7 @@ import { messageKey, senderSession, type Row } from './history'; import type { MessageOrigin } from '../../../../packages/plugin-sdk/src/index.js'; import { MarkdownBody } from './MarkdownBody'; import { SourceText } from './SourceText'; +import { SkillMessage } from './SkillMessage'; import { C, bodyTextStyle, @@ -483,9 +484,7 @@ export function MessageRow({ gap: 8, }} > - - - + 0}> ; + onChange?(event: { value: string }): void; + onSelectionChange?(event: Selection): void; + onCaretMove?(event: CaretEvent): void; + onSubmit?(event: { text: string }): void; + onPaste?(event: { text: string; uris: string[]; files: unknown[] }): void; + onFocus?(): void; + onBlur?(): void; + onEscape?(): void; + onFileDrop?(event: { uris: string[] }): void; + onFileDragEnter?(): void; + onFileDragLeave?(): void; +}; +type Binding = { key: string; action: () => void; options?: EditorKeyOptions }; + +/** Product-owned plain-text composer; skills are inline atoms in the existing rich editor. */ +export function SkillInput(props: Props) { + const native = signal(null); + const status = signal(null); + const composition = signal(false); + const valueCount = signal(0); + const height = signal(bodyTextStyle.lineHeight); + let alive = true; + let frame: ReturnType | undefined; + const captureKeys = signal<{ key: string; fallback: boolean }[]>([]); + const bindings = new Map(); + const refreshKeys = () => { + const current = new Map(); + for (const binding of bindings.values()) current.set(binding.key, binding); + captureKeys.set([ + ...(!props.readonly && !current.has('escape') ? [{ key: 'escape', fallback: false }] : []), + ...Array.from(current.values(), (binding) => ({ + key: binding.key, + fallback: binding.options?.fallback ?? false, + })), + ]); + }; + cleanup(() => { + alive = false; + bindings.clear(); + if (frame !== undefined) cancelAnimationFrame(frame); + }); + effect(() => { + props.readonly; + untracked(refreshKeys); + }); + const bindKey = (key: string, action: () => void, options?: EditorKeyOptions) => { + const target = native()?.node; + // These keystrokes reach the native input instead of RichEditor's key table. + if (key === 'mod+v' || key === 'alt+v' || key === 'tab') + return target + ? keymap.bind(key, action, { when: 'always', within: target, ...options }) + : () => {}; + const ticket = {}; + bindings.set(ticket, { key, action, options }); + untracked(refreshKeys); + return () => { + if (!alive) return; + bindings.delete(ticket); + untracked(refreshKeys); + }; + }; + effect(() => { + const node = native(); + if (!node) return; + let current = true; + void node.status().then( + (state) => { + if (current && alive) { + status.set(state); + caretMoved(); + } + }, + (error) => { + if (current && alive) throw error; + }, + ); + return () => { + current = false; + }; + }); + effect(() => { + const node = native(); + const state = status(); + if (!node || !state) return; + untracked(() => { + props.ref?.({ + anchor: node.node, + focus: () => node.focus(), + blur: () => node.blur(), + composing: () => { + if (!alive) return false; + // Subscribe to transitions while reading the native state for action guards. + composition(); + return state.composing; + }, + replaceRange: (request) => node.replaceRange(request), + bindKey, + }); + props.onSelectionChange?.(state.selection); + }); + }); + effect(() => { + const node = native(); + if (node && status() && props.autoFocus) node.focus(); + }); + const caretMoved = () => { + if (frame !== undefined || !alive) return; + frame = requestAnimationFrame(async () => { + frame = undefined; + if (!alive) return; + const node = native(); + if (!node) return; + try { + const [measured, pageBounds] = await Promise.all([ + node.contentHeight(), + node.caretBounds(), + ]); + if (!alive || native() !== node) return; + if (measured !== null && measured > 0) height.set(measured); + if (pageBounds) props.onCaretMove?.({ bounds: pageBounds, pageBounds }); + } catch (error) { + if (alive) throw error; + } + }); + }; + const key = ({ key }: { key: string }) => { + if (status()?.composing) return; + const binding = Array.from(bindings.values()) + .reverse() + .find((binding) => binding.key === key); + if (binding) binding.action(); + else if (key === 'escape') props.onEscape?.(); + }; + return ( + + native.set(node)} + testID={props.testID} + accessibilityLabel={props.accessibilityLabel} + value={props.value} + sourceId={props.sourceId} + sourceOffset={props.sourceOffset ?? 0} + valueCount={valueCount()} + readOnly={props.readonly} + placeholder={props.placeholder} + tokenColor={C.accent} + tokenBackgroundColor={`${C.accent}20`} + tokenIcon={lucide.book[0].d} + captureKeys={captureKeys()} + styles={{ + root: { ...bodyTextStyle, color: C.text }, + content: { padding: 0 }, + caret: { backgroundColor: C.text }, + placeholder: { color: C.muted }, + nodes: { paragraph: bodyTextStyle }, + }} + style={{ flexGrow: 1, flexShrink: 1, minHeight: 0 }} + onChange={(event) => { + valueCount.set(event.count); + props.onChange?.({ value: event.value }); + caretMoved(); + }} + onSelectionChange={(event) => { + props.onSelectionChange?.(event); + caretMoved(); + }} + onCompositionChange={({ composing }) => composition.set(composing)} + onSubmit={props.onSubmit} + onKey={key} + onPasteFiles={(event) => + props.onPaste?.({ text: '', uris: event.uris, files: event.files }) + } + onFocus={props.onFocus} + onBlur={props.onBlur} + /> + + ); +} diff --git a/products/maka/workbench/app/SkillMessage.tsx b/products/maka/workbench/app/SkillMessage.tsx new file mode 100644 index 00000000000..2f247a99454 --- /dev/null +++ b/products/maka/workbench/app/SkillMessage.tsx @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { computed, Text, View } from 'nsh'; +import { SkillInput } from './SkillInput'; +import { SourceText } from './SourceText'; +import { hasSkillToken, skillTextGroups } from './skillTokenText'; +import { C, bodyTextStyle } from './ui'; +import type { Row } from './history'; + +/** Message selection uses the same atom model and source serializer as the composer. */ +export function SkillMessage(props: { row(): Row; sourceId: string }) { + const fragments = () => props.row().fragments ?? [{ offset: 0, text: props.row().text }]; + const groups = computed(() => skillTextGroups(fragments())); + const hasSkills = computed(() => groups().some(hasSkillToken)); + return ( + + + + } + > + + group.offset}> + {(group) => ( + + )} + + + + ); +} diff --git a/products/maka/workbench/app/TerminalInterrupt.tsx b/products/maka/workbench/app/TerminalInterrupt.tsx index 33a08a9af0d..a509b5450ff 100644 --- a/products/maka/workbench/app/TerminalInterrupt.tsx +++ b/products/maka/workbench/app/TerminalInterrupt.tsx @@ -103,6 +103,7 @@ export function createTerminalInterrupt(options: { if (timer !== undefined) clearTimeout(timer); }); return { + interrupt, prompt, confirmation, dismiss, diff --git a/products/maka/workbench/app/lucide.ts b/products/maka/workbench/app/lucide.ts index 3c99db03bc7..68bbd00dea2 100644 --- a/products/maka/workbench/app/lucide.ts +++ b/products/maka/workbench/app/lucide.ts @@ -22,6 +22,12 @@ export const lucide = { { tag: 'path', d: 'm5 12 7-7 7 7' }, { tag: 'path', d: 'M12 19V5' }, ], + book: [ + { + tag: 'path', + d: 'M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20', + }, + ], brain: [ { tag: 'path', d: 'M12 18V5' }, { tag: 'path', d: 'M15 13a4.17 4.17 0 01-3-4 4.17 4.17 0 01-3 4' }, diff --git a/products/maka/workbench/app/skillTokenText.ts b/products/maka/workbench/app/skillTokenText.ts new file mode 100644 index 00000000000..c2b47d3ddd2 --- /dev/null +++ b/products/maka/workbench/app/skillTokenText.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { messageBytes } from './history.ts'; + +// Keep the whitespace boundary identical to the host's skill invocation grammar. +export const SKILL_TOKEN_PATTERN = String.raw`(?:^|[\t-\r \u{00a0}\u{1680}\u{2000}-\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}])(?P/skill:(?P