From b5e02cb88ae2d8491c3c14af71266f014c52b17b Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 6 Nov 2026 14:44:16 +0800 Subject: [PATCH 1/2] fix(composer): render skills as atomic inline book tags Use the existing RichEditor for inline skill atoms in drafts and sent messages, preserving canonical source text and whole-node editing. Keep completion, clipboard, IME and application shortcuts integrated. Fix reproduced NSH focus, composition and shortcut routing faults. Generated-by: Codex --- packages/plugin-sdk/src/composer-view.ts | 1 + plugins/agent/composer/ui/Composer.tsx | 90 ++- .../agent/composer/ui/ComposerCompletion.tsx | 44 +- plugins/agent/composer/ui/composerPaste.ts | 18 +- .../agent/conversations/ui/SessionSurface.tsx | 36 +- .../tests/real_host/desktop_new_session.rs | 3 +- products/maka/workbench/app/ComposerEditor.ts | 46 ++ products/maka/workbench/app/Icon.tsx | 1 + products/maka/workbench/app/MessageRow.tsx | 5 +- products/maka/workbench/app/SkillInput.tsx | 228 +++++++ products/maka/workbench/app/SkillMessage.tsx | 59 ++ products/maka/workbench/app/lucide.ts | 6 + products/maka/workbench/app/skillTokenText.ts | 46 ++ products/maka/workbench/examples/ui_types.rs | 1 + products/maka/workbench/scripts/lucide.py | 1 + products/maka/workbench/src/bindings.rs | 2 + products/maka/workbench/src/composer_input.rs | 628 ++++++++++++++++++ .../workbench/src/composer_input/message.rs | 217 ++++++ .../workbench/src/composer_input/model.rs | 502 ++++++++++++++ products/maka/workbench/src/lib.rs | 99 +++ .../workbench/tests/fixtures/skill-input.tsx | 90 +++ .../maka/workbench/tests/skill-token.test.mjs | 55 ++ scripts/ci/suites.mjs | 2 +- .../assets/composer-ui/index.tsx | 8 +- .../gpui/nsh-platform-gpui/src/text_input.rs | 52 +- .../src/rich_editor/view/controller.rs | 15 +- .../nsh-base/src/rich_editor/view/keys.rs | 19 +- .../nsh-base/src/rich_editor/view/render.rs | 6 +- .../nsh-base/tests/base/rich_editor/typing.rs | 92 +++ tools/ui-build/src/lib.rs | 8 +- 30 files changed, 2258 insertions(+), 122 deletions(-) create mode 100644 products/maka/workbench/app/ComposerEditor.ts create mode 100644 products/maka/workbench/app/SkillInput.tsx create mode 100644 products/maka/workbench/app/SkillMessage.tsx create mode 100644 products/maka/workbench/app/skillTokenText.ts create mode 100644 products/maka/workbench/src/composer_input.rs create mode 100644 products/maka/workbench/src/composer_input/message.rs create mode 100644 products/maka/workbench/src/composer_input/model.rs create mode 100644 products/maka/workbench/tests/fixtures/skill-input.tsx create mode 100644 products/maka/workbench/tests/skill-token.test.mjs diff --git a/packages/plugin-sdk/src/composer-view.ts b/packages/plugin-sdk/src/composer-view.ts index ce498f180bd..69875e85824 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): () => 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..0eb62c388eb 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,46 +639,42 @@ export function createComposerCompletion(options: Options) { if (!target) return; const valid = () => current === read && owns(read); const unbind = [ - keymap.bind( + target.bindKey( 'escape', () => { if (valid()) dismiss(); }, - { when: 'always', within: target }, + {}, ), ]; 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( + target.bindKey( 'enter', () => { if (!valid()) return; const item = rows()[index()]; if (item) accept(item.action); }, - { when: 'always', within: target }, + {}, ), ); } @@ -724,16 +724,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 +747,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 +784,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..dee54b4105b 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,14 +110,17 @@ 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( + // 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); }, - { when: 'always', within: node }, + {}, ), ); return () => unbind.forEach((remove) => remove()); diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx index df6c1caef46..d42e51fd227 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,28 @@ 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) => { 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 +2382,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/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index ea3a74a4f1c..5c93f32103b 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 @@ -2442,9 +2442,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/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}> ; + caretBounds(): Promise; + contentHeight(): Promise; + replaceRange: InputRef['replaceRange']; +}; +type Props = { + ref?(node: ComposerEditor): void; + testID?: string; + accessibilityLabel: string; + value: string; + readonly: boolean; + sourceId?: string; + sourceOffset?: number; + placeholder: string; + autoFocus: boolean; + style?: StyleProp; + 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 scope = signal(null); + const status = signal<{ readonly composing: boolean; readonly selection: Selection } | null>( + null, + ); + 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 = scope(); + // These keystrokes reach the native input instead of RichEditor's key table. + if (key === 'mod+v' || key === 'alt+v') + 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 anchor = scope(); + const state = status(); + if (!node || !anchor || !state) return; + untracked(() => { + props.ref?.({ + anchor, + focus: () => node.focus(), + blur: () => node.blur(), + composing: () => 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 ( + scope.set(node)} + style={[props.style, { height: height(), minHeight: bodyTextStyle.lineHeight }]} + onFileDrop={props.onFileDrop} + onFileDragEnter={props.onFileDragEnter} + onFileDragLeave={props.onFileDragLeave} + onLayout={caretMoved} + > + 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(); + }} + 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/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