diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 784cd25c..a1eec2fa 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -10,6 +10,7 @@ import type { ProviderModelsResult, SavedProvider } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' import type { AppMode, ChatSendBehavior, PermissionMode, ThemeMode, UpdateProxySettings } from '../types/settings' import { browserHost } from '../lib/desktopHost/browserHost' +import { settingsApi } from '../api/settings' const MOCK_DELETE_PROVIDER = vi.fn() const MOCK_GET_SETTINGS = vi.fn() @@ -238,6 +239,7 @@ describe('Settings > General tab', () => { permissionMode: 'default', autoModeOptInAccepted: false, thinkingEnabled: true, + workflowKeywordTriggerEnabled: true, autoDreamEnabled: false, skipWebFetchPreflight: true, desktopNotificationsEnabled: true, @@ -897,6 +899,33 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false) }) + it('lets the user disable and restore the Ultracode keyword trigger', async () => { + const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true }) + + try { + render() + + fireEvent.click(screen.getByText('General')) + + const toggle = screen.getByRole('switch', { name: 'Enable Ultracode keyword trigger' }) + expect(toggle).toBeChecked() + + await act(async () => { + fireEvent.click(toggle) + }) + expect(toggle).not.toBeChecked() + expect(updateUser).toHaveBeenLastCalledWith({ workflowKeywordTriggerEnabled: false }) + + await act(async () => { + fireEvent.click(toggle) + }) + expect(toggle).toBeChecked() + expect(updateUser).toHaveBeenLastCalledWith({ workflowKeywordTriggerEnabled: true }) + } finally { + updateUser.mockRestore() + } + }) + it('lets the user choose a default permission mode for new sessions', async () => { render() diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 80d6b39a..7b5c04b3 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -110,6 +110,7 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { browserHost } from '../../lib/desktopHost/browserHost' +import { settingsApi } from '../../api/settings' /** * Opens the run-location pill's menu. Directory, branch and worktree all live @@ -2347,4 +2348,65 @@ describe('ChatInput file mentions', () => { attachments: [], }) }) + + it('highlights only actionable ultracode tokens and follows the persisted setting in both directions', async () => { + const updateUser = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true }) + + try { + render() + + const highlightedKeywords = () => + Array.from(document.querySelectorAll('[data-workflow-keyword="true"]')) + .map((element) => element.textContent) + + const complexPrompt = 'ultracode:audit routes, error propagation, and missing tests with several agents' + setComposerText(complexPrompt, complexPrompt.length) + await waitFor(() => { + expect(highlightedKeywords()).toEqual(['ultracode']) + }) + + for (const literalPrompt of [ + '```text\nultracode\n```', + 'explain "ultracode" without running it', + 'open docs/ultracode/readme.md', + 'pass --ultracode to the CLI', + 'inspect the ultracode-runner package', + ]) { + setComposerText(literalPrompt, literalPrompt.length) + await waitFor(() => { + expect(highlightedKeywords()).toEqual([]) + }) + } + + setComposerText(complexPrompt, complexPrompt.length) + await waitFor(() => { + expect(highlightedKeywords()).toEqual(['ultracode']) + }) + + await act(async () => { + await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(false) + }) + await waitFor(() => { + expect(highlightedKeywords()).toEqual([]) + }) + + await act(async () => { + await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(true) + }) + await waitFor(() => { + expect(highlightedKeywords()).toEqual(['ultracode']) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Run' })) + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: complexPrompt, + attachments: [], + }) + expect(updateUser).toHaveBeenNthCalledWith(1, { workflowKeywordTriggerEnabled: false }) + expect(updateUser).toHaveBeenNthCalledWith(2, { workflowKeywordTriggerEnabled: true }) + } finally { + updateUser.mockRestore() + } + }) }) diff --git a/desktop/src/components/chat/MentionComposer.tsx b/desktop/src/components/chat/MentionComposer.tsx index 1a49c948..145a8d37 100644 --- a/desktop/src/components/chat/MentionComposer.tsx +++ b/desktop/src/components/chat/MentionComposer.tsx @@ -16,11 +16,13 @@ * textarea handlers calling preventDefault). */ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import type { Node as PMNode } from 'prosemirror-model' import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state' -import { EditorView } from 'prosemirror-view' +import { Decoration, DecorationSet, EditorView } from 'prosemirror-view' import { keymap } from 'prosemirror-keymap' import { history, redo, undo } from 'prosemirror-history' import { baseKeymap, splitBlock } from 'prosemirror-commands' +import { findKeywordRanges } from '../../../../src/utils/workflows/keyword' import { buildComposerDoc, composerSchema, @@ -33,6 +35,7 @@ import { } from './composerEditor' import type { ComposerMention } from '../../lib/composerMentions' import { mentionsEqual } from '../../lib/composerMentions' +import { useSettingsStore } from '../../stores/settingsStore' export type MentionComposerHandle = { focus: () => void @@ -72,6 +75,7 @@ export type MentionComposerProps = { * editable element (`[data-composer-editor]`). */ const composerViewRegistry = new WeakMap() +const workflowKeywordPluginKey = new PluginKey('workflow-keyword-highlight') export function getComposerViewForTesting(element: HTMLElement | null): EditorView | undefined { return element ? composerViewRegistry.get(element) : undefined @@ -91,6 +95,23 @@ function syncEmptyState(view: EditorView, placeholder?: string) { } } +function workflowKeywordDecorations(doc: PMNode, enabled: boolean): DecorationSet { + if (!enabled) return DecorationSet.empty + + const text = projectComposerDoc(doc).text + const decorations = findKeywordRanges(text).map((range) => + Decoration.inline( + textOffsetToPmPos(doc, range.start), + textOffsetToPmPos(doc, range.end), + { + class: 'composer-workflow-keyword', + 'data-workflow-keyword': 'true', + }, + ), + ) + return DecorationSet.create(doc, decorations) +} + export const MentionComposer = forwardRef( function MentionComposer(props, ref) { const { @@ -107,6 +128,11 @@ export const MentionComposer = forwardRef(null) const viewRef = useRef(null) + const workflowKeywordTriggerEnabled = useSettingsStore( + (state) => state.workflowKeywordTriggerEnabled, + ) + const workflowKeywordTriggerEnabledRef = useRef(workflowKeywordTriggerEnabled) + workflowKeywordTriggerEnabledRef.current = workflowKeywordTriggerEnabled const propsRef = useRef(props) propsRef.current = props const lastProjectedRef = useRef({ text: value, mentions }) @@ -134,6 +160,15 @@ export const MentionComposer = forwardRef workflowKeywordDecorations( + state.doc, + workflowKeywordTriggerEnabledRef.current, + ), + }, + }), history(), // Whole-pill deletion, ahead of baseKeymap's structural commands. keymap({ @@ -203,6 +238,18 @@ export const MentionComposer = forwardRef { + const view = viewRef.current + if (!view) return + view.dispatch(view.state.tr.setMeta( + workflowKeywordPluginKey, + workflowKeywordTriggerEnabled, + )) + }, [workflowKeywordTriggerEnabled]) + // Dynamic editor chrome: classes and aria attributes are not part of the // document, so they are applied straight to the editable element. useEffect(() => { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index fcfab617..9d133733 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1479,6 +1479,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.', 'settings.general.thinkingEnabled': 'Enable thinking mode', 'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.', + 'settings.general.workflowKeywordTitle': 'Ultracode', + 'settings.general.workflowKeywordDescription': 'Controls whether a standalone Ultracode keyword opts the prompt into dynamic Workflow orchestration.', + 'settings.general.workflowKeywordEnabled': 'Enable Ultracode keyword trigger', + 'settings.general.workflowKeywordHint': 'Actionable keywords are highlighted before send; code blocks, quotes, paths, flags, and hyphenated literals stay literal.', 'settings.general.autoDreamTitle': 'Auto-dream', 'settings.general.autoDreamDescription': 'Periodically consolidates auto-memory in the background after enough sessions have changed.', 'settings.general.autoDreamEnabled': 'Enable Auto-dream', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 5718c2cd..53a649ad 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1481,6 +1481,10 @@ export const jp: Record = { 'settings.general.thinkingDescription': '新しいセッションをモデルの思考を有効にして開始するかどうかを制御します。オフの場合、DeepSeek などの対応プロバイダーには明示的に非思考パラメーターが渡されます。', 'settings.general.thinkingEnabled': '思考モードを有効にする', 'settings.general.thinkingHint': '新しいセッションを --thinking 無効で開始するにはこれをオフにします。DeepSeek V4 Flash/Pro やその他の非思考ワークフローに便利です。', + 'settings.general.workflowKeywordTitle': 'Ultracode', + 'settings.general.workflowKeywordDescription': '単独の Ultracode キーワードでプロンプトを動的な Workflow オーケストレーションに切り替えるかを制御します。', + 'settings.general.workflowKeywordEnabled': 'Ultracode キーワードトリガーを有効にする', + 'settings.general.workflowKeywordHint': '有効なキーワードは送信前に強調表示され、コードブロック、引用、パス、flag、ハイフン付きリテラルはそのまま扱われます。', 'settings.general.autoDreamTitle': 'Auto-dream', 'settings.general.autoDreamDescription': '十分なセッションが変更された後、バックグラウンドで auto-memory を定期的に統合します。', 'settings.general.autoDreamEnabled': 'Auto-dream を有効にする', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index dc9eae63..e8cb8c44 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1481,6 +1481,10 @@ export const kr: Record = { 'settings.general.thinkingDescription': '새 세션을 모델 사고를 사용으로 시작할지 제어합니다. 꺼져 있으면 DeepSeek 같은 호환 공급자에 명시적인 비사고 매개변수가 전달됩니다.', 'settings.general.thinkingEnabled': '사고 모드 사용', 'settings.general.thinkingHint': '새 세션을 --thinking 비활성화로 시작하려면 이를 끄세요. DeepSeek V4 Flash/Pro 및 기타 비사고 워크플로에 유용합니다.', + 'settings.general.workflowKeywordTitle': 'Ultracode', + 'settings.general.workflowKeywordDescription': '독립된 Ultracode 키워드가 프롬프트를 동적 Workflow 오케스트레이션으로 전환할지 제어합니다.', + 'settings.general.workflowKeywordEnabled': 'Ultracode 키워드 트리거 사용', + 'settings.general.workflowKeywordHint': '실행 가능한 키워드는 전송 전에 강조되며 코드 블록, 인용문, 경로, flag 및 하이픈 리터럴은 그대로 유지됩니다.', 'settings.general.autoDreamTitle': 'Auto-dream', 'settings.general.autoDreamDescription': '충분한 세션이 변경된 후 백그라운드에서 auto-memory를 주기적으로 통합합니다.', 'settings.general.autoDreamEnabled': 'Auto-dream 사용', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 925a74c1..bfa722db 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1480,6 +1480,10 @@ export const zh: Record = { 'settings.general.thinkingDescription': '控制新會話是否啟用模型思考。關閉後,DeepSeek 等相容供應商會收到顯式非思考模式引數。', 'settings.general.thinkingEnabled': '啟用思考模式', 'settings.general.thinkingHint': '關閉後會以 --thinking disabled 啟動新會話;適合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', + 'settings.general.workflowKeywordTitle': 'Ultracode', + 'settings.general.workflowKeywordDescription': '控制獨立出現的 Ultracode 關鍵詞是否讓目前提示詞進入動態 Workflow 編排。', + 'settings.general.workflowKeywordEnabled': '啟用 Ultracode 關鍵詞觸發', + 'settings.general.workflowKeywordHint': '可觸發的關鍵詞會在傳送前醒目顯示;程式碼區塊、引號、路徑、flag 和連字號字面量維持原義。', 'settings.general.autoDreamTitle': '自動做夢', 'settings.general.autoDreamDescription': '在累積足夠會話後,背景定期整理和壓縮 auto-memory。', 'settings.general.autoDreamEnabled': '啟用自動做夢', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1ea8cc8b..80e1b7ff 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1480,6 +1480,10 @@ export const zh: Record = { 'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后,DeepSeek 等兼容供应商会收到显式非思考模式参数。', 'settings.general.thinkingEnabled': '启用思考模式', 'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', + 'settings.general.workflowKeywordTitle': 'Ultracode', + 'settings.general.workflowKeywordDescription': '控制独立出现的 Ultracode 关键词是否让当前提示词进入动态 Workflow 编排。', + 'settings.general.workflowKeywordEnabled': '启用 Ultracode 关键词触发', + 'settings.general.workflowKeywordHint': '可触发的关键词会在发送前高亮;代码块、引号、路径、flag 和连字符字面量保持原义。', 'settings.general.autoDreamTitle': '自动做梦', 'settings.general.autoDreamDescription': '在积累足够会话后,后台定期整理和压缩 auto-memory。', 'settings.general.autoDreamEnabled': '启用自动做梦', diff --git a/desktop/src/pages/settings/GeneralSettings.tsx b/desktop/src/pages/settings/GeneralSettings.tsx index be565abc..9bbb91f1 100644 --- a/desktop/src/pages/settings/GeneralSettings.tsx +++ b/desktop/src/pages/settings/GeneralSettings.tsx @@ -52,6 +52,8 @@ export function GeneralSettings() { const { thinkingEnabled, setThinkingEnabled, + workflowKeywordTriggerEnabled, + setWorkflowKeywordTriggerEnabled, permissionMode, setPermissionMode, autoDreamEnabled, @@ -833,7 +835,22 @@ export function GeneralSettings() { -
+ +
+ void setWorkflowKeywordTriggerEnabled(enabled)} + label={t('settings.general.workflowKeywordEnabled')} + description={t('settings.general.workflowKeywordHint')} + /> +
+
+ +

{t('settings.general.autoDreamTitle')}

{t('settings.general.autoDreamDescription')}