fix(desktop): add Ultracode keyword controls

This commit is contained in:
程序员阿江(Relakkes)
2026-08-10 17:31:21 +08:00
parent 38de706963
commit eee09fe3f4
14 changed files with 307 additions and 2 deletions
@@ -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(<Settings />)
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(<Settings />)
@@ -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(<ChatInput />)
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()
}
})
})
@@ -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<HTMLElement, EditorView>()
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<MentionComposerHandle, MentionComposerProps>(
function MentionComposer(props, ref) {
const {
@@ -107,6 +128,11 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
const containerRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(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<MentionComposerHandle, MentionComposer
},
},
}),
new Plugin({
key: workflowKeywordPluginKey,
props: {
decorations: (state) => workflowKeywordDecorations(
state.doc,
workflowKeywordTriggerEnabledRef.current,
),
},
}),
history(),
// Whole-pill deletion, ahead of baseKeymap's structural commands.
keymap({
@@ -203,6 +238,18 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
syncEmptyState(view, propsRef.current.placeholder)
}, [value, mentions])
// The setting can change while the current draft stays untouched. An
// empty transaction makes ProseMirror recompute decorations immediately,
// so the visible opt-in warning and the persisted runtime gate stay joined.
useEffect(() => {
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(() => {
+4
View File
@@ -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',
+4
View File
@@ -1481,6 +1481,10 @@ export const jp: Record<TranslationKey, string> = {
'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 を有効にする',
+4
View File
@@ -1481,6 +1481,10 @@ export const kr: Record<TranslationKey, string> = {
'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 사용',
+4
View File
@@ -1480,6 +1480,10 @@ export const zh: Record<TranslationKey, string> = {
'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': '啟用自動做夢',
+4
View File
@@ -1480,6 +1480,10 @@ export const zh: Record<TranslationKey, string> = {
'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': '启用自动做梦',
+18 -1
View File
@@ -52,6 +52,8 @@ export function GeneralSettings() {
const {
thinkingEnabled,
setThinkingEnabled,
workflowKeywordTriggerEnabled,
setWorkflowKeywordTriggerEnabled,
permissionMode,
setPermissionMode,
autoDreamEnabled,
@@ -833,7 +835,22 @@ export function GeneralSettings() {
</label>
</div>
<div className="mt-8">
<SettingsSection
className="mt-8"
title={t('settings.general.workflowKeywordTitle')}
description={t('settings.general.workflowKeywordDescription')}
>
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<Switch
checked={workflowKeywordTriggerEnabled}
onChange={(enabled) => void setWorkflowKeywordTriggerEnabled(enabled)}
label={t('settings.general.workflowKeywordEnabled')}
description={t('settings.general.workflowKeywordHint')}
/>
</div>
</SettingsSection>
<div>
<h2 className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)] mb-1" style={{ fontFamily: 'var(--font-headline)' }}>{t('settings.general.autoDreamTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.autoDreamDescription')}</p>
<label className="relative flex items-start gap-3 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
+76
View File
@@ -1005,6 +1005,82 @@ describe('settingsStore thinking persistence', () => {
})
})
describe('settingsStore workflow keyword persistence', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
window.localStorage.clear()
})
it('keeps the Ultracode trigger enabled for an old settings file with no field', async () => {
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn().mockResolvedValue({}),
updateUser: vi.fn(),
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn().mockResolvedValue({ models: [] }),
getCurrent: vi.fn().mockResolvedValue({ model: null }),
setCurrent: vi.fn(),
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
setEffort: vi.fn(),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn().mockResolvedValue({
settings: {
enabled: false,
token: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
fixedPort: null,
disconnectGraceSeconds: null,
},
}),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAll()
expect(useSettingsStore.getState().workflowKeywordTriggerEnabled).toBe(true)
})
it('persists both disabling and restoring the Ultracode trigger', async () => {
const updateUser = vi.fn().mockResolvedValue({})
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn(),
updateUser,
getPermissionMode: vi.fn(),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(false)
await useSettingsStore.getState().setWorkflowKeywordTriggerEnabled(true)
expect(updateUser).toHaveBeenNthCalledWith(1, { workflowKeywordTriggerEnabled: false })
expect(updateUser).toHaveBeenNthCalledWith(2, { workflowKeywordTriggerEnabled: true })
expect(useSettingsStore.getState().workflowKeywordTriggerEnabled).toBe(true)
})
})
describe('settingsStore Auto-dream persistence', () => {
beforeEach(() => {
vi.resetModules()
+15
View File
@@ -54,6 +54,7 @@ type SettingsStore = {
currentModel: ModelInfo | null
effortLevel: EffortLevel
thinkingEnabled: boolean
workflowKeywordTriggerEnabled: boolean
autoDreamEnabled: boolean
autoModeOptInAccepted: boolean
availableModels: ModelInfo[]
@@ -94,6 +95,7 @@ type SettingsStore = {
setModel: (modelId: string) => Promise<void>
setEffort: (level: EffortLevel) => Promise<void>
setThinkingEnabled: (enabled: boolean) => Promise<void>
setWorkflowKeywordTriggerEnabled: (enabled: boolean) => Promise<void>
setAutoDreamEnabled: (enabled: boolean) => Promise<void>
acceptAutoModeOptIn: () => Promise<void>
setLocale: (locale: Locale) => void
@@ -181,6 +183,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
currentModel: null,
effortLevel: 'max',
thinkingEnabled: true,
workflowKeywordTriggerEnabled: true,
autoDreamEnabled: false,
autoModeOptInAccepted: false,
availableModels: [],
@@ -255,6 +258,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
currentModel: model,
effortLevel: level,
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
workflowKeywordTriggerEnabled: userSettings.workflowKeywordTriggerEnabled !== false,
autoDreamEnabled: userSettings.autoDreamEnabled === true,
autoModeOptInAccepted: userSettings.skipAutoPermissionPrompt === true,
chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior),
@@ -326,6 +330,17 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}
},
setWorkflowKeywordTriggerEnabled: async (enabled) => {
const prev = get().workflowKeywordTriggerEnabled
set({ workflowKeywordTriggerEnabled: enabled })
try {
await settingsApi.updateUser({ workflowKeywordTriggerEnabled: enabled })
} catch (error) {
set({ workflowKeywordTriggerEnabled: prev })
throw error
}
},
setAutoDreamEnabled: async (enabled) => {
const prev = get().autoDreamEnabled
set({ autoDreamEnabled: enabled })
+12
View File
@@ -1629,6 +1629,18 @@ button, input, textarea, select, a, [role="button"] {
overflow-wrap: break-word;
}
/* The same conservative matcher that injects the runtime Workflow reminder
marks actionable Ultracode tokens before submit. */
.composer-workflow-keyword {
color: var(--color-on-brand-soft);
background: var(--color-brand-soft);
border-radius: var(--radius-sm);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
padding: 0 2px;
font-weight: 600;
}
/* Floated zero-height placeholder: the empty paragraph keeps its own caret,
the hint text just borrows its line. */
.composer-pm[data-empty="true"]::before {
+1
View File
@@ -152,6 +152,7 @@ export type UserSettings = {
modelContext?: string
effort?: EffortLevel
alwaysThinkingEnabled?: boolean
workflowKeywordTriggerEnabled?: boolean
autoDreamEnabled?: boolean
skipAutoPermissionPrompt?: boolean
permissionMode?: PermissionMode
@@ -78,6 +78,21 @@ describe('workflow keyword attachment', () => {
).toEqual([])
})
test('restoring the setting lets a complex typed prompt opt in again', async () => {
const prompt =
'ultracode:audit route registration, error propagation, and missing tests with several agents'
await writeSettings({ workflowKeywordTriggerEnabled: false })
expect(
getAttachmentsForTesting.workflowKeyword(prompt, { suppressed: false }),
).toEqual([])
await writeSettings({ workflowKeywordTriggerEnabled: true })
expect(
getAttachmentsForTesting.workflowKeyword(prompt, { suppressed: false }),
).toEqual([{ type: 'workflow_keyword_request' }])
})
test('a quoted mention of the word is not an opt-in', () => {
expect(
getAttachmentsForTesting.workflowKeyword(
@@ -86,6 +101,17 @@ describe('workflow keyword attachment', () => {
),
).toEqual([])
})
test.each([
['a fenced code block', '```text\nultracode\n```'],
['a path', 'open docs/ultracode/readme.md'],
['a flag', 'pass --ultracode to the CLI'],
['a hyphenated literal', 'inspect the ultracode-runner package'],
])('does not opt in for %s', (_label, prompt) => {
expect(
getAttachmentsForTesting.workflowKeyword(prompt, { suppressed: false }),
).toEqual([])
})
})
describe('ultracode effort attachments', () => {