From 7aec84676368db2a3cc9689f5666561d8bb8d263 Mon Sep 17 00:00:00 2001 From: Mohamed Bishr Date: Sun, 16 Aug 2026 03:38:54 +0300 Subject: [PATCH 01/13] feat(palette): add a global command palette to the workbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kun's GUI has grown many destinations — Code, Write, Design, Schedule, Workflow, Connect phone, Plugins, Extensions, settings sections, per-project conversations, skills, models, and extension views — but reaching any of them required already knowing which surface owns it. The only keyboard surface was a fixed registry of window-level commands, and the TUI already had a command surface the GUI lacked. The palette aggregates the existing registries rather than defining a new one, so a destination added to a shared union becomes reachable without a second registration. Every activation routes through existing store actions and desktop commands; no preload IPC or extension manifest surface is added. Opened with a new rebindable `command-palette` shortcut, registered last in the shared registry so first-match resolution lets any user-assigned chord win. The empty-query view browses the whole capability surface as labeled sections; typing ranks across sources with a pure tiered scorer. Conversation deep search adds one local runtime route backed by a new optional `SessionStore.searchItemText` capability. Search is a read-only side path: it never takes a thread's write queue and never schedules a history rewrite, so a keystroke cannot contend with an in-flight turn. Stores without the capability report no matches rather than falling back to the blocking item-load path. Destructive actions are deliberately absent. Deleting a conversation from a fuzzy-matched row is a trap a mistyped query can spring, and the sidebar already offers it behind an explicit confirmation. --- .../file/file-session-store.search.test.ts | 124 ++++ kun/src/adapters/file/file-session-store.ts | 109 ++++ .../adapters/hybrid/hybrid-session-store.ts | 8 + kun/src/manager/remote-data-stores.ts | 12 + kun/src/manager/service-manager-state.ts | 3 +- .../manager/shared-data-store-contracts.ts | 1 + .../shared-data-store-implementation.ts | 16 + kun/src/manager/shared-data-store.test.ts | 32 ++ kun/src/ports/session-store.ts | 17 + .../server/routes/register-thread-routes.ts | 6 + kun/src/server/routes/threads.test.ts | 181 +++++- kun/src/server/routes/threads.ts | 108 ++++ kun/src/services/thread-lifecycle-fence.ts | 6 + .../changes/add-gui-command-palette/design.md | 120 ++++ .../add-gui-command-palette/proposal.md | 40 ++ .../specs/gui-command-palette/spec.md | 334 +++++++++++ .../changes/add-gui-command-palette/tasks.md | 92 +++ src/renderer/src/agent/kun-runtime.ts | 38 ++ src/renderer/src/components/SettingsView.tsx | 2 + src/renderer/src/components/Workbench.tsx | 159 ++++- .../src/components/chat/FloatingComposer.tsx | 7 + .../src/components/chat/WorkbenchTopBar.tsx | 17 +- .../chat/floating-composer-commands.ts | 25 + .../chat/use-composer-slash-command-menu.tsx | 94 ++- .../workbench/WorkbenchChatStage.tsx | 3 + .../workbench/useWorkbenchChatStoreState.ts | 2 + .../useWorkbenchExtensionSurfaces.ts | 16 +- .../useWorkbenchKeyboardShortcuts.test.ts | 236 +++++++- .../useWorkbenchKeyboardShortcuts.ts | 161 ++++-- .../src/lib/native-dialog-activity.ts | 23 + src/renderer/src/locales/en/common.ts | 2 + .../locales/en/common/command-palette.json | 53 ++ .../en/settings/navigation-providers.json | 2 + src/renderer/src/locales/hi/common.ts | 2 + .../locales/hi/common/command-palette.json | 53 ++ .../hi/settings/navigation-providers.json | 2 + src/renderer/src/locales/ja/common.ts | 2 + .../locales/ja/common/command-palette.json | 53 ++ .../ja/settings/navigation-providers.json | 2 + src/renderer/src/locales/ko/common.ts | 2 + .../locales/ko/common/command-palette.json | 53 ++ .../ko/settings/navigation-providers.json | 2 + src/renderer/src/locales/ru/common.ts | 2 + .../locales/ru/common/command-palette.json | 53 ++ .../ru/settings/navigation-providers.json | 2 + src/renderer/src/locales/th/common.ts | 2 + .../locales/th/common/command-palette.json | 53 ++ .../th/settings/navigation-providers.json | 2 + src/renderer/src/locales/zh/common.ts | 2 + .../locales/zh/common/command-palette.json | 53 ++ .../zh/settings/navigation-providers.json | 2 + .../src/palette/CommandPaletteOverlay.test.ts | 302 ++++++++++ .../src/palette/CommandPaletteOverlay.tsx | 378 ++++++++++++ .../src/palette/palette-highlight.test.ts | 74 +++ src/renderer/src/palette/palette-highlight.ts | 77 +++ src/renderer/src/palette/palette-model.ts | 106 ++++ .../src/palette/palette-recents.test.ts | 187 ++++++ src/renderer/src/palette/palette-recents.ts | 192 +++++++ .../src/palette/palette-scorer.test.ts | 166 ++++++ src/renderer/src/palette/palette-scorer.ts | 151 +++++ .../src/palette/palette-sources.test.ts | 373 ++++++++++++ src/renderer/src/palette/palette-sources.ts | 543 ++++++++++++++++++ src/renderer/src/palette/palette-store.ts | 17 + .../useSettingsCommandPaletteShortcut.ts | 40 ++ .../useWorkbenchCommandPalette.test.ts | 506 ++++++++++++++++ .../src/palette/useWorkbenchCommandPalette.ts | 474 +++++++++++++++ ...chat-store-navigation-workspace-actions.ts | 5 +- src/shared/keyboard-shortcuts.ts | 10 + 68 files changed, 5919 insertions(+), 73 deletions(-) create mode 100644 kun/src/adapters/file/file-session-store.search.test.ts create mode 100644 openspec/changes/add-gui-command-palette/design.md create mode 100644 openspec/changes/add-gui-command-palette/proposal.md create mode 100644 openspec/changes/add-gui-command-palette/specs/gui-command-palette/spec.md create mode 100644 openspec/changes/add-gui-command-palette/tasks.md create mode 100644 src/renderer/src/lib/native-dialog-activity.ts create mode 100644 src/renderer/src/locales/en/common/command-palette.json create mode 100644 src/renderer/src/locales/hi/common/command-palette.json create mode 100644 src/renderer/src/locales/ja/common/command-palette.json create mode 100644 src/renderer/src/locales/ko/common/command-palette.json create mode 100644 src/renderer/src/locales/ru/common/command-palette.json create mode 100644 src/renderer/src/locales/th/common/command-palette.json create mode 100644 src/renderer/src/locales/zh/common/command-palette.json create mode 100644 src/renderer/src/palette/CommandPaletteOverlay.test.ts create mode 100644 src/renderer/src/palette/CommandPaletteOverlay.tsx create mode 100644 src/renderer/src/palette/palette-highlight.test.ts create mode 100644 src/renderer/src/palette/palette-highlight.ts create mode 100644 src/renderer/src/palette/palette-model.ts create mode 100644 src/renderer/src/palette/palette-recents.test.ts create mode 100644 src/renderer/src/palette/palette-recents.ts create mode 100644 src/renderer/src/palette/palette-scorer.test.ts create mode 100644 src/renderer/src/palette/palette-scorer.ts create mode 100644 src/renderer/src/palette/palette-sources.test.ts create mode 100644 src/renderer/src/palette/palette-sources.ts create mode 100644 src/renderer/src/palette/palette-store.ts create mode 100644 src/renderer/src/palette/useSettingsCommandPaletteShortcut.ts create mode 100644 src/renderer/src/palette/useWorkbenchCommandPalette.test.ts create mode 100644 src/renderer/src/palette/useWorkbenchCommandPalette.ts diff --git a/kun/src/adapters/file/file-session-store.search.test.ts b/kun/src/adapters/file/file-session-store.search.test.ts new file mode 100644 index 000000000..e4fbe4764 --- /dev/null +++ b/kun/src/adapters/file/file-session-store.search.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { makeAssistantTextItem, makeToolResultItem, makeUserItem } from '../../domain/item.js' +import { FileSessionStore } from './file-session-store.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function newStore(options: { itemHistoryCompactionMinBytes?: number } = {}): Promise<{ + store: FileSessionStore + root: string + messagesPath: (threadId: string) => string +}> { + const root = await mkdtemp(join(tmpdir(), 'kun-session-search-')) + roots.push(root) + return { + store: new FileSessionStore({ dataDir: root, ...options }), + root, + messagesPath: (threadId) => join(root, 'threads', threadId, 'messages.jsonl') + } +} + +describe('FileSessionStore.searchItemText', () => { + it('finds user and assistant text and returns the matching item text', async () => { + const { store } = await newStore() + const threadId = 'thread_search' + await store.appendItem(threadId, makeUserItem({ + id: 'i1', turnId: 't1', threadId, text: 'Please rework the checkout flow.' + })) + await store.appendItem(threadId, makeAssistantTextItem({ + id: 'i2', turnId: 't1', threadId, text: 'Rewriting the billing module now.' + })) + + await expect(store.searchItemText(threadId, 'checkout')) + .resolves.toBe('Please rework the checkout flow.') + await expect(store.searchItemText(threadId, 'BILLING')) + .resolves.toBe('Rewriting the billing module now.') + await expect(store.searchItemText(threadId, 'absent')).resolves.toBeNull() + }) + + it('ignores tool payloads so search never surfaces raw tool output', async () => { + const { store } = await newStore() + const threadId = 'thread_tools' + await store.appendItem(threadId, makeToolResultItem({ + id: 'i1', turnId: 't1', threadId, callId: 'c1', toolName: 'read', output: { text: 'secret-token' } + })) + await expect(store.searchItemText(threadId, 'secret-token')).resolves.toBeNull() + }) + + it('does not match on record metadata that only looks like a hit', async () => { + const { store } = await newStore() + const threadId = 'thread_meta' + await store.appendItem(threadId, makeUserItem({ + id: 'i1', turnId: 't1', threadId, text: 'unrelated body' + })) + // 'assistant_text' and the thread id appear in the raw JSON of every + // record; only real item text may produce a match. + await expect(store.searchItemText(threadId, 'user_message')).resolves.toBeNull() + await expect(store.searchItemText(threadId, 'thread_meta')).resolves.toBeNull() + }) + + it('never schedules a rewrite the way loadItems does', async () => { + // A one-byte threshold makes every log "oversized". + const { store, messagesPath } = await newStore({ itemHistoryCompactionMinBytes: 1 }) + const threadId = 'thread_big' + await store.appendItem(threadId, makeUserItem({ + id: 'i1', turnId: 't1', threadId, text: 'first checkout note' + })) + await store.appendItem(threadId, makeUserItem({ + id: 'i1', turnId: 't1', threadId, text: 'second checkout note' + })) + await store.resetMemory() + const path = messagesPath(threadId) + const before = (await stat(path)).size + + // Searching leaves the log untouched, and queues no deferred rewrite. + await expect(store.searchItemText(threadId, 'checkout')).resolves.toContain('checkout') + await store.flushScheduledCompaction(threadId) + expect((await stat(path)).size).toBe(before) + + // The blocking path schedules the rewrite; this documents the contrast. + await store.loadItems(threadId) + await store.flushScheduledCompaction(threadId) + expect((await stat(path)).size).toBeLessThan(before) + }) + + it('reads the tail when a log exceeds the scan window', async () => { + const { store } = await newStore() + const threadId = 'thread_tail' + const filler = 'x'.repeat(4_000) + await store.appendItem(threadId, makeUserItem({ + id: 'oldest', turnId: 't0', threadId, text: 'oldest-marker ' + filler + })) + for (let index = 0; index < 40; index += 1) { + await store.appendItem(threadId, makeUserItem({ + id: 'mid_' + index, turnId: 't1', threadId, text: 'filler ' + filler + })) + } + await store.appendItem(threadId, makeUserItem({ + id: 'recent', turnId: 't2', threadId, text: 'recent-marker at the end' + })) + await store.resetMemory() + + await expect(store.searchItemText(threadId, 'recent-marker', { maxBytes: 8_000 })) + .resolves.toBe('recent-marker at the end') + // Content older than the tail window is outside the bound by design. + await expect(store.searchItemText(threadId, 'oldest-marker', { maxBytes: 8_000 })) + .resolves.toBeNull() + // Widening the window brings it back into range. + await expect(store.searchItemText(threadId, 'oldest-marker', { maxBytes: 4_000_000 })) + .resolves.toContain('oldest-marker') + }) + + it('returns null for unsafe thread ids and empty queries', async () => { + const { store } = await newStore() + await expect(store.searchItemText('../escape', 'anything')).resolves.toBeNull() + await expect(store.searchItemText('thread_ok', '')).resolves.toBeNull() + }) +}) diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts index 0d62d35af..11824715e 100644 --- a/kun/src/adapters/file/file-session-store.ts +++ b/kun/src/adapters/file/file-session-store.ts @@ -44,6 +44,12 @@ const SLOW_LOAD_ITEMS_LOG_MS = 1_000 const ITEMS_CACHE_MAX_THREADS = 4 const DEFAULT_ITEMS_CACHE_MAX_BYTES = 16 * 1024 * 1024 const DEFAULT_ITEM_HISTORY_COMPACTION_MIN_BYTES = 4 * 1024 * 1024 +/** + * Tail window a lock-free content search reads per thread. Kept well under + * the compaction threshold so search stays cheap on logs large enough that + * `loadItems` would rewrite them. + */ +const DEFAULT_ITEM_TEXT_SEARCH_MAX_BYTES = 512 * 1024 const HIGHEST_SEQ_CACHE_MAX_THREADS = 256 const ITEM_HISTORY_REVISION_MAX_THREADS = 512 // A valid model tool argument may contain 1 MiB of JSON, whose escaping can @@ -340,6 +346,87 @@ export class FileSessionStore implements SessionStore { return this.withThreadWrite(threadId, () => this.loadItemsUnlocked(threadId)) } + /** + * Bounded, lock-free scan for the first item text containing `query`. + * + * Deliberately does not reuse `loadItems`: that path takes the per-thread + * write queue and schedules a rewrite for logs at or above the compaction + * threshold, so driving it from a search would let a keystroke contend with + * an in-flight turn and queue a multi-megabyte rewrite (#621). This reads + * the tail of messages.jsonl directly, biasing toward recent messages, and + * neither mutates nor schedules anything. + */ + async searchItemText( + threadId: string, + query: string, + options: { maxBytes?: number } = {} + ): Promise { + if (!isSafeThreadId(threadId)) return null + const needle = query.toLowerCase() + if (!needle) return null + + const cached = this.itemsCache.get(threadId) + if (cached) return firstMatchingItemText(cached, needle) + + const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_ITEM_TEXT_SEARCH_MAX_BYTES)) + const path = this.messagesPath(threadId) + const info = await stat(path).catch(() => null) + if (!info || info.size === 0) return null + // Reading the tail keeps the work bounded on huge logs while covering the + // most recent conversation, which is what a palette query is looking for. + const start = Math.max(0, info.size - maxBytes) + + return new Promise((resolvePromise) => { + const stream = createReadStream(path, { encoding: 'utf-8', start }) + let remainder = '' + // A non-zero start almost certainly lands mid-record; drop that partial + // first line rather than reporting a truncated snippet. + let skipPartialLine = start > 0 + let settled = false + + const finish = (value: string | null): void => { + if (settled) return + settled = true + stream.destroy() + resolvePromise(value) + } + + const acceptLine = (line: string): string | null => { + if (skipPartialLine) { + skipPartialLine = false + return null + } + // Cheap pre-filter: only parse records whose raw JSON could match. + if (!line || !line.toLowerCase().includes(needle)) return null + let item: TurnItem + try { + item = JSON.parse(line) as TurnItem + } catch { + return null + } + const text = searchableItemText(item) + // The raw hit may have been a field name or id rather than content. + return text && text.toLowerCase().includes(needle) ? text : null + } + + stream.on('data', (chunk: string | Buffer) => { + remainder += typeof chunk === 'string' ? chunk : chunk.toString('utf-8') + let newline = remainder.indexOf('\n') + while (newline >= 0) { + const match = acceptLine(remainder.slice(0, newline).trim()) + remainder = remainder.slice(newline + 1) + if (match !== null) { + finish(match) + return + } + newline = remainder.indexOf('\n') + } + }) + stream.on('error', () => finish(null)) + stream.on('close', () => finish(acceptLine(remainder.trim()))) + }) + } + async loadItemPage( threadId: string, options: ItemHistoryPageOptions @@ -639,3 +726,25 @@ export class FileSessionStore implements SessionStore { } } } + +/** + * Item kinds whose text a content search may read. Tool calls, results, and + * internal bookkeeping stay out so a search never surfaces raw tool payloads. + */ +function searchableItemText(item: TurnItem): string | null { + switch (item.kind) { + case 'user_message': + case 'assistant_text': + return item.text + default: + return null + } +} + +function firstMatchingItemText(items: readonly TurnItem[], lowerCaseNeedle: string): string | null { + for (const item of items) { + const text = searchableItemText(item) + if (text && text.toLowerCase().includes(lowerCaseNeedle)) return text + } + return null +} diff --git a/kun/src/adapters/hybrid/hybrid-session-store.ts b/kun/src/adapters/hybrid/hybrid-session-store.ts index 743fc0ab7..e511cb5de 100644 --- a/kun/src/adapters/hybrid/hybrid-session-store.ts +++ b/kun/src/adapters/hybrid/hybrid-session-store.ts @@ -106,6 +106,14 @@ export class HybridSessionStore implements SessionStore { return this.delegate.loadItemPage(threadId, options) } + async searchItemText( + threadId: string, + query: string, + options?: { maxBytes?: number } + ): Promise { + return this.delegate.searchItemText?.(threadId, query, options) ?? null + } + async loadSession(threadId: string): Promise { return this.delegate.loadSession(threadId) } diff --git a/kun/src/manager/remote-data-stores.ts b/kun/src/manager/remote-data-stores.ts index 7dfb48315..a2db3b159 100644 --- a/kun/src/manager/remote-data-stores.ts +++ b/kun/src/manager/remote-data-stores.ts @@ -304,6 +304,18 @@ export class ManagerRemoteSessionStore implements SessionStore { return ItemPageSchema.parse(await this.call('loadItemPage', { threadId, options })) } + async searchItemText( + threadId: string, + query: string, + options?: { maxBytes?: number } + ): Promise { + return z.string().nullable().parse(await this.call('searchItemText', { + threadId, + query, + ...(options?.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }) + })) + } + async loadSession(threadId: string): Promise { return AgentSessionSchema.nullable().parse(await this.call('loadSession', { threadId })) as AgentSession | null } diff --git a/kun/src/manager/service-manager-state.ts b/kun/src/manager/service-manager-state.ts index a2f3c41f6..6e42bd5da 100644 --- a/kun/src/manager/service-manager-state.ts +++ b/kun/src/manager/service-manager-state.ts @@ -57,7 +57,8 @@ export const ThreadStoreOperationSchema = z.enum([ export const SessionStoreOperationSchema = z.enum([ 'appendEvent', 'appendItem', 'rewriteItems', 'loadItemSnapshot', 'rewriteItemsIfRevision', 'updateItem', 'compactItems', 'loadEventsSince', - 'loadItems', 'loadItemPage', 'loadSession', 'upsertSession', 'highestSeq', 'allocateEventSeq', + 'loadItems', 'searchItemText', 'loadItemPage', 'loadSession', 'upsertSession', + 'highestSeq', 'allocateEventSeq', 'loadUsageRecords', 'loadLatestUsageSnapshots', 'resetMemory', 'clearThreadMemory' ]) export const ArtifactStoreOperationSchema = z.enum([ diff --git a/kun/src/manager/shared-data-store-contracts.ts b/kun/src/manager/shared-data-store-contracts.ts index 876a62935..505e4df5c 100644 --- a/kun/src/manager/shared-data-store-contracts.ts +++ b/kun/src/manager/shared-data-store-contracts.ts @@ -112,6 +112,7 @@ export type ManagerSessionStoreOperation = | 'compactItems' | 'loadEventsSince' | 'loadItems' + | 'searchItemText' | 'loadItemPage' | 'loadSession' | 'upsertSession' diff --git a/kun/src/manager/shared-data-store-implementation.ts b/kun/src/manager/shared-data-store-implementation.ts index d0ca2e8ca..7fb21fbde 100644 --- a/kun/src/manager/shared-data-store-implementation.ts +++ b/kun/src/manager/shared-data-store-implementation.ts @@ -477,6 +477,22 @@ export class ManagerSharedDataStore extends ManagerSharedDataStoreCore { const { threadId } = parseThreadId(value) return this.sessionStore.loadItems(threadId) } + case 'searchItemText': { + const body = z.object({ + threadId: ThreadIdSchema, + query: z.string(), + maxBytes: z.number().int().positive().optional() + }).strict().parse(value) + // The owning store keeps the lock-free guarantee; a manager-backed + // runtime without it reports no match rather than falling back to the + // blocking item-load path. + if (!this.sessionStore.searchItemText) return null + return this.sessionStore.searchItemText( + body.threadId, + body.query, + body.maxBytes === undefined ? undefined : { maxBytes: body.maxBytes } + ) + } case 'loadItemPage': { const body = z.object({ threadId: ThreadIdSchema, diff --git a/kun/src/manager/shared-data-store.test.ts b/kun/src/manager/shared-data-store.test.ts index 19cf84541..6974ca8a7 100644 --- a/kun/src/manager/shared-data-store.test.ts +++ b/kun/src/manager/shared-data-store.test.ts @@ -21,6 +21,38 @@ async function dataStore(): Promise { } describe('manager shared data store', () => { + it('proxies the lock-free item text search so palette deep search works in shared mode', async () => { + const store = await dataStore() + const thread = createThreadRecord({ + id: 'thread-search', title: 'Search', workspace: '/tmp/workspace', model: 'test-model' + }) + const turn = createTurnRecord({ + id: 'turn-search', threadId: thread.id, prompt: 'search', model: 'test-model' + }) + const createdAt = '2026-08-15T00:00:00.000Z' + await store.executeThread('upsert', { thread }) + await store.executeSession('appendItem', { + threadId: thread.id, + item: { + id: 'msg-1', kind: 'user_message', turnId: turn.id, threadId: thread.id, + role: 'user', status: 'completed', createdAt, + text: 'Please rework the checkout flow end to end.' + } + }) + + // The GUI's shared runtime reaches the store through this proxy. Before + // `searchItemText` was allowlisted here it silently resolved to nothing, + // so deep search returned no matches in the real app. + await expect(store.executeSession('searchItemText', { + threadId: thread.id, query: 'checkout' + })).resolves.toBe('Please rework the checkout flow end to end.') + await expect(store.executeSession('searchItemText', { + threadId: thread.id, query: 'absent' + })).resolves.toBeNull() + await store.close() + }) + + it('serializes canonical thread mutations without changing the existing format', async () => { const store = await dataStore() const thread = createThreadRecord({ diff --git a/kun/src/ports/session-store.ts b/kun/src/ports/session-store.ts index 46f547695..2e654bf02 100644 --- a/kun/src/ports/session-store.ts +++ b/kun/src/ports/session-store.ts @@ -141,6 +141,23 @@ export interface SessionStore { loadItems(threadId: string): Promise /** Optional bounded history read used by renderer timeline hydration. */ loadItemPage?(threadId: string, options: ItemHistoryPageOptions): Promise + /** + * Optional bounded, lock-free text scan over item history. + * + * Search is a read-only side path: unlike `loadItems` it must never take a + * thread's write queue and must never trigger history compaction, so a + * search keystroke cannot contend with an in-flight turn or rewrite a + * multi-megabyte log. Implementations return the first matching item text, + * or null when the thread has no match within `maxBytes`. Stores that + * cannot honor those guarantees should leave this undefined; callers treat + * an absent implementation as "no content-search capability" rather than + * falling back to `loadItems`. + */ + searchItemText?( + threadId: string, + query: string, + options?: { maxBytes?: number } + ): Promise loadSession(threadId: string): Promise upsertSession(session: AgentSession): Promise /** Highest known per-thread `seq`. Returns 0 when no events have been recorded. */ diff --git a/kun/src/server/routes/register-thread-routes.ts b/kun/src/server/routes/register-thread-routes.ts index 6a09b8a37..6005564af 100644 --- a/kun/src/server/routes/register-thread-routes.ts +++ b/kun/src/server/routes/register-thread-routes.ts @@ -1,5 +1,6 @@ import type { Router } from '../router.js' import { + contentSearchThreads, createThread, clearThreadGoal, clearThreadTodos, @@ -59,6 +60,11 @@ export function registerThreadRoutes( if (!authorize(request, runtime)) return ERRORS.unauthorized() return createThread(runtime.threadService, request) }) + // Static content-search suffix must be registered before `/:id`. + router.add('GET', '/v1/threads/content-search', async (request) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + return contentSearchThreads(runtime.threadService, runtime.sessionStore, request) + }) // This static suffix must be registered before `/:id`, because Router uses // first-match ordering for parameterized paths. router.add('GET', '/v1/threads/:id/state', async (request, ctx) => { diff --git a/kun/src/server/routes/threads.test.ts b/kun/src/server/routes/threads.test.ts index d9ee40d40..47bb6ca6d 100644 --- a/kun/src/server/routes/threads.test.ts +++ b/kun/src/server/routes/threads.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it, vi } from 'vitest' -import { forkThread, getThread, getThreadState, getThreadTimeline, updateThread } from './threads.js' +import { + contentSearchThreads, + forkThread, + getThread, + getThreadState, + getThreadTimeline, + snippetAroundMatch, + updateThread, + THREAD_CONTENT_SEARCH_BUDGET_MS +} from './threads.js' import { buildRouter } from './index.js' import type { ServerRuntime } from './server-runtime.js' import { createThreadRecord } from '../../domain/thread.js' import { createTurnRecord } from '../../domain/turn.js' import { makeGoalContextItem, makeUserItem } from '../../domain/item.js' +import type { TurnItem } from '../../contracts/items.js' import { createApprovalRequest } from '../../domain/approval.js' import { InMemoryApprovalGate } from '../../adapters/in-memory-approval-gate.js' import { InMemoryUserInputGate } from '../../adapters/in-memory-user-input-gate.js' @@ -587,4 +597,173 @@ describe('GET /v1/threads/:id active-owner forwarding (#1053)', () => { const rejected = await match.handler(unauthorized, { params: match.params }) expect(rejected.status).toBe(401) }) + +describe('contentSearchThreads', () => { + it('returns one snippet per matching conversation, most recently updated first', async () => { + const newer = createThreadRecord({ + id: 'thr_newer', title: 'Payment gateway', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const older = createThreadRecord({ + id: 'thr_older', title: 'Docs rewrite', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const none = createThreadRecord({ + id: 'thr_none', title: 'Nothing', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const archived = createThreadRecord({ + id: 'thr_archived', title: 'Archived hit', workspace: '/tmp', model: 'deepseek-chat', status: 'archived' + }) + newer.updatedAt = '2026-08-15T03:00:00.000Z' + older.updatedAt = '2026-08-15T02:00:00.000Z' + none.updatedAt = '2026-08-15T01:00:00.000Z' + archived.updatedAt = '2026-08-15T04:00:00.000Z' + const service = { + list: async () => [none, archived, older, newer] + } as unknown as ThreadService + const sessionStore = { + searchItemText: async (threadId: string): Promise => { + if (threadId === 'thr_newer') return 'Let us redesign the checkout flow end to end.' + if (threadId === 'thr_older') return 'checkout must be faster' + if (threadId === 'thr_archived') return 'checkout checkout checkout' + return null + } + } + const response = await contentSearchThreads( + service, + sessionStore, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + expect(response.status).toBe(200) + const body = JSON.parse(response.body) as { matches: Array<{ threadId: string; title: string; workspace: string; snippet: string; updatedAt: string }> } + expect(body.matches.map((match) => match.threadId)).toEqual(['thr_newer', 'thr_older']) + expect(body.matches[0].title).toBe('Payment gateway') + expect(body.matches[0].workspace).toBe('/tmp') + expect(body.matches[0].snippet.toLowerCase()).toContain('checkout') + }) + + it('never drives the blocking loadItems path', async () => { + const thread = createThreadRecord({ + id: 'thr_only', title: 'Only', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const service = { list: async () => [thread] } as unknown as ThreadService + const loadItems = vi.fn(async (): Promise => [ + makeUserItem({ id: 'i0', turnId: 't0', threadId: 'thr_only', text: 'checkout' }) + ]) + // A store exposing only the blocking path reports no matches rather than + // taking per-thread write queues and compacting logs on a keystroke. + const response = await contentSearchThreads( + service, + { loadItems } as unknown as Parameters[1], + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + expect(response.status).toBe(200) + expect(JSON.parse(response.body)).toEqual({ matches: [] }) + expect(loadItems).not.toHaveBeenCalled() + }) + + it('searches every project and reports which one each match came from', async () => { + const here = createThreadRecord({ + id: 'thr_here', title: 'This project', workspace: '/repo/app', model: 'deepseek-chat', status: 'idle' + }) + const elsewhere = createThreadRecord({ + id: 'thr_elsewhere', title: 'Other project', workspace: '/repo/other', model: 'deepseek-chat', status: 'idle' + }) + here.updatedAt = '2026-08-15T02:00:00.000Z' + elsewhere.updatedAt = '2026-08-15T03:00:00.000Z' + const service = { list: async () => [here, elsewhere] } as unknown as ThreadService + const sessionStore = { searchItemText: async (): Promise => 'checkout here' } + const response = await contentSearchThreads( + service, + sessionStore, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + const body = JSON.parse(response.body) as { + matches: Array<{ threadId: string; workspace: string }> + } + // Recency alone orders them; the workspace rides along so the caller can + // show which project a match belongs to. + expect(body.matches.map((match) => match.threadId)).toEqual(['thr_elsewhere', 'thr_here']) + expect(body.matches.map((match) => match.workspace)).toEqual(['/repo/other', '/repo/app']) + }) + + it('stops scanning once the time budget is spent', async () => { + const threads = Array.from({ length: 10 }, (_, index) => { + const record = createThreadRecord({ + id: 'thr_' + index, title: 'T' + index, workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + record.updatedAt = '2026-08-15T0' + index + ':00:00.000Z' + return record + }) + const service = { list: async () => threads } as unknown as ThreadService + const searchItemText = vi.fn(async (): Promise => 'checkout') + // Reads: deadline stamp, first candidate check (in budget), second check + // (spent). Exactly one of the ten candidates is scanned. + const clock = [0, 0, THREAD_CONTENT_SEARCH_BUDGET_MS + 1] + const response = await contentSearchThreads( + service, + { searchItemText }, + new Request('http://kun.local/v1/threads/content-search?q=checkout'), + () => clock.shift() ?? THREAD_CONTENT_SEARCH_BUDGET_MS + 1 + ) + const body = JSON.parse(response.body) as { matches: Array<{ threadId: string }> } + expect(body.matches).toHaveLength(1) + expect(searchItemText).toHaveBeenCalledTimes(1) + }) + + it('rejects empty and oversized queries with 400', async () => { + const service = { list: async () => [] } as unknown as ThreadService + const sessionStore = { searchItemText: async () => null } + const empty = await contentSearchThreads( + service, sessionStore, new Request('http://kun.local/v1/threads/content-search') + ) + expect(empty.status).toBe(400) + const oversized = await contentSearchThreads( + service, + sessionStore, + new Request('http://kun.local/v1/threads/content-search?q=' + 'x'.repeat(257)) + ) + expect(oversized.status).toBe(400) + }) + + it('tolerates threads whose items cannot be scanned', async () => { + const broken = createThreadRecord({ + id: 'thr_broken', title: 'Broken', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const fine = createThreadRecord({ + id: 'thr_fine', title: 'Fine', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + broken.updatedAt = '2026-08-15T03:00:00.000Z' + fine.updatedAt = '2026-08-15T02:00:00.000Z' + const service = { + list: async () => [broken, fine] + } as unknown as ThreadService + const sessionStore = { + searchItemText: async (threadId: string): Promise => { + if (threadId === 'thr_broken') throw new Error('corrupt') + return 'checkout once more' + } + } + const response = await contentSearchThreads( + service, + sessionStore, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + const body = JSON.parse(response.body) as { matches: Array<{ threadId: string }> } + expect(body.matches.map((match) => match.threadId)).toEqual(['thr_fine']) + }) +}) + +describe('snippetAroundMatch', () => { + it('windows the snippet around the first match and elides the edges', () => { + const text = 'a'.repeat(300) + ' checkout ' + 'b'.repeat(300) + const snippet = snippetAroundMatch(text, 'checkout') + expect(snippet).toContain('checkout') + expect(snippet.startsWith('…')).toBe(true) + expect(snippet.endsWith('…')).toBe(true) + expect(snippet.length).toBeLessThan(180) + }) + + it('returns the head of the text when nothing matches', () => { + expect(snippetAroundMatch('plain text without match', 'zzz')).toBe('plain text without match') + }) +}) }) diff --git a/kun/src/server/routes/threads.ts b/kun/src/server/routes/threads.ts index c545c764c..a9bd80b80 100644 --- a/kun/src/server/routes/threads.ts +++ b/kun/src/server/routes/threads.ts @@ -579,4 +579,112 @@ function parseListThreadsOptions( } } +/** + * Deep-search route: scans the message content of recent conversations for + * a literal term and returns one snippet per matching thread. Content never + * leaves the local data dir. + * + * Deliberately not workspace-scoped: "where did I discuss this?" is usually + * asked without remembering which project it was in. Each match carries its + * workspace so callers can show which project it came from. The bounds below + * are therefore shared across every project, and a busy project can crowd out + * a quieter one. + * + * Every bound here exists because this runs on a palette keystroke. The scan + * uses the store's lock-free `searchItemText` capability rather than + * `loadItems`, which would take each thread's write queue and compact logs + * past the compaction threshold (#621). Stores without that capability report + * no matches instead of falling back to the blocking path. + */ +const THREAD_CONTENT_SEARCH_MAX_THREADS = 40 +const THREAD_CONTENT_SEARCH_DEFAULT_MATCHES = 12 +const THREAD_CONTENT_SEARCH_MAX_QUERY_CHARS = 256 +const THREAD_CONTENT_SEARCH_CANDIDATE_POOL = 500 +/** Wall-clock ceiling for one scan; partial results beat a stalled palette. */ +export const THREAD_CONTENT_SEARCH_BUDGET_MS = 400 + +const ContentSearchQuery = z.object({ + q: z.string().min(1).max(THREAD_CONTENT_SEARCH_MAX_QUERY_CHARS), + limit: z.preprocess((value) => { + if (typeof value !== 'string' || value.trim() === '') return undefined + return Number(value) + }, z.number().int().positive().max(20).optional()) +}) + +export type ThreadContentMatch = { + threadId: string + title: string + workspace: string + snippet: string + updatedAt: string +} + +export type ThreadContentSearchResponse = { matches: ThreadContentMatch[] } + +export type ThreadContentSearchStore = Pick + +export function snippetAroundMatch(text: string, query: string): string { + const index = text.toLowerCase().indexOf(query.toLowerCase()) + if (index < 0) return text.slice(0, 160) + const start = Math.max(0, index - 60) + const end = Math.min(text.length, index + query.length + 100) + return ((start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')) + .replace(/\s+/g, ' ') + .trim() +} + +export async function contentSearchThreads( + service: ThreadService, + sessionStore: ThreadContentSearchStore, + request: Request, + now: () => number = () => Date.now() +): Promise { + const url = new URL(request.url) + const parsed = ContentSearchQuery.safeParse(Object.fromEntries(url.searchParams.entries())) + if (!parsed.success) { + return validationError('invalid content search query', parsed.error.issues) + } + const search = sessionStore.searchItemText + if (!search) return jsonResponse({ matches: [] } satisfies ThreadContentSearchResponse) + + const query = parsed.data.q + const matchLimit = parsed.data.limit ?? THREAD_CONTENT_SEARCH_DEFAULT_MATCHES + const deadline = now() + THREAD_CONTENT_SEARCH_BUDGET_MS + + // `list` already excludes archived and deleted threads and orders by + // recency; the status filter and sort here keep the route correct on its + // own terms rather than depending on that as an invariant. + const threads = await service.list({ limit: THREAD_CONTENT_SEARCH_CANDIDATE_POOL }) + const candidates = threads + .filter((thread) => thread.status !== 'archived' && thread.status !== 'deleted') + .sort((left, right) => sortableTime(right.updatedAt) - sortableTime(left.updatedAt)) + .slice(0, THREAD_CONTENT_SEARCH_MAX_THREADS) + + const matches: ThreadContentMatch[] = [] + for (const thread of candidates) { + if (matches.length >= matchLimit || now() >= deadline) break + let text: string | null + try { + text = await search.call(sessionStore, thread.id, query) + } catch { + continue + } + if (!text) continue + matches.push({ + threadId: thread.id, + title: thread.title.trim() || thread.id, + workspace: thread.workspace, + snippet: snippetAroundMatch(text, query), + updatedAt: thread.updatedAt + }) + } + return jsonResponse({ matches } satisfies ThreadContentSearchResponse) +} + +/** Unparsable timestamps sort last instead of poisoning the comparator. */ +function sortableTime(value: string): number { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? 0 : parsed +} + void z diff --git a/kun/src/services/thread-lifecycle-fence.ts b/kun/src/services/thread-lifecycle-fence.ts index b86bd4fc9..d8bc3c041 100644 --- a/kun/src/services/thread-lifecycle-fence.ts +++ b/kun/src/services/thread-lifecycle-fence.ts @@ -225,6 +225,7 @@ export class LifecycleFencedSessionStore implements SessionStore { readonly scheduleUsageEventCompaction?: SessionStore['scheduleUsageEventCompaction'] readonly flushScheduledCompaction?: SessionStore['flushScheduledCompaction'] readonly loadItemPage?: SessionStore['loadItemPage'] + readonly searchItemText?: SessionStore['searchItemText'] constructor( readonly raw: SessionStore, @@ -250,6 +251,11 @@ export class LifecycleFencedSessionStore implements SessionStore { if (raw.loadLatestUsageSnapshots) { this.loadLatestUsageSnapshots = (options) => raw.loadLatestUsageSnapshots!(options) } + if (raw.searchItemText) { + // Read-only and lock-free by contract, so it takes no lifecycle lease. + this.searchItemText = (threadId, query, options) => + raw.searchItemText!(threadId, query, options) + } if (raw.compactItems) { this.compactItems = (threadId, options) => this.write(threadId, { diff --git a/openspec/changes/add-gui-command-palette/design.md b/openspec/changes/add-gui-command-palette/design.md new file mode 100644 index 000000000..b98c9b4bf --- /dev/null +++ b/openspec/changes/add-gui-command-palette/design.md @@ -0,0 +1,120 @@ +## Context + +The renderer already owns every mechanism a palette needs, but none of them are joined. `KEYBOARD_SHORTCUT_COMMANDS` in `src/shared/keyboard-shortcuts.ts` defines commands as `{ id, labelKey, descriptionKey, defaultBindings, platformDefaultBindings }` and `resolveKeyboardShortcutBindings` already merges user bindings with platform defaults. `useWorkbenchKeyboardShortcuts` maps a matched command id either to a workbench callback or to a window-level `DesktopCommand`. Navigation is expressed as `AppRoute` and `SettingsRouteSection` in `chat-store-types.ts`, driven by the `setRoute`, `openSettings`, `selectThread`, and `chooseWorkspace` store actions. The composer already models user-invocable actions as `SlashCommand` objects carrying `title`, `description`, `keywords`, `icon`, `badge`, `scopeLabel`, and `disabled`, and already represents skills as `skill:` command ids. + +Two constraints shape the design. First, extension right-sidebar contributions are Host-owned, workspace-scoped, and permission-gated; a palette row must not become a path that renders extension-controlled content or activates a View before workspace review. Second, the composer slash menu is an existing, heavily used command surface with its own key handling, so a second global command surface must be additive rather than competing. + +## Goals / Non-Goals + +**Goals:** + +- Give the GUI one keyboard-first entry point that reaches any existing route, settings destination, conversation, workspace, command, or skill. +- Reuse the existing command, route, and slash-command registries as the palette's sources so a new destination appears in the palette by construction. +- Make invocation a normal rebindable shortcut command rather than a hardcoded key. +- Keep ranking pure and deterministic so result ordering is unit-testable. +- Preserve extension trust, workspace scoping, and permission review unchanged. + +**Non-Goals:** + +- Searching message content, memory entries, artifacts, or workspace file contents. The palette indexes entity metadata only, with one later extension: a bounded conversation-content deep search (see the `gui-command-palette` spec) for unprefixed and `@`-scoped queries via a dedicated local runtime route. +- Replacing, absorbing, or restyling the composer slash-command menu or the sidebar conversation search. +- Running agent turns, sending prompts, or mutating threads from the palette. +- Adding preload IPC, Kun runtime routes, or extension manifest surface. +- Fuzzy-matching across locales beyond the existing localized label and keyword text. + +## Decisions + +### 1. The palette aggregates existing registries instead of defining its own + +A palette source is a pure function from renderer state to a `PaletteEntry` list, where an entry carries a stable id, a source kind, localized title and subtitle, optional keywords, an optional badge, a `disabled` reason, and an activation descriptor. Sources are: shortcut commands, app routes, settings sections, in-scope threads, recent workspaces, builtin and skill slash commands, and visible extension right-sidebar contributions. + +This keeps one registry per concept. Adding a settings section to `SettingsRouteSection` or a command to `KEYBOARD_SHORTCUT_COMMANDS` makes it reachable from the palette without a second edit, which is the property that keeps a palette from decaying into a stale subset of the app. + +Alternative considered: a dedicated palette command registry that each feature registers into. Rejected because it duplicates existing enums and guarantees drift the first time a contributor adds a route and forgets the registration. + +### 2. Invocation is a registry command, not a hardcoded key + +The palette adds `command-palette` to `KEYBOARD_SHORTCUT_COMMANDS` with `defaultBindings: ['Ctrl+K']` and `platformDefaultBindings: { darwin: ['Meta+K'] }`. `useWorkbenchKeyboardShortcuts` gains one callback that opens the palette. + +This inherits three behaviors for free: the settings shortcuts section lists and rebinds it, `normalizeKeyboardShortcuts` validates it against `COMMAND_IDS`, and platform key normalization is already handled. Because `findKeyboardShortcutCommand` resolves the first command whose bindings match, a user who has already bound the same chord to another command keeps that command's behavior; the palette does not special-case itself ahead of user configuration. + +Alternative considered: a hardcoded `Ctrl+K`/`Meta+K` listener in the palette component. Rejected because it bypasses the settings UI, cannot be rebound or disabled, and would silently shadow a user's existing binding. + +### 3. Sources are lazily evaluated with a synchronous fast path + +Route, settings, shortcut, and slash-command sources are derived synchronously from in-memory state and are always available on open. The thread and workspace sources read from already-loaded store state, are capped at a bounded scan, and are recomputed on query change rather than on every keystroke of unrelated state. Any source that cannot resolve is omitted rather than blocking the palette. + +Alternative considered: eagerly materializing all entries on open. Rejected because thread lists grow without bound over a project's life and the palette must open instantly. + +### 4. Query prefixes scope the search + +A leading `>` restricts results to commands, `@` to conversations, `#` to settings, and `/` to slash commands; anything else searches all sources. The prefix is stripped before matching. An empty query renders the recent-selection list, then a small default set of high-frequency destinations. + +Prefixes are a learned convention from comparable palettes and cost nothing when unknown, since unprefixed search still reaches everything. + +Alternative considered: a tab-based filter row. Rejected because it requires a pointer or an extra keystroke for the common case and adds chrome to a surface whose value is speed. + +### 5. Ranking is a pure, deterministic scorer + +Matching runs in tiers — exact title match, title prefix, word-boundary match on title or keywords, then subsequence match — and each tier is tie-broken first by source priority, then by per-workspace recency, then by stable entry id. Scoring takes a query and an entry list and returns an ordered list with no access to stores, timers, or randomness. + +Determinism is the point: ordering is the part of a palette users build muscle memory around, and a pure scorer lets tests assert exact result order rather than mere membership. + +Alternative considered: an off-the-shelf fuzzy matcher. Rejected because the repository ships no such dependency, tier boundaries would become implicit, and localized labels in the seven shipped locales need predictable word-boundary behavior more than they need aggressive fuzziness. + +### 6. Activation routes through existing store actions only + +Each entry's activation descriptor is a discriminated union resolved by a single dispatcher: `route` calls `setRoute`, `settings` calls `openSettings(section)`, `thread` calls `selectThread`, `workspace` calls `chooseWorkspace`, `shortcut-command` invokes the same callback or `DesktopCommand` that the shortcut handler would, `slash-command` inserts the command into the composer without sending it, and `extension-view` uses the existing right-workspace tab controller. + +No activation path introduces a new mutation. The palette is a router over existing behavior, which keeps its blast radius to the overlay itself. + +### 7. Extension rows are fail-closed and never execute extension code + +Extension entries are built from bounded Host-owned manifest display metadata already used by the launcher rail. Icons resolve through the existing extension resource protocol, which serves only exact manifest-declared icon paths. A contribution whose workspace review is pending renders as a locked entry whose activation opens the existing permission review rather than the View, and unavailable, disabled, or untrusted contributions are omitted. + +### 8. The palette yields to the composer slash menu + +The palette does not open while the composer slash-command menu is open, while an IME composition is active, or while a native dialog owns input. Slash-command entries in the palette insert text into the composer and leave sending to the user, so the two surfaces stay complementary: the slash menu is the in-composer path and the palette is the global path. + +Alternative considered: routing the composer slash menu through the palette. Rejected because it would move command entry out of the composer's typing flow and change an established interaction for no discovery gain. + +### 9. Curated groups and ranked results are mutually exclusive + +The overlay renders `results` followed by `groups`, so the two must never describe the same rows. With no query and no scope the hook emits curated groups and an empty result list; with any query it emits ranked results and, at most, a conversation-matches group. The scorer still treats an empty query as "everything matches", which is what makes a scoped empty query such as `#` a browsable settings list. + +Alternative considered: letting the overlay ignore `results` whenever `groups` is present. Rejected because the conversation-matches group must render *alongside* ranked results, so the overlay cannot decide this on its own — the hook is the only place that knows which state it is in. + +### 10. Content search uses a lock-free store capability, not item loading + +`SessionStore.loadItems` takes the per-thread write queue and compacts item history past the compaction threshold. Both are correct for the agent loop and wrong for a keystroke: a search would contend with an in-flight turn and could rewrite a multi-megabyte log as a side effect of typing (#621). Search therefore gets its own optional capability, `searchItemText`, which reads the tail of `messages.jsonl` under a byte budget, pre-filters raw lines before parsing, verifies the hit against real item text, and never writes. + +A store that cannot honor those guarantees leaves the capability undefined, and the route reports no matches. Degrading the feature is strictly better than degrading the runtime. + +Alternative considered: keeping `loadItems` and lowering the thread cap. Rejected because it reduces the frequency of the hazard without removing it — a single large active thread is enough. + +### 11. Settings keeps the chord by leaving Settings + +AppShell renders Settings *instead of* the workbench, so the palette overlay and its sources are unmounted there. Rather than build a second reduced palette for one route, the Settings surface listens for the chord and uses the existing `closeSettings` action, which restores the route Settings was opened from, before opening the palette. + +Alternative considered: hoisting the palette to AppShell. Rejected because its sources and activation handlers are workbench-owned; lifting them would mean threading most of the workbench through the shell for one route. + +## Risks / Trade-offs + +- **Binding collision.** `Ctrl+K` is currently unused in the renderer, but a future editor binding could want it. Mitigated by shipping it as a rebindable registry command and by honoring an existing user binding ahead of the palette default. +- **Two search surfaces.** The palette and the sidebar conversation search can both find a thread. Accepted: the sidebar stays scoped to its section and ordering, the palette is global and keyboard-first, and neither changes the other's behavior. +- **Large thread histories.** Unbounded scanning would make the palette feel slow exactly where it matters most. Mitigated by a bounded scan cap, recency-first ordering, a per-thread byte budget, and a wall-clock budget for the whole scan. +- **Tail-window search misses old content.** Reading the tail of a thread log bounds the work but means a term that appears only early in a very long conversation is not found. Accepted: the alternative is unbounded reads on the typing path, and recent content is what a palette query is usually after. +- **Localized matching quality.** Word-boundary matching is weaker for locales without spaces, notably Simplified Chinese, Japanese, and Thai. Mitigated by keeping the subsequence tier and by matching against keywords in addition to titles; further tuning is deferred rather than guessed at. +- **Discovery of the palette itself.** A keyboard-only surface is invisible to pointer users. Mitigated by a workbench top-bar entry point alongside the shortcut. + +## Migration Plan + +No existing persisted state changes shape. The recent-selection store is a new versioned key scoped by normalized workspace; an absent or unparsable key yields an empty list and the palette falls back to its default entry set. Entries referencing a thread, workspace, or extension contribution that no longer resolves are dropped on read rather than repaired. + +Adding `command-palette` to the shortcut registry is additive: `normalizeKeyboardShortcuts` already discards unknown ids, so settings written by an older build load unchanged, and settings written by a newer build load in an older build with the unknown binding discarded. + +## Open Questions + +- Should the palette eventually index message content, memory entries, and artifacts? That is the more valuable search product but requires a persistent index and is deliberately out of scope here. +- Should activating a skill entry prefill the composer or start the turn immediately? This change prefills, on the reasoning that a palette should never send an agent request the user has not seen. +- Should the palette expose an extension contribution point so extensions can register their own entries? Deferred until the built-in source set is proven. diff --git a/openspec/changes/add-gui-command-palette/proposal.md b/openspec/changes/add-gui-command-palette/proposal.md new file mode 100644 index 000000000..dc9e0a4f9 --- /dev/null +++ b/openspec/changes/add-gui-command-palette/proposal.md @@ -0,0 +1,40 @@ +## Why + +Kun's GUI has grown many destinations — chat, Write, Design, Schedule, Workflow, Claw, Plugins, Extensions, twenty-five settings sections, per-workspace conversations, skills, and extension Views — but no single way to reach them. Discovery is split across the sidebar, the workbench top bar, the settings sidebar, and the composer slash menu, so reaching a destination requires already knowing which surface owns it. + +The only keyboard surface today is a fixed registry of twenty commands in `src/shared/keyboard-shortcuts.ts`, most of which are window-level desktop actions (quit, zoom, devtools) rather than navigation. Conversation search is scoped to one sidebar section and matches only title, preview, and workspace. The TUI already exposes a command surface; the GUI has no equivalent. + +A palette binds the existing feature set together without adding new runtime capability: every destination it reaches is already reachable, and every action it runs is already implemented as a store action or a desktop command. + +## What Changes + +- Add a renderer-owned modal command palette overlay, opened by a new rebindable `command-palette` entry in the shared keyboard-shortcut registry with platform defaults of `Meta+K` on macOS and `Ctrl+K` elsewhere. +- Aggregate palette results from existing sources instead of introducing a second command registry: keyboard-shortcut commands, top-level app routes, settings destinations, conversation threads in scope, recent workspaces, builtin and skill slash commands, and visible extension right-sidebar contributions. +- Add query mode prefixes so users can scope a search: `>` for commands, `@` for conversations, `#` for settings, `/` for slash commands, and unprefixed text for a mixed ranked result set. +- Rank results through a pure, deterministic scorer (exact, prefix, word-boundary, then subsequence match) tie-broken by source priority and per-workspace recency, so ordering is assertable in tests. +- Activate results exclusively through existing store actions and desktop commands. Except for the conversation-content deep search below, the palette adds no preload IPC, Kun HTTP/SSE, or extension manifest surface. +- Keep the composer slash-command menu unchanged and non-overlapping: the palette SHALL NOT open while the composer slash menu is active, and SHALL NOT replace in-composer command entry. +- Keep extension rows fail-closed: render only bounded Host-owned manifest display metadata, never execute extension code to populate a row, and route unreviewed contributions to the existing permission review. +- Persist a bounded per-workspace recent-selection list, bounded in stored workspaces as well as entries, and surface it as the palette's only empty-query content. +- Deep-search conversation message content for unprefixed and `@`-scoped queries through one new local runtime route (`GET /v1/threads/content-search`), spanning every project (each match badged with the project it came from) and bounded in scanned threads, matches, and wall-clock time, rendered as a conversation-matches section with snippets. +- Add a lock-free `searchItemText` session-store capability for that route, so a palette keystroke never takes a thread's write queue or triggers history compaction the way item loading does; stores without it report no matches instead of falling back. The manager session-store proxy carries the capability so the shared runtime the GUI actually uses can serve it. +- Highlight the matched term in result titles and snippets, and show a pending indicator from the keystroke until the search settles so a slow search never reads as an empty one. +- Keep the palette chord alive on the Settings route, which replaces the workbench surface that owns the overlay, by returning to the originating route and opening the palette there. +- Register `command-palette` last in the shortcut registry so first-match resolution gives every other command precedence for a chord a user assigned to it. +- Give the overlay full combobox/listbox accessibility, roving keyboard focus, Escape dismissal, and focus restoration to the previously focused element. + +## Capabilities + +### New Capabilities + +- `gui-command-palette`: Palette invocation, result source aggregation, query scoping, deterministic ranking, activation routing, extension trust handling, recency persistence, and accessibility. + +## Impact + +- Renderer workbench gains a palette overlay, a palette store slice, pure source-aggregation and ranking modules, and focused tests. +- `src/shared/keyboard-shortcuts.ts` gains one command definition; the existing shortcuts settings section lists and rebinds it with no section-specific changes. +- Locale files gain palette labels, descriptions, mode hints, and empty-state copy. +- Renderer local storage gains one additive versioned key for per-workspace recent selections; absence of the key yields an empty recents list. +- Kun gains one additive local route, `GET /v1/threads/content-search`, and one optional `SessionStore.searchItemText` capability implemented by the file store and delegated through the hybrid and lifecycle-fenced stores. Existing store behavior, including item loading and compaction, is unchanged. +- The Settings surface gains a palette-chord listener; preload IPC, extension manifest schema, the composer slash-command menu, the sidebar conversation search, and Write, Design, and SDD behavior are unchanged. +- The renderer gains a small native-dialog activity tracker so the palette can honor its "native dialog owns input" suppression rule; the workspace picker reports through it. diff --git a/openspec/changes/add-gui-command-palette/specs/gui-command-palette/spec.md b/openspec/changes/add-gui-command-palette/specs/gui-command-palette/spec.md new file mode 100644 index 000000000..fd4576848 --- /dev/null +++ b/openspec/changes/add-gui-command-palette/specs/gui-command-palette/spec.md @@ -0,0 +1,334 @@ +## ADDED Requirements + +### Requirement: Kun opens a global command palette from a rebindable shortcut +Kun SHALL provide a modal command palette overlay in the GUI workbench. Invocation SHALL be a `command-palette` command in the shared keyboard-shortcut registry with a default binding of `Ctrl+K` and a macOS platform default of `Meta+K`. The palette SHALL also be reachable from a workbench top-bar control. The palette MUST be dismissible with Escape and MUST restore focus to the previously focused element on close. + +#### Scenario: User opens the palette with the default binding +- **WHEN** the workbench has focus and the user presses the resolved `command-palette` binding +- **THEN** Kun SHALL open the palette overlay with an empty query and focus its input + +#### Scenario: User rebinds the palette shortcut +- **WHEN** the user assigns a different chord to `command-palette` in the shortcuts settings section +- **THEN** the new chord SHALL open the palette and the previous default SHALL no longer open it + +#### Scenario: Palette binding is already claimed by another command +- **WHEN** a user binding assigns the palette's default chord to a different shortcut command +- **THEN** Kun SHALL run that other command and SHALL NOT open the palette + +#### Scenario: User dismisses the palette +- **WHEN** the palette is open and the user presses Escape +- **THEN** Kun SHALL close the overlay without activating a result and SHALL return focus to the element focused before the palette opened + +### Requirement: Palette results aggregate existing navigation and command sources +The palette SHALL build results from existing renderer registries and state: keyboard-shortcut commands, top-level app routes, settings destinations, conversation threads in the active scope, recent workspaces, builtin and skill slash commands, and visible extension right-sidebar contributions. Each result MUST carry a stable identity, a localized title, a source-identifying label, and an activation descriptor. The palette MUST NOT define a parallel command registry. + +A shortcut command with no assigned chord SHALL still be listed, without a binding badge. A settings section that resolves to a destination another listed section already reaches SHALL be listed once. + +#### Scenario: A new settings destination is added +- **WHEN** a settings destination is added to the shared settings route union and given localized copy +- **THEN** it SHALL be reachable from the palette without a separate palette registration + +#### Scenario: Command has no keyboard binding +- **WHEN** a shortcut command has no default or user-assigned chord +- **THEN** the palette SHALL list it as an activatable result with no binding badge + +#### Scenario: Two settings sections resolve to one destination +- **WHEN** a legacy settings section alias opens the same destination as another listed section +- **THEN** the palette SHALL list that destination once rather than showing two differently-labeled rows for it + +#### Scenario: Results identify their source +- **WHEN** the palette displays results drawn from more than one source +- **THEN** each result SHALL display a localized source label distinguishing commands, conversations, settings, workspaces, skills, and extensions + +#### Scenario: A source cannot be resolved +- **WHEN** one result source fails to resolve for the active workspace +- **THEN** the palette SHALL omit that source and SHALL continue to display results from every other source + +### Requirement: Query prefixes scope palette results +The palette SHALL scope results by leading prefix: `>` SHALL restrict results to commands, `@` to conversations, `#` to settings destinations, and `/` to slash commands. Any other leading character SHALL search all sources. The prefix MUST be stripped before matching and the active scope MUST be indicated in the palette. + +#### Scenario: User scopes the query to settings +- **WHEN** the user types `#` followed by a settings term +- **THEN** the palette SHALL return only settings destinations matching that term + +#### Scenario: User searches without a prefix +- **WHEN** the user types a term with no recognized prefix +- **THEN** the palette SHALL return ranked results drawn from every available source + +#### Scenario: Scoped query has no matches +- **WHEN** a scoped query matches nothing in its source +- **THEN** the palette SHALL display a localized empty state for that scope and SHALL NOT silently widen the scope + +### Requirement: Palette ranking is deterministic +Result ordering SHALL be produced by a pure scoring function over the query and the candidate entries, with no dependency on wall-clock time, randomness, or store access. Matching SHALL proceed in tiers — exact title match, title prefix match, word-boundary match against title or keywords, title acronym, then subsequence match — and ties MUST be broken by source priority, then per-workspace frecency, then stable entry identity. + +#### Scenario: Exact match outranks a prefix match +- **WHEN** the query exactly matches one entry title and is a prefix of another +- **THEN** the exactly matching entry SHALL be ordered first + +#### Scenario: Equal-tier results are ordered stably +- **WHEN** two entries match in the same tier with the same source priority and no recency +- **THEN** the palette SHALL order them by stable entry identity and SHALL produce the same order on every evaluation of the same query + +#### Scenario: Entry matches on keywords only +- **WHEN** the query matches an entry's keywords but not its title +- **THEN** the entry SHALL be eligible at the word-boundary tier and SHALL rank below entries matching the same query on title + +### Requirement: Activating a result reuses existing navigation actions +Palette activation SHALL route through existing store actions and desktop commands. Route entries SHALL set the app route, settings entries SHALL open their settings destination, conversation entries SHALL select that thread, workspace entries SHALL use the existing workspace-selection flow, shortcut-command entries SHALL run the same behavior as the shortcut, slash-command entries SHALL insert the command into the composer without sending it, and extension entries SHALL open or activate the matching right-workspace tab. Activation MUST NOT introduce preload IPC, Kun runtime HTTP or SSE, or extension manifest surface. + +#### Scenario: User activates a conversation result +- **WHEN** the user activates a conversation result +- **THEN** Kun SHALL select that thread through the existing thread-selection action and SHALL close the palette + +#### Scenario: User activates a skill result +- **WHEN** the user activates a skill slash-command result +- **THEN** Kun SHALL place that command in the composer and SHALL NOT start an agent turn + +#### Scenario: Composer already holds a draft +- **WHEN** the composer holds unsent text and the user activates a slash-command result +- **THEN** Kun MUST NOT discard that text +- **AND** for an argument-taking command the draft SHALL become the command's argument +- **AND** for any other command Kun SHALL leave the composer untouched and surface a localized notice + +#### Scenario: Result target is no longer available +- **WHEN** the activated entry references a thread, workspace, or contribution that no longer resolves +- **THEN** Kun SHALL close the palette without navigating and SHALL surface a localized unavailable notice + +#### Scenario: Entry is disabled in the current context +- **WHEN** an entry is disabled for the active thread, workspace, or route +- **THEN** the palette SHALL render it with its localized disabled reason and activation MUST have no effect + +### Requirement: Extension entries remain fail-closed +Extension results SHALL be constructed from bounded Host-owned manifest display metadata and MUST NOT execute extension code, evaluate extension scripts, or create a View Session to populate a palette row. Icons MUST resolve through the existing extension resource protocol, which serves only exact manifest-declared icon paths. Unavailable, disabled, or untrusted contributions MUST be omitted. + +#### Scenario: Extension awaits workspace review +- **WHEN** an enabled and compatible extension contributes a right-sidebar View that the active workspace has not reviewed +- **THEN** the palette SHALL show a locked entry containing only bounded manifest display metadata +- **AND** activating it SHALL open the existing permission review without creating a View Session + +#### Scenario: Extension is unavailable in the active workspace +- **WHEN** a contribution is not available for the active workspace +- **THEN** the palette SHALL omit it rather than showing an inert entry + +#### Scenario: Extension declares no icon +- **WHEN** a contributed View has no valid declared icon +- **THEN** the palette SHALL render a Host-owned fallback icon without executing extension code + +### Requirement: The palette does not displace the composer slash-command menu +The composer slash-command menu SHALL retain its existing invocation, filtering, and selection behavior. The palette MUST NOT open while the composer slash-command menu is open, while an IME composition is active, or while a native dialog owns input. + +#### Scenario: Slash menu is open when the palette chord is pressed +- **WHEN** the composer slash-command menu is open and the user presses the palette binding +- **THEN** Kun SHALL leave the slash menu open and SHALL NOT open the palette + +#### Scenario: IME composition is active +- **WHEN** a text input has an active IME composition and the user presses the palette binding +- **THEN** Kun SHALL NOT open the palette and SHALL NOT consume the key event + +#### Scenario: A native dialog owns input +- **WHEN** a Main-owned native dialog is open and the palette binding is pressed +- **THEN** Kun SHALL NOT open the palette and SHALL NOT consume the key event +- **AND** suppression SHALL apply only to the palette, leaving other shortcut commands resolvable + +#### Scenario: No palette surface is mounted +- **WHEN** the palette chord resolves in a surface with no palette to open +- **THEN** Kun SHALL leave the key event unconsumed rather than swallowing it + +### Requirement: The palette chord yields to any user-assigned command +`command-palette` SHALL be ordered last in the shared keyboard-shortcut registry so that first-match resolution gives every other command precedence for a chord a user has assigned to it, regardless of that command's position. + +#### Scenario: User binds the palette chord to a late-registered command +- **WHEN** a user assigns the palette's chord to a command registered after the palette's previous position, such as a window command +- **THEN** Kun SHALL run that command and SHALL NOT open the palette + +### Requirement: The palette chord works on the Settings route +Settings replaces the workbench surface that owns the palette overlay, so the palette MUST remain reachable there. Pressing the palette binding on the Settings route SHALL return to the route Settings was opened from and open the palette. + +#### Scenario: User presses the palette chord inside Settings +- **WHEN** the user opens a settings destination from the palette and then presses the palette binding again +- **THEN** Kun SHALL leave Settings for the originating route and SHALL open the palette there + +### Requirement: The palette is keyboard accessible +The palette SHALL expose a combobox input associated with a listbox of results, SHALL move the active option with Arrow, Home, End, Page Up, and Page Down, SHALL activate the selected option with Enter, and SHALL communicate the active option through ARIA relationships. Focus MUST be trapped within the overlay while it is open. + +#### Scenario: Keyboard user moves through results +- **WHEN** the palette has results and the user presses an arrow key +- **THEN** the active option SHALL move, the list SHALL scroll it into view, and the input SHALL reference the active option through ARIA + +#### Scenario: Screen reader announces result count +- **WHEN** the result set changes in response to a query +- **THEN** the palette SHALL expose the current result count through a live region + +### Requirement: Recent palette selections are workspace-scoped and bounded +Kun SHALL persist a bounded, ordered list of recent palette selections per normalized workspace under a versioned renderer storage key, and SHALL render that list as the empty-query state. Entries that no longer resolve MUST be dropped on read. An absent, unparsable, or unversioned value MUST yield an empty list rather than an error. + +#### Scenario: User reopens the palette after activating results +- **WHEN** the user opens the palette with an empty query after previously activating results in this workspace +- **THEN** the palette SHALL list the most recent selections first, followed by default destinations + +#### Scenario: Workspace changes +- **WHEN** the active workspace changes +- **THEN** the palette SHALL load that workspace's recent selections and SHALL NOT show recents from the previous workspace + +#### Scenario: Stored recents exceed the bound +- **WHEN** more selections are recorded than the retention bound allows +- **THEN** Kun SHALL retain the most recent entries up to the bound and SHALL discard the oldest + +#### Scenario: Selections accumulate across many workspaces +- **WHEN** selections have been recorded in more workspaces than the scope bound allows +- **THEN** Kun SHALL retain the most recently written scopes up to that bound and SHALL discard the least recent + +### Requirement: The empty-query state browses the whole capability surface +With no query and no scope prefix the palette SHALL render recent selections, then default destinations, then the remaining catalog grouped into labeled sections, so opening the palette shows everything it can do rather than a curated few. Sections SHALL appear in a stable order. + +No entry may appear more than once in one rendered result set: an entry promoted into recents or defaults MUST be omitted from its section below. The catalog MUST be rendered as grouped sections only, never additionally as a flat list beneath them. + +Conversations are content rather than capability and MAY be previewed rather than listed in full, since typing reaches the rest including message content. + +#### Scenario: User opens the palette +- **WHEN** the user opens the palette and has typed nothing +- **THEN** the palette SHALL render recents, then default destinations, then labeled sections covering commands, navigation, settings, models, conversations, projects, and extensions +- **AND** no entry SHALL be rendered twice + +#### Scenario: A destination is also a recent or a default +- **WHEN** an entry appears in recents or default destinations +- **THEN** it MUST NOT appear again in its own section further down + +#### Scenario: The workspace holds many conversations +- **WHEN** more conversations exist than the preview bound allows +- **THEN** the palette SHALL show the most recent up to that bound rather than every one + +#### Scenario: User types after opening +- **WHEN** the user types a query into the freshly opened palette +- **THEN** the palette SHALL replace the curated groups with ranked results from every available source + +### Requirement: The palette deep-searches conversation content +Kun SHALL extend unprefixed and `@`-scoped palette queries with a deep search over the message content of recent conversations, served by a dedicated local runtime route, and SHALL render each matching conversation under a distinct section with a snippet around the matched text. Deep search MUST NOT run for command, settings, or slash scopes, MUST be debounced, and MUST be bounded in scanned threads, returned matches, and wall-clock duration. + +Deep search SHALL span every project rather than only the active one, because recalling a discussion rarely comes with recalling which project it happened in. Every match MUST carry the workspace it belongs to, and each rendered row MUST show that project, so a result from elsewhere is never mistaken for one in the current project. The bounds above are shared across all projects, so a busy project can crowd out a quieter one. + +#### Scenario: Query matches a conversation in another project +- **WHEN** a term appears in the message content of a conversation belonging to a different project +- **THEN** the palette SHALL list that conversation +- **AND** the row SHALL identify the project it belongs to + +#### Scenario: Matches come from several projects +- **WHEN** conversations in more than one project match a term +- **THEN** the palette SHALL order them by recency across all projects + +#### Scenario: Scan exceeds its time budget +- **WHEN** scanning the candidate conversations reaches the route's wall-clock budget +- **THEN** the route SHALL return the matches found so far rather than continuing + +### Requirement: The palette acts, not only navigates +The palette SHALL offer actions the user can complete without leaving it: switching the composer's model, and reversible actions on the active conversation. Every configured model SHALL be listed with its provider, and the model in use MUST be marked rather than hidden. Conversation actions SHALL be offered only while an unarchived conversation is active, and MUST re-check that the target still exists before applying, reporting it unavailable otherwise. + +Destructive actions MUST NOT be offered. Deleting a conversation from a fuzzy-matched row is a trap that a mistyped query can spring, and the sidebar already offers deletion behind an explicit confirmation. + +#### Scenario: User switches model from the palette +- **WHEN** the user activates a model result +- **THEN** the composer SHALL send with that model and provider +- **AND** reopening the palette SHALL mark that model as the active one + +#### Scenario: User pins the conversation they are in +- **WHEN** an unarchived conversation is active and the user activates the pin action +- **THEN** Kun SHALL pin that conversation +- **AND** the action SHALL read as unpin while it stays pinned + +#### Scenario: No conversation is active +- **WHEN** no conversation is active, or the active one is archived +- **THEN** the palette SHALL offer no conversation actions + +#### Scenario: Action target disappeared +- **WHEN** an activated conversation action targets a conversation that no longer resolves +- **THEN** Kun SHALL apply nothing and SHALL surface a localized unavailable notice + +#### Scenario: User searches for a destructive action +- **WHEN** the user types a term that would match deleting a conversation +- **THEN** the palette SHALL NOT offer any destructive action + +### Requirement: Title initials reach a destination +Matching SHALL include an acronym tier that accepts a query matching the initials of a multi-word title, ranked below word-boundary matches and above loose subsequence. A single-word title MUST NOT produce an acronym match, because its initial is already covered by the prefix tier. + +#### Scenario: User types the initials of a destination +- **WHEN** the user types the initials of a multi-word destination, such as `ks` for a two-word settings title +- **THEN** that destination SHALL match at the acronym tier + +#### Scenario: A word-boundary match competes with an acronym match +- **WHEN** one entry matches a query on a word boundary and another only on its initials +- **THEN** the word-boundary match SHALL rank first + +### Requirement: Recall improves with use +Recent selections SHALL be ordered by frecency, combining how often an entry has been activated in the workspace with how recently, so a frequently used destination outranks a once-used newer one until its weight decays. Stored recents from the previous version MUST migrate without reordering what the user already recognizes, and repeated activations within the same millisecond MUST still order deterministically by activation sequence. + +#### Scenario: A habit outranks a one-off +- **WHEN** one entry has been activated many times and another once more recently +- **THEN** the frequently used entry SHALL be listed first until its weight decays below the newer one + +#### Scenario: Stored recents predate frecency +- **WHEN** recents stored by the previous version are read +- **THEN** they SHALL migrate in place and keep their existing order + +### Requirement: Deep search reports its own progress +While a conversation deep search is debouncing or in flight, the palette SHALL show a pending indicator and MUST NOT render its empty state. The pending state SHALL begin at the keystroke rather than at the request, so the debounce window is covered, and MUST clear whether the search succeeds or fails. + +#### Scenario: Results have not arrived yet +- **WHEN** the user types a term long enough to trigger deep search and no results have arrived +- **THEN** the palette SHALL show a searching indicator +- **AND** it MUST NOT state that there are no matching results + +#### Scenario: Some results are already visible +- **WHEN** ranked results are already rendered while deep search is still running +- **THEN** the palette SHALL keep a searching indicator visible so the user knows more may arrive + +#### Scenario: Deep search fails +- **WHEN** the conversation content search rejects +- **THEN** the palette SHALL clear the pending indicator and fall back to its normal empty or result state + +### Requirement: Matched terms are highlighted in results +The palette SHALL visually emphasize occurrences of the searched term in result titles and snippets. Matching SHALL be literal and case-insensitive so a query containing regex metacharacters highlights exactly what the user typed, and the rendered text MUST remain byte-identical to the source text. + +#### Scenario: Term appears in a conversation snippet +- **WHEN** a conversation content match is rendered for a query +- **THEN** each occurrence of the term within the snippet SHALL be emphasized + +#### Scenario: Query contains regex metacharacters +- **WHEN** the query contains characters such as `.`, `$`, or `+` +- **THEN** the palette SHALL highlight those literal characters and MUST NOT treat them as a pattern + +#### Scenario: Query carries a scope prefix +- **WHEN** the query is scoped, such as `@checkout` +- **THEN** highlighting SHALL match the stripped term rather than the prefix + +### Requirement: Deep search never blocks conversation writes +Content search SHALL read item history through a store capability that takes no per-thread write queue and performs no history compaction, so a palette keystroke cannot contend with an in-flight turn or rewrite a thread log. Search SHALL read only user and assistant message text, excluding tool payloads and reasoning. A store without that capability MUST report no matches rather than falling back to the blocking item-load path. + +#### Scenario: Store exposes only the blocking item-load path +- **WHEN** the runtime's session store provides no lock-free search capability +- **THEN** the route SHALL return no matches and MUST NOT call the blocking item-load path + +#### Scenario: Thread log is large enough to trigger compaction +- **WHEN** a candidate thread's item history exceeds the compaction threshold +- **THEN** the search SHALL read it without rewriting it and the log SHALL be unchanged afterwards + +#### Scenario: Query matches a tool payload +- **WHEN** the query appears only inside tool call arguments or tool output +- **THEN** the search SHALL NOT report that thread as a match + +#### Scenario: User types a word that only appears inside a message +- **WHEN** the user opens the palette and types a term that appears in a conversation's message content but not in its title or preview +- **THEN** the palette SHALL list that conversation in a conversation-matches section with a snippet around the matched text +- **AND** activating it SHALL select that thread + +#### Scenario: Match already surfaced by the regular thread source +- **WHEN** a conversation matches both the regular thread source and the content search +- **THEN** the palette SHALL show the conversation once, without a duplicate content-match row + +#### Scenario: Deep search has no matches +- **WHEN** the query matches no conversation content +- **THEN** the palette SHALL show no conversation-matches section and SHALL leave the existing empty state unchanged + +#### Scenario: Scoped queries skip deep search +- **WHEN** the query carries a `>`, `#`, or `/` scope prefix +- **THEN** Kun SHALL NOT run the conversation content search diff --git a/openspec/changes/add-gui-command-palette/tasks.md b/openspec/changes/add-gui-command-palette/tasks.md new file mode 100644 index 000000000..691a3e944 --- /dev/null +++ b/openspec/changes/add-gui-command-palette/tasks.md @@ -0,0 +1,92 @@ +## 1. Shortcut registration and invocation + +- [x] 1.1 Add the `command-palette` command definition to the shared keyboard-shortcut registry with `Ctrl+K` default bindings and a `darwin` platform default of `Meta+K`, plus its label and description keys +- [x] 1.2 Wire the workbench shortcut hook to an open-palette callback, suppressing invocation while the composer slash menu is open, an IME composition is active, or a native dialog owns input +- [x] 1.3 Add the workbench top-bar palette control for pointer discovery + +## 2. Entry model, sources, and ranking + +- [x] 2.1 Define the pure `PaletteEntry` model and activation descriptor union covering routes, settings destinations, threads, workspaces, shortcut commands, slash commands, and extension contributions +- [x] 2.2 Implement the shortcut-command, app-route, settings-destination, and slash-command sources derived from the existing shared registries +- [x] 2.3 Implement the conversation and recent-workspace sources against loaded store state with a bounded scan cap +- [x] 2.4 Implement the extension right-sidebar source from bounded Host-owned manifest metadata, omitting unavailable, disabled, and untrusted contributions and marking unreviewed ones locked +- [x] 2.5 Implement the pure tiered scorer with source-priority, recency, and stable-identity tie-breaking, and the prefix-based scope parser for `>`, `@`, `#`, and `/` + +## 3. Overlay and activation + +- [x] 3.1 Build the palette overlay with combobox/listbox semantics, roving active-option movement, live-region result counts, focus trapping, Escape dismissal, and focus restoration +- [x] 3.2 Implement the single activation dispatcher routing each descriptor through the existing store actions and desktop commands, closing the palette on success +- [x] 3.3 Render disabled entries with their localized reason, make their activation inert, and surface a localized notice when an activated target no longer resolves +- [x] 3.4 Route locked extension entries to the existing permission review without creating a View Session + +## 4. Persistence and copy + +- [x] 4.1 Add the versioned per-workspace recent-selection store with bounded retention, drop-on-read of unresolvable entries, and empty-list fallback for absent or unparsable values +- [x] 4.2 Render the empty-query state as recents followed by default destinations, and reload recents when the active workspace changes +- [x] 4.3 Add English and Simplified Chinese palette copy for labels, source labels, scope hints, disabled reasons, and empty states, leaving remaining locales on English fallback until translated + +## 5. Verification + +- [x] 5.1 Add focused tests for the scorer covering tier ordering, tie-breaking determinism, keyword-only matches, and scope parsing +- [x] 5.2 Add focused tests for source aggregation, extension fail-closed behavior, disabled entries, and unresolvable activation targets +- [x] 5.3 Add focused tests for invocation suppression, accessibility semantics, focus restoration, and recents persistence including workspace switching and bound overflow +- [x] 5.4 Confirm the composer slash-command menu, sidebar conversation search, Write, Design, and SDD behavior are unchanged +- [x] 5.5 Run focused Vitest, typecheck, lint, build, and strict OpenSpec validation + +## 6. Conversation content deep search + +- [x] 6.1 Add the local runtime route `GET /v1/threads/content-search` scanning bounded recent thread message content and returning one snippet per match +- [x] 6.2 Add the renderer runtime client method and debounced hook integration for unprefixed and `@`-scoped queries with duplicate suppression against the regular thread source +- [x] 6.3 Render the conversation-matches section in the overlay and add localized copy in all seven locales +- [x] 6.4 Add runtime route tests, palette mapping/dedupe tests, and strict OpenSpec validation + +## 7. Review remediation + +- [x] 7.1 Stop the empty-query state from rendering the whole catalog beneath the curated groups, and add the missing `useWorkbenchCommandPalette` coverage that let it through +- [x] 7.2 Add the lock-free `SessionStore.searchItemText` capability, implement it in the file store, delegate it through the hybrid and lifecycle-fenced stores, and move the content-search route onto it so a keystroke never takes a write queue or triggers compaction +- [x] 7.3 Scope content search to the active workspace and bound it by wall-clock time as well as thread count +- [x] 7.4 Keep the palette chord working on the Settings route by returning to the originating route before opening +- [x] 7.5 Preserve a pending composer draft on slash activation: feed it to argument-taking commands and otherwise decline with a localized notice +- [x] 7.6 List unbound shortcut commands without a badge, and list one row per settings destination instead of one per alias +- [x] 7.7 Register `command-palette` last so any user-assigned chord wins, consume the chord only when a palette exists, and suppress invocation while a native dialog owns input +- [x] 7.8 Bound stored recents by workspace count, ignore synthetic pointer moves during keyboard navigation, memoize per-entry word splitting, bound thread preview keywords, and report extension activation that could not act +- [x] 7.9 Re-run focused Vitest, typecheck, and lint across the runtime and renderer + +## 8. Deep search delivery + +- [x] 8.1 Allowlist `searchItemText` in the manager session-store proxy, its operation union, and the remote store, so shared-runtime deep search reaches a store that implements it instead of silently reporting no matches +- [x] 8.2 Highlight the matched term in result titles and snippets with literal, case-insensitive segmentation +- [x] 8.3 Surface a pending indicator from the keystroke through the debounce and request, and never render the empty state while a search is still running +- [x] 8.4 Add manager-proxy, highlighting, and pending-state coverage; verify the whole feature against the running app with Playwright + +## 9. Scope trim for review + +- [x] 9.1 Revert the unrelated runtime build-id flavor change in `resolve-kun-binary`, its test, and its `kun-adapter` call site; the latent development-flavor mismatch on `master` belongs in its own change +- [x] 9.2 Drop an unused test-only helper and narrow internal-only constants and helpers to module scope + +## 10. Matching and recall depth + +- [x] 10.1 Add an acronym tier so title initials reach a multi-word destination, ranked below word-boundary and above loose subsequence +- [x] 10.2 Extend highlighting to scattered acronym and subsequence hits so every result shows why it matched +- [x] 10.3 Replace pure recency with frecency, migrating stored recents from version 1 without reshuffling them, and keep activation stamps strictly increasing so same-millisecond bursts still order correctly +- [x] 10.4 Offer an unmatched query as a composer prompt instead of dead-ending, suppressed while a draft is pending or a deep search is still running +- [x] 10.5 Cover each with focused tests and re-verify the whole feature against the running app with Playwright + +## 11. Cross-project recall + +- [x] 11.1 Drop the workspace filter from the content-search route so a term is findable from any project +- [x] 11.2 Badge each conversation match with its project and make that project name searchable, so a result from elsewhere is never mistaken for a local one +- [x] 11.3 Update route, source, and hook coverage for cross-project results + +## 12. Direct actions + +- [x] 12.1 List every configured model with its provider and mark the one in use, switching the composer model on activation +- [x] 12.2 Offer reversible actions on the active conversation (pin/unpin, archive), re-checking the target before applying and deliberately excluding delete +- [x] 12.3 Cover both sources, their dispatch, and the stale-target path with focused tests, and verify each against the running app + +## 13. Browsable opening view + +- [x] 13.1 Render the empty-query state as recents, then quick actions, then the remaining catalog grouped into labeled sections in a stable order +- [x] 13.2 Keep the duplication guard that the original defect required: an entry promoted into recents or quick actions is omitted from its section below +- [x] 13.3 Preview conversations rather than listing every one, since typing reaches the rest including message content +- [x] 13.4 Give the result list room to browse, and add section-heading copy in all seven locales diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 3b0e5d94c..713a20bba 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -174,6 +174,15 @@ async function sharedDefaultModelSelection(): Promise<{ * reconnection, and approval policy decisions. DTO and chat-block * mapping live in `kun-contract.ts` and `kun-mapper.ts`. */ +/** One conversation whose message content matched a deep-search term. */ +export type ThreadContentMatch = { + threadId: string + title: string + workspace: string + snippet: string + updatedAt: string +} + export class KunRuntimeProvider extends KunRuntimeThreadServices implements AgentProvider { readonly id = 'kun' as const readonly displayName = 'Kun' @@ -199,6 +208,35 @@ export class KunRuntimeProvider extends KunRuntimeThreadServices implements Agen } } + /** + * Deep-search conversation message content across recent threads in every + * project. Returns one snippet per matching conversation, most recently + * updated first; each match carries the workspace it belongs to. + */ + async searchThreadContent( + query: string, + options: { limit?: number } = {} + ): Promise { + const normalized = query.trim() + if (!normalized) return [] + const params = new URLSearchParams({ + q: normalized, + limit: String(options.limit ?? 12) + }) + const response = await rendererRuntimeClient.runtimeRequest( + '/v1/threads/content-search?' + params.toString(), + 'GET' + ) + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to search thread content')) + } + const body = readRuntimeJson<{ matches: ThreadContentMatch[] }>( + response.body, + 'runtime returned an invalid thread content search response' + ) + return Array.isArray(body.matches) ? body.matches : [] + } + async listThreads(options: ThreadListOptions = {}): Promise { const page = await this.listThreadsPage(options) return page.threads diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 637ac4c8c..3a74a6108 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -41,6 +41,7 @@ import { } from '../lib/settings-home-paths' import { defaultConversationWorkspaceRoot } from '../lib/workspace-path' import { useChatStore } from '../store/chat-store' +import { useSettingsCommandPaletteShortcut } from '../palette/useSettingsCommandPaletteShortcut' import { DEFAULT_WORKSPACE_ROOT, hasValidPort, @@ -75,6 +76,7 @@ export function SettingsView(): ReactElement { const { t, i18n } = useTranslation('settings') const { t: tCommon } = useTranslation('common') const closeSettings = useChatStore((s) => s.closeSettings) + useSettingsCommandPaletteShortcut(closeSettings) const settingsSection = useChatStore((s) => s.settingsSection) const openCode = useChatStore((s) => s.openCode) const openInitialSetup = useChatStore((s) => s.openInitialSetup) diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 097d3ea0c..1f8c08aa1 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -11,7 +11,16 @@ import { useWorkbenchComposerSubmitController } from './workbench/useWorkbenchCo import { useWorkbenchNavigationController } from './workbench/useWorkbenchNavigationController' import { useWorkbenchDesignRuntime } from './workbench/useWorkbenchDesignRuntime' import { useWorkbenchExecutionSettings } from './workbench/useWorkbenchExecutionSettings' -import { useWorkbenchKeyboardShortcuts } from './workbench/useWorkbenchKeyboardShortcuts' +import { + runWorkbenchShortcutCommand, + useWorkbenchKeyboardShortcuts +} from './workbench/useWorkbenchKeyboardShortcuts' +import { getSlashQuery, COMPOSER_FOCUS_REQUEST_EVENT } from './chat/floating-composer-commands' +import { resolveKeyboardShortcutBindings } from '@shared/keyboard-shortcuts' +import { useKeyboardShortcutSettings } from '../lib/keyboard-shortcut-settings' +import { useCommandPaletteStore } from '../palette/palette-store' +import { useWorkbenchCommandPalette } from '../palette/useWorkbenchCommandPalette' +import { CommandPaletteOverlay } from '../palette/CommandPaletteOverlay' import { useWorkbenchChatStoreState } from './workbench/useWorkbenchChatStoreState' import { useWorkbenchDerivedState } from './workbench/useWorkbenchDerivedState' import { useWorkbenchWriteAssistantRuntime } from './workbench/useWorkbenchWriteAssistantRuntime' @@ -103,12 +112,14 @@ const extensionSurfaceLayoutStorage = { export function Workbench(): ReactElement { const { t, i18n } = useTranslation('common') + const { t: tSettings } = useTranslation('settings') const { threads, threadSearch, showArchivedThreads, activeThreadId, activeThreadRelation, activeThreadParentId, selectThread, createThread, createConversation, blocks, liveReasoning, liveAssistant, error, runtimeErrorDetail, runtimeStatus, busy, currentTurnOrchestration, route, pluginHostRoute, workspaceRoot, conversationWorkspaceRoot, runtimeConnection, + codeWorkspaceRoots, selectWorkspaceRoot, setRoute, openCode, openWrite, openDesign, ensureWriteThreadForWorkspace, ensureDesignThreadForWorkspace, createWriteThread, clearDesignHistory, openSettings, openPlugins, openClaw, openSchedule, openWorkflow, chooseWorkspace, clawChannels, @@ -391,7 +402,15 @@ export function Workbench(): ReactElement { if (!linkedSddDraft) return void openSddRequirementDraftFromHistory(linkedSddDraft) }, [linkedSddDraft, openSddRequirementDraftFromHistory]) - useWorkbenchKeyboardShortcuts({ + const slashMenuOpen = getSlashQuery(input) !== null + const keyboardShortcuts = useKeyboardShortcutSettings() + const shortcutPlatform = typeof window === 'undefined' ? undefined : window.kunGui?.platform + const keyboardShortcutBindings = useMemo( + () => resolveKeyboardShortcutBindings(keyboardShortcuts, shortcutPlatform), + [keyboardShortcuts, shortcutPlatform] + ) + const openPalette = useCommandPaletteStore((state) => state.openPalette) + const shortcutCommandContext = useMemo(() => ({ composerMode, setComposerMode, handleGuiPlanCommand, @@ -403,6 +422,17 @@ export function Workbench(): ReactElement { setUseWorktreePool, worktreeBranch, navigationLocked: designDrawingCreationSubmitting + }), [ + chooseWorkspace, composerMode, createThread, designDrawingCreationSubmitting, + handleGuiPlanCommand, openSettings, setComposerMode, setUseWorktreePool, + toggleTerminal, useWorktreePool, worktreeBranch + ]) + + useWorkbenchKeyboardShortcuts({ + ...shortcutCommandContext, + slashMenuOpen, + openCommandPalette: openPalette, + keyboardShortcutBindings }) const showDevPreviewCard = route === 'chat' && @@ -646,7 +676,111 @@ export function Workbench(): ReactElement { implementDesignInCode, selectCanvasShape, handleDesignHtmlElementAsContext, handleDesignRuntimeQualityFindings, handleDesignQualityRepairRequest }) - return { + switch (target) { + case 'chat': openCodeMode(); break + case 'write': openWriteMode(); break + case 'design': openDesignMode(); break + case 'settings': openSettings(); break + case 'plugins': openPluginsView(); break + case 'extensions': openExtensionsView(); break + case 'claw': openClaw(); break + case 'schedule': openScheduleView(); break + case 'workflow': openWorkflowView(); break + } + }, + settings: (section) => openSettings(section), + thread: (threadId) => { + void openThread(threadId) + }, + workspace: (root) => { + void selectWorkspaceRoot(root) + }, + 'shortcut-command': (commandId) => { + runWorkbenchShortcutCommand(commandId, shortcutCommandContext) + }, + 'slash-command': (_commandId, insertText) => { + const draft = input.trim() + // Never overwrite a pending draft. A trailing space marks the + // argument-taking commands (goal, research, btw), where the draft + // becomes the argument; the rest would be broken by trailing text. + const takesArgument = insertText.endsWith(' ') + if (draft && !takesArgument) { + setError(t('paletteComposerBusy')) + return + } + const focusComposer = (): void => { + window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT)) + } + setInput(draft && takesArgument ? insertText + draft : insertText) + if (route === 'chat') { + focusComposer() + } else { + void openCode() + window.setTimeout(focusComposer, 0) + } + }, + 'extension-view': (entryId) => { + const entry = extensionRightRailItems.find((candidate) => candidate.id === entryId) + if (!entry) return false + return selectRightRailExtension(entry) + }, + compose: (text) => { + const focusComposer = (): void => { + window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT)) + } + // Only offered with an empty composer, so this cannot clobber a draft. + setInput(text) + if (route === 'chat') { + focusComposer() + } else { + void openCode() + window.setTimeout(focusComposer, 0) + } + }, + 'select-model': (modelId, providerId) => { + setComposerModel(modelId, providerId) + }, + 'thread-action': (action, threadId) => { + if (action === 'archive') { + void archiveThread(threadId, true) + return + } + void pinThread(threadId, action === 'pin') + }, + unavailable: () => setError(t('paletteTargetUnavailable')) + }, + t, + tSettings, + route, + workspaceRoot: activeSkillWorkspace, + threads: codeThreads, + codeWorkspaceRoots, + runtimeReady: paletteRuntimeReady, + busy, + activeThreadId, + activeThreadArchived: threads.find((thread) => thread.id === activeThreadId)?.archived === true, + canOpenGoalPanel: paletteRuntimeReady && route !== 'claw', + canCreateNewThread: paletteRuntimeReady && route !== 'claw' && Boolean(activeSkillWorkspace), + hasPlanCommand: route !== 'claw', + hasBtwCommand: route !== 'claw', + hideBtwCommand: false, + hasReviewCommand: route !== 'claw', + skillCommands: runtimeSkills, + disabledSkillIds, + extensionRightRailItems, + shortcutBindings: keyboardShortcutBindings, + hasComposerDraft: input.trim().length > 0, + composerModel, + composerModelGroups, + activeThreadPinned: threads.find((thread) => thread.id === activeThreadId)?.pinned === true + }) + + return <> + + {commandPalette.open ? ( + + ) : null} + } diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx index da9acfafa..8022a631b 100644 --- a/src/renderer/src/components/chat/FloatingComposer.tsx +++ b/src/renderer/src/components/chat/FloatingComposer.tsx @@ -48,6 +48,7 @@ import { } from '../../lib/composer-file-references' import { buildResearchPrompt, + COMPOSER_FOCUS_REQUEST_EVENT, getGoalPanelDraftObjective, getSlashQuery, parseBtwCommand, @@ -393,6 +394,12 @@ export function FloatingComposer({ [codeAgentPresets] ) const draft = useComposerDraft({ input, canCompose: canEditComposer }) + const { focusComposer } = draft + useEffect(() => { + const onFocusRequest = (): void => focusComposer() + window.addEventListener(COMPOSER_FOCUS_REQUEST_EVENT, onFocusRequest) + return () => window.removeEventListener(COMPOSER_FOCUS_REQUEST_EVENT, onFocusRequest) + }, [focusComposer]) const inputHistory = useComposerInputHistory() const slashQuery = getSlashQuery(input) const [composerMenuOpen, setComposerMenuOpen] = useState(false) diff --git a/src/renderer/src/components/chat/WorkbenchTopBar.tsx b/src/renderer/src/components/chat/WorkbenchTopBar.tsx index afc4bbd6d..537721429 100644 --- a/src/renderer/src/components/chat/WorkbenchTopBar.tsx +++ b/src/renderer/src/components/chat/WorkbenchTopBar.tsx @@ -24,6 +24,7 @@ import { Puzzle, RefreshCw, ScanSearch, + Search, Shapes, Terminal } from 'lucide-react' @@ -68,6 +69,7 @@ type WorkbenchTopActionsProps = { onToggleTerminal?: () => void rightWorkspaceExpanded?: boolean onToggleRightWorkspace?: () => void + onOpenCommandPalette?: () => void } const TOPBAR_ICON_CLASS = 'h-4 w-4' @@ -91,7 +93,8 @@ export function WorkbenchTopActions({ terminalOpen = false, onToggleTerminal, rightWorkspaceExpanded = false, - onToggleRightWorkspace + onToggleRightWorkspace, + onOpenCommandPalette }: WorkbenchTopActionsProps): ReactElement { const { t } = useTranslation(['common', 'settings']) const [editors, setEditors] = useState([]) @@ -315,6 +318,18 @@ export function WorkbenchTopActions({ return (
+ {onOpenCommandPalette ? ( + + ) : null} + {guiUpdateAction ? (
diff --git a/src/renderer/src/components/workbench/useWorkbenchChatStoreState.ts b/src/renderer/src/components/workbench/useWorkbenchChatStoreState.ts index 99181bc47..b41b6033b 100644 --- a/src/renderer/src/components/workbench/useWorkbenchChatStoreState.ts +++ b/src/renderer/src/components/workbench/useWorkbenchChatStoreState.ts @@ -26,6 +26,7 @@ export function useWorkbenchChatStoreState() { pluginHostRoute: s.pluginHostRoute, workspaceRoot: s.workspaceRoot, conversationWorkspaceRoot: s.conversationWorkspaceRoot, + codeWorkspaceRoots: s.codeWorkspaceRoots, runtimeConnection: s.runtimeConnection, setRoute: s.setRoute, openCode: s.openCode, @@ -43,6 +44,7 @@ export function useWorkbenchChatStoreState() { openSchedule: s.openSchedule, openWorkflow: s.openWorkflow, chooseWorkspace: s.chooseWorkspace, + selectWorkspaceRoot: s.selectWorkspaceRoot, clawChannels: s.clawChannels, activeClawChannelId: s.activeClawChannelId, selectClawChannel: s.selectClawChannel, diff --git a/src/renderer/src/components/workbench/useWorkbenchExtensionSurfaces.ts b/src/renderer/src/components/workbench/useWorkbenchExtensionSurfaces.ts index 42587c221..845524b97 100644 --- a/src/renderer/src/components/workbench/useWorkbenchExtensionSurfaces.ts +++ b/src/renderer/src/components/workbench/useWorkbenchExtensionSurfaces.ts @@ -197,18 +197,25 @@ export function useWorkbenchExtensionSurfaces({ selectExtensionSurface(view.id) }, [leftSidebarCollapsed, openRightPanelTab, selectExtensionSurface, setRoute, setRightPanelMode, toggleLeftSidebar]) - const selectRightRailExtension = useCallback((entry: ExtensionRightRailViewEntry): void => { + /** + * Returns whether the selection actually opened a View or started permission + * review, so callers such as the command palette can report an unavailable + * target instead of silently doing nothing. + */ + const selectRightRailExtension = useCallback((entry: ExtensionRightRailViewEntry): boolean => { const runnable = workbenchContributionRegistry.get(entry.id, contributionContext) if (isExtensionWorkbenchView(runnable) && runnable.point === 'views.rightSidebar') { openExtensionSurface(runnable) - return + return true + } + if (entry.owner.kind !== 'extension' || entry.workspaceTrusted || !extensionWorkspaceRoot) { + return false } - if (entry.owner.kind !== 'extension' || entry.workspaceTrusted || !extensionWorkspaceRoot) return const extensionId = entry.owner.extensionId const loadContext = extensionContributionLoadContext const currentAuthorization = extensionAuthorizationInFlightRef.current - if (currentAuthorization && sameExtensionContributionLoadContext(currentAuthorization.context, loadContext)) return + if (currentAuthorization && sameExtensionContributionLoadContext(currentAuthorization.context, loadContext)) return true const authorization = { extensionId, context: loadContext } extensionAuthorizationInFlightRef.current = authorization const contextIsCurrent = (): boolean => sameExtensionContributionLoadContext( @@ -244,6 +251,7 @@ export function useWorkbenchExtensionSurfaces({ } } })() + return true }, [ contributionContext, extensionContributionLoadContext, extensionContributionLoadContextRef, extensionWorkspaceRoot, openExtensionSurface, setError, t diff --git a/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.test.ts b/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.test.ts index e4f5dac08..059b69db5 100644 --- a/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.test.ts +++ b/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.test.ts @@ -1,5 +1,51 @@ -import { describe, expect, it } from 'vitest' -import { isWorkbenchNavigationShortcutLocked } from './useWorkbenchKeyboardShortcuts' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + resolveKeyboardShortcutBindings, + type KeyboardShortcutBindingsV1 +} from '@shared/keyboard-shortcuts' +import { + isWorkbenchNavigationShortcutLocked, + resolveWorkbenchShortcutKeyDown, + runWorkbenchShortcutCommand +} from './useWorkbenchKeyboardShortcuts' + +const DARWIN_BINDINGS = resolveKeyboardShortcutBindings(null, 'darwin') + +function keyEvent(overrides: Partial<{ + key: string + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + altKey: boolean + defaultPrevented: boolean + repeat: boolean + isComposing: boolean +}> = {}): { + key: string + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + altKey: boolean + defaultPrevented: boolean + repeat: boolean + isComposing: boolean +} { + return { + key: 'k', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + repeat: false, + isComposing: false, + ...overrides + } +} + +function bindingsWith(bindings: Partial): Required { + return resolveKeyboardShortcutBindings({ bindings }, 'darwin') +} describe('isWorkbenchNavigationShortcutLocked', () => { it('locks navigation commands during a scoped drawing submission', () => { @@ -13,3 +59,189 @@ describe('isWorkbenchNavigationShortcutLocked', () => { expect(isWorkbenchNavigationShortcutLocked('new-chat', false)).toBe(false) }) }) + +describe('resolveWorkbenchShortcutKeyDown', () => { + it('resolves the palette chord to command-palette by default', () => { + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'k', metaKey: true }), DARWIN_BINDINGS, { + slashMenuOpen: false + }) + ).toBe('command-palette') + }) + + it('yields while the composer slash menu is open and leaves the event unconsumed', () => { + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'k', metaKey: true }), DARWIN_BINDINGS, { + slashMenuOpen: true + }) + ).toBeNull() + }) + + it('suppresses repeated, composing, and default-prevented events', () => { + expect( + resolveWorkbenchShortcutKeyDown( + keyEvent({ key: 'k', metaKey: true, repeat: true }), + DARWIN_BINDINGS, + { slashMenuOpen: false } + ) + ).toBeNull() + expect( + resolveWorkbenchShortcutKeyDown( + keyEvent({ key: 'k', metaKey: true, isComposing: true }), + DARWIN_BINDINGS, + { slashMenuOpen: false } + ) + ).toBeNull() + expect( + resolveWorkbenchShortcutKeyDown( + keyEvent({ key: 'k', metaKey: true, defaultPrevented: true }), + DARWIN_BINDINGS, + { slashMenuOpen: false } + ) + ).toBeNull() + }) + + it('honors a user binding that claims the palette default chord', () => { + const rebound = bindingsWith({ 'new-chat': ['Meta+K'] }) + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'k', metaKey: true }), rebound, { + slashMenuOpen: false + }) + ).toBe('new-chat') + }) + + it('yields the palette chord to any command, not just earlier ones', () => { + // The palette is registered last precisely so a user binding wins + // regardless of where the other command sits in the registry. + for (const commandId of ['close', 'toggle-maximize', 'minimize'] as const) { + const rebound = bindingsWith({ [commandId]: ['Meta+K'] }) + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'k', metaKey: true }), rebound, { + slashMenuOpen: false + }) + ).toBe(commandId) + } + }) + + it('yields while a native dialog owns input', () => { + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'k', metaKey: true }), DARWIN_BINDINGS, { + slashMenuOpen: false, + nativeDialogOpen: true + }) + ).toBeNull() + // Suppression is palette-only; other chords still resolve. + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'n', ctrlKey: true }), DARWIN_BINDINGS, { + slashMenuOpen: false, + nativeDialogOpen: true + }) + ).toBe('new-chat') + }) + + it('honors a rebound palette chord', () => { + const rebound = bindingsWith({ 'command-palette': ['Meta+P'] }) + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'p', metaKey: true }), rebound, { + slashMenuOpen: false + }) + ).toBe('command-palette') + expect( + resolveWorkbenchShortcutKeyDown(keyEvent({ key: 'k', metaKey: true }), rebound, { + slashMenuOpen: false + }) + ).toBeNull() + }) +}) + +describe('runWorkbenchShortcutCommand', () => { + beforeEach(() => { + vi.stubGlobal('window', { + kunGui: { runDesktopCommand: vi.fn() } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('toggles plan mode and invokes the plan command when enabling', () => { + const setComposerMode = vi.fn() + const handleGuiPlanCommand = vi.fn() + runWorkbenchShortcutCommand('toggle-plan-mode', { + composerMode: 'agent', + setComposerMode, + handleGuiPlanCommand, + createThread: vi.fn(), + chooseWorkspace: vi.fn(), + toggleTerminal: vi.fn(), + openSettings: vi.fn(), + useWorktreePool: false, + setUseWorktreePool: vi.fn(), + worktreeBranch: '' + }) + expect(setComposerMode).toHaveBeenCalledWith('plan') + expect(handleGuiPlanCommand).toHaveBeenCalledTimes(1) + }) + + it('creates a thread with worktree options and clears the pool flag', () => { + const createThread = vi.fn() + const setUseWorktreePool = vi.fn() + runWorkbenchShortcutCommand('new-chat', { + composerMode: 'agent', + setComposerMode: vi.fn(), + handleGuiPlanCommand: vi.fn(), + createThread, + chooseWorkspace: vi.fn(), + toggleTerminal: vi.fn(), + openSettings: vi.fn(), + useWorktreePool: true, + setUseWorktreePool, + worktreeBranch: 'feature/x' + }) + expect(createThread).toHaveBeenCalledWith({ useWorktreePool: true, worktreeBranch: 'feature/x' }) + expect(setUseWorktreePool).toHaveBeenCalledWith(false) + }) + + it('ignores navigation-locked commands', () => { + const createThread = vi.fn() + const chooseWorkspace = vi.fn() + const openSettings = vi.fn() + const context = { + composerMode: 'agent' as const, + setComposerMode: vi.fn(), + handleGuiPlanCommand: vi.fn(), + createThread, + chooseWorkspace, + toggleTerminal: vi.fn(), + openSettings, + useWorktreePool: false, + setUseWorktreePool: vi.fn(), + worktreeBranch: '', + navigationLocked: true + } + runWorkbenchShortcutCommand('new-chat', context) + runWorkbenchShortcutCommand('choose-workspace', context) + runWorkbenchShortcutCommand('settings', context) + expect(createThread).not.toHaveBeenCalled() + expect(chooseWorkspace).not.toHaveBeenCalled() + expect(openSettings).not.toHaveBeenCalled() + }) + + it('runs window-level desktop commands', () => { + const windowApi = window as unknown as { kunGui: { runDesktopCommand: ReturnType } } + runWorkbenchShortcutCommand('quit', { + composerMode: 'agent', + setComposerMode: vi.fn(), + handleGuiPlanCommand: vi.fn(), + createThread: vi.fn(), + chooseWorkspace: vi.fn(), + toggleTerminal: vi.fn(), + openSettings: vi.fn(), + useWorktreePool: false, + setUseWorktreePool: vi.fn(), + worktreeBranch: '' + }) + expect(windowApi.kunGui.runDesktopCommand).toHaveBeenCalledWith('quit') + }) +}) diff --git a/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.ts b/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.ts index 63489227d..387b42e93 100644 --- a/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.ts +++ b/src/renderer/src/components/workbench/useWorkbenchKeyboardShortcuts.ts @@ -4,9 +4,12 @@ import { findKeyboardShortcutCommand, keyboardEventToShortcut, resolveKeyboardShortcutBindings, - type KeyboardShortcutCommandId + type KeyboardShortcutBindingsV1, + type KeyboardShortcutCommandId, + type KeyboardShortcutEventLike } from '@shared/keyboard-shortcuts' import { useKeyboardShortcutSettings } from '../../lib/keyboard-shortcut-settings' +import { isNativeDialogOpen } from '../../lib/native-dialog-activity' const DESKTOP_SHORTCUT_COMMANDS: Partial> = { quit: 'quit', @@ -39,7 +42,7 @@ export function isWorkbenchNavigationShortcutLocked( ) } -type UseWorkbenchKeyboardShortcutsInput = { +export type WorkbenchShortcutCommandContext = { composerMode: ComposerMode setComposerMode: (mode: ComposerMode) => void handleGuiPlanCommand: () => void | Promise @@ -53,6 +56,87 @@ type UseWorkbenchKeyboardShortcutsInput = { navigationLocked?: boolean } +/** + * Runs a workbench shortcut command through the exact same behavior the + * keydown handler uses. The command palette dispatches its + * 'shortcut-command' entries through this function so activation is + * identical to pressing the chord. + */ +export function runWorkbenchShortcutCommand( + commandId: KeyboardShortcutCommandId, + context: WorkbenchShortcutCommandContext +): void { + if (isWorkbenchNavigationShortcutLocked(commandId, context.navigationLocked === true)) return + + if (commandId === 'toggle-plan-mode') { + if (context.composerMode === 'plan') { + context.setComposerMode('agent') + } else { + context.setComposerMode('plan') + void context.handleGuiPlanCommand() + } + return + } + if (commandId === 'new-chat') { + void context.createThread({ useWorktreePool: context.useWorktreePool, worktreeBranch: context.worktreeBranch }) + if (context.useWorktreePool) context.setUseWorktreePool(false) + return + } + if (commandId === 'choose-workspace') { + void context.chooseWorkspace() + return + } + if (commandId === 'toggle-terminal') { + context.toggleTerminal() + return + } + if (commandId === 'settings') { + context.openSettings() + return + } + + const desktopCommand = DESKTOP_SHORTCUT_COMMANDS[commandId] + if (desktopCommand && typeof window.kunGui?.runDesktopCommand === 'function') { + void window.kunGui.runDesktopCommand(desktopCommand) + } +} + +export type WorkbenchShortcutKeyDownEvent = KeyboardShortcutEventLike & { + defaultPrevented: boolean + repeat: boolean + isComposing: boolean +} + +/** + * Resolves a keydown event to the shortcut command it should run, applying + * invocation suppression. Default-prevented, repeated, and IME-composing + * events never resolve. The command palette additionally yields while the + * composer slash-command menu is open or a native dialog owns input, leaving + * the event unconsumed in both cases. + */ +export function resolveWorkbenchShortcutKeyDown( + event: WorkbenchShortcutKeyDownEvent, + bindings: Required, + options: { slashMenuOpen: boolean; nativeDialogOpen?: boolean } +): KeyboardShortcutCommandId | null { + if (event.defaultPrevented || event.repeat || event.isComposing) return null + const commandId = findKeyboardShortcutCommand(bindings, keyboardEventToShortcut(event)) + if (!commandId) return null + if (commandId === 'command-palette' && (options.slashMenuOpen || options.nativeDialogOpen)) { + return null + } + return commandId +} + +type UseWorkbenchKeyboardShortcutsInput = WorkbenchShortcutCommandContext & { + /** Suppresses command-palette invocation while the composer slash menu is open. */ + slashMenuOpen?: boolean + /** Opens the palette; omitted in environments without the palette surface. */ + openCommandPalette?: () => void + /** Pre-resolved bindings shared with other consumers (e.g. the palette). */ + keyboardShortcutBindings?: Required +} + export function useWorkbenchKeyboardShortcuts({ composerMode, setComposerMode, @@ -64,61 +148,50 @@ export function useWorkbenchKeyboardShortcuts({ useWorktreePool, setUseWorktreePool, worktreeBranch, - navigationLocked = false + navigationLocked = false, + slashMenuOpen = false, + openCommandPalette, + keyboardShortcutBindings: providedBindings }: UseWorkbenchKeyboardShortcutsInput): void { const keyboardShortcuts = useKeyboardShortcutSettings() const shortcutPlatform = typeof window === 'undefined' ? undefined : window.kunGui?.platform - const keyboardShortcutBindings = useMemo( + const resolvedBindings = useMemo( () => resolveKeyboardShortcutBindings(keyboardShortcuts, shortcutPlatform), [keyboardShortcuts, shortcutPlatform] ) + const keyboardShortcutBindings = providedBindings ?? resolvedBindings useEffect(() => { - const runDesktopShortcut = (command: DesktopCommand): void => { - if (typeof window.kunGui?.runDesktopCommand !== 'function') return - void window.kunGui.runDesktopCommand(command) - } - const onKeyDown = (event: KeyboardEvent): void => { - if (event.defaultPrevented || event.repeat || event.isComposing) return - const commandId = findKeyboardShortcutCommand( - keyboardShortcutBindings, - keyboardEventToShortcut(event) - ) + const commandId = resolveWorkbenchShortcutKeyDown(event, keyboardShortcutBindings, { + slashMenuOpen, + nativeDialogOpen: isNativeDialogOpen() + }) if (!commandId) return - event.preventDefault() - - if (isWorkbenchNavigationShortcutLocked(commandId, navigationLocked)) return - if (commandId === 'toggle-plan-mode') { - if (composerMode === 'plan') { - setComposerMode('agent') - } else { - setComposerMode('plan') - void handleGuiPlanCommand() - } - return - } - if (commandId === 'new-chat') { - void createThread({ useWorktreePool, worktreeBranch }) - if (useWorktreePool) setUseWorktreePool(false) - return - } - if (commandId === 'choose-workspace') { - void chooseWorkspace() - return - } - if (commandId === 'toggle-terminal') { - toggleTerminal() - return - } - if (commandId === 'settings') { - openSettings() + if (commandId === 'command-palette') { + // Only consume the chord when there is a palette to open, so a build + // without the surface leaves the key to whatever else may handle it. + if (!openCommandPalette) return + event.preventDefault() + openCommandPalette() return } + event.preventDefault() - const desktopCommand = DESKTOP_SHORTCUT_COMMANDS[commandId] - if (desktopCommand) runDesktopShortcut(desktopCommand) + runWorkbenchShortcutCommand(commandId, { + composerMode, + setComposerMode, + handleGuiPlanCommand, + createThread, + chooseWorkspace, + toggleTerminal, + openSettings, + useWorktreePool, + setUseWorktreePool, + worktreeBranch, + navigationLocked + }) } window.addEventListener('keydown', onKeyDown, true) @@ -130,9 +203,11 @@ export function useWorkbenchKeyboardShortcuts({ handleGuiPlanCommand, keyboardShortcutBindings, navigationLocked, + openCommandPalette, openSettings, setComposerMode, setUseWorktreePool, + slashMenuOpen, toggleTerminal, useWorktreePool, worktreeBranch diff --git a/src/renderer/src/lib/native-dialog-activity.ts b/src/renderer/src/lib/native-dialog-activity.ts new file mode 100644 index 000000000..5a79a1751 --- /dev/null +++ b/src/renderer/src/lib/native-dialog-activity.ts @@ -0,0 +1,23 @@ +/** + * Tracks whether a Main-owned native dialog currently owns input. + * + * Native dialogs are modal to the window, so renderer surfaces that open on a + * global chord must stay closed while one is up. The renderer cannot observe + * Main's dialog queue directly, so callers that trigger a native dialog wrap + * the call and this module answers the question for them. + */ +let openNativeDialogs = 0 + +export function isNativeDialogOpen(): boolean { + return openNativeDialogs > 0 +} + +/** Marks a native dialog as owning input for the lifetime of `operation`. */ +export async function withNativeDialog(operation: () => Promise): Promise { + openNativeDialogs += 1 + try { + return await operation() + } finally { + openNativeDialogs = Math.max(0, openNativeDialogs - 1) + } +} diff --git a/src/renderer/src/locales/en/common.ts b/src/renderer/src/locales/en/common.ts index c9b221e95..3c5deb614 100644 --- a/src/renderer/src/locales/en/common.ts +++ b/src/renderer/src/locales/en/common.ts @@ -8,6 +8,7 @@ import agentsGraph from './common/agents-graph.json' import codePersonas from './common/code-personas.json' import workWhiteboard from './common/work-whiteboard.json' import sidebar from './common/sidebar.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -20,6 +21,7 @@ const common = { ...codePersonas, ...workWhiteboard, ...sidebar, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/en/common/command-palette.json b/src/renderer/src/locales/en/common/command-palette.json new file mode 100644 index 000000000..4a4e435a5 --- /dev/null +++ b/src/renderer/src/locales/en/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "Command palette", + "paletteInputLabel": "Search commands, conversations, and settings", + "palettePlaceholder": "Type a command or search…", + "paletteOpenTooltip": "Command palette", + "paletteResultsLabel": "Results", + "paletteScopeCommands": "Commands", + "paletteScopeConversations": "Conversations", + "paletteScopeSettings": "Settings", + "paletteScopeSlash": "Slash commands", + "paletteSectionRecent": "Recent", + "paletteSectionDefault": "Quick actions", + "paletteSectionCommands": "Commands", + "paletteSectionNavigation": "Navigation", + "paletteSectionSettings": "Settings", + "paletteSectionModels": "Models", + "paletteSectionConversations": "Conversations", + "paletteSectionProjects": "Projects", + "paletteSectionExtensions": "Extensions", + "paletteSourceCommand": "Command", + "paletteSourceRoute": "Navigation", + "paletteSourceSettings": "Settings", + "paletteSourceThread": "Conversation", + "paletteSourceWorkspace": "Workspace", + "paletteSourceSkill": "Skill", + "paletteSourceExtension": "Extension", + "paletteSourceAction": "Action", + "paletteSourceModel": "Model", + "paletteModelActiveBadge": "Active", + "paletteActionPinThread": "Pin this conversation", + "paletteActionUnpinThread": "Unpin this conversation", + "paletteActionArchiveThread": "Archive this conversation", + "paletteActionThreadScope": "Applies to the conversation you are in.", + "paletteActionsSection": "Actions", + "paletteComposeWithQuery": "Start a chat about “{{query}}”", + "paletteComposeWithQueryDesc": "Put what you typed in the message box.", + "paletteEmpty": "No matching results.", + "paletteEmptyScoped": "No matching results in {{scope}}.", + "paletteResultCount_one": "{{count}} result", + "paletteResultCount_other": "{{count}} results", + "paletteTargetUnavailable": "That item is no longer available.", + "paletteComposerBusy": "Clear the message box before running that command.", + "paletteLockedBadge": "Review", + "paletteLockedReason": "Requires workspace permission review", + "paletteDisabledDefault": "Unavailable in the current context", + "paletteUntitledThread": "Untitled conversation", + "paletteNavigationHint": "Navigate with arrow keys, Enter to select", + "paletteEscapeHint": "Esc to close", + "paletteContentMatchBadge": "Match", + "paletteContentSearchSection": "Conversation matches", + "paletteSearchingConversations": "Searching conversations…", + "extensions": "Extensions" +} diff --git a/src/renderer/src/locales/en/settings/navigation-providers.json b/src/renderer/src/locales/en/settings/navigation-providers.json index d03a40a80..8b74a8c67 100644 --- a/src/renderer/src/locales/en/settings/navigation-providers.json +++ b/src/renderer/src/locales/en/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "Open or close the terminal panel.", "shortcutSettings": "Settings", "shortcutSettingsDesc": "Open settings.", + "shortcutCommandPalette": "Command palette", + "shortcutCommandPaletteDesc": "Open the command palette to search commands, conversations, and settings.", "shortcutQuit": "Quit", "shortcutQuitDesc": "Quit the app.", "shortcutUndo": "Undo", diff --git a/src/renderer/src/locales/hi/common.ts b/src/renderer/src/locales/hi/common.ts index e1476b267..36294577d 100644 --- a/src/renderer/src/locales/hi/common.ts +++ b/src/renderer/src/locales/hi/common.ts @@ -5,6 +5,7 @@ import commandsSdd from './common/commands-sdd.json' import sddFrameworks from './common/sdd-frameworks.json' import sddMcp from './common/sdd-mcp.json' import agentsGraph from './common/agents-graph.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -14,6 +15,7 @@ const common = { ...sddFrameworks, ...sddMcp, ...agentsGraph, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/hi/common/command-palette.json b/src/renderer/src/locales/hi/common/command-palette.json new file mode 100644 index 000000000..f27f7d870 --- /dev/null +++ b/src/renderer/src/locales/hi/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "कमांड पैलेट", + "paletteInputLabel": "कमांड, वार्तालाप और सेटिंग खोजें", + "palettePlaceholder": "कमांड टाइप करें या खोजें…", + "paletteOpenTooltip": "कमांड पैलेट", + "paletteResultsLabel": "परिणाम", + "paletteScopeCommands": "कमांड", + "paletteScopeConversations": "वार्तालाप", + "paletteScopeSettings": "सेटिंग", + "paletteScopeSlash": "स्लैश कमांड", + "paletteSectionRecent": "हाल में", + "paletteSectionDefault": "त्वरित क्रियाएँ", + "paletteSectionCommands": "कमांड", + "paletteSectionNavigation": "नेविगेशन", + "paletteSectionSettings": "सेटिंग्स", + "paletteSectionModels": "मॉडल", + "paletteSectionConversations": "बातचीत", + "paletteSectionProjects": "प्रोजेक्ट", + "paletteSectionExtensions": "एक्सटेंशन", + "paletteSourceCommand": "कमांड", + "paletteSourceRoute": "नेविगेशन", + "paletteSourceSettings": "सेटिंग", + "paletteSourceThread": "वार्तालाप", + "paletteSourceWorkspace": "कार्यक्षेत्र", + "paletteSourceSkill": "कौशल", + "paletteSourceExtension": "एक्सटेंशन", + "paletteSourceAction": "क्रिया", + "paletteSourceModel": "मॉडल", + "paletteModelActiveBadge": "सक्रिय", + "paletteActionPinThread": "इस बातचीत को पिन करें", + "paletteActionUnpinThread": "इस बातचीत से पिन हटाएँ", + "paletteActionArchiveThread": "इस बातचीत को संग्रह करें", + "paletteActionThreadScope": "आप जिस बातचीत में हैं उस पर लागू होता है।", + "paletteActionsSection": "क्रियाएँ", + "paletteComposeWithQuery": "“{{query}}” पर चैट शुरू करें", + "paletteComposeWithQueryDesc": "आपने जो लिखा वह संदेश बॉक्स में रखें।", + "paletteEmpty": "कोई मिलता-जुलता परिणाम नहीं।", + "paletteEmptyScoped": "{{scope}} में कोई मिलता-जुलता परिणाम नहीं।", + "paletteResultCount_one": "{{count}} परिणाम", + "paletteResultCount_other": "{{count}} परिणाम", + "paletteTargetUnavailable": "यह आइटम अब उपलब्ध नहीं है।", + "paletteComposerBusy": "यह कमांड चलाने से पहले संदेश बॉक्स खाली करें।", + "paletteLockedBadge": "समीक्षा", + "paletteLockedReason": "कार्यक्षेत्र अनुमति समीक्षा आवश्यक है", + "paletteDisabledDefault": "वर्तमान संदर्भ में अनुपलब्ध", + "paletteUntitledThread": "शीर्षकहीन वार्तालाप", + "paletteNavigationHint": "तीर कुंजियों से नेविगेट करें, Enter से चुनें", + "paletteEscapeHint": "बंद करने के लिए Esc", + "paletteContentMatchBadge": "मिलान", + "paletteContentSearchSection": "वार्तालाप मिलान", + "paletteSearchingConversations": "बातचीत खोजी जा रही है…", + "extensions": "एक्सटेंशन" +} diff --git a/src/renderer/src/locales/hi/settings/navigation-providers.json b/src/renderer/src/locales/hi/settings/navigation-providers.json index c8fcccf09..3f849cf8d 100644 --- a/src/renderer/src/locales/hi/settings/navigation-providers.json +++ b/src/renderer/src/locales/hi/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "टर्मिनल पैनल खोलें या बंद करें.", "shortcutSettings": "सेटिंग्स", "shortcutSettingsDesc": "सेटिंग्स खोलें.", + "shortcutCommandPalette": "कमांड पैलेट", + "shortcutCommandPaletteDesc": "कमांड, वार्तालाप और सेटिंग खोजने के लिए कमांड पैलेट खोलें।", "shortcutQuit": "छोड़ो", "shortcutQuitDesc": "ऐप छोड़ें.", "shortcutUndo": "पूर्ववत करें", diff --git a/src/renderer/src/locales/ja/common.ts b/src/renderer/src/locales/ja/common.ts index e1476b267..36294577d 100644 --- a/src/renderer/src/locales/ja/common.ts +++ b/src/renderer/src/locales/ja/common.ts @@ -5,6 +5,7 @@ import commandsSdd from './common/commands-sdd.json' import sddFrameworks from './common/sdd-frameworks.json' import sddMcp from './common/sdd-mcp.json' import agentsGraph from './common/agents-graph.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -14,6 +15,7 @@ const common = { ...sddFrameworks, ...sddMcp, ...agentsGraph, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/ja/common/command-palette.json b/src/renderer/src/locales/ja/common/command-palette.json new file mode 100644 index 000000000..d5b5ba30f --- /dev/null +++ b/src/renderer/src/locales/ja/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "コマンドパレット", + "paletteInputLabel": "コマンド、会話、設定を検索", + "palettePlaceholder": "コマンドを入力または検索…", + "paletteOpenTooltip": "コマンドパレット", + "paletteResultsLabel": "結果", + "paletteScopeCommands": "コマンド", + "paletteScopeConversations": "会話", + "paletteScopeSettings": "設定", + "paletteScopeSlash": "スラッシュコマンド", + "paletteSectionRecent": "最近", + "paletteSectionDefault": "クイックアクション", + "paletteSectionCommands": "コマンド", + "paletteSectionNavigation": "ナビゲーション", + "paletteSectionSettings": "設定", + "paletteSectionModels": "モデル", + "paletteSectionConversations": "会話", + "paletteSectionProjects": "プロジェクト", + "paletteSectionExtensions": "拡張機能", + "paletteSourceCommand": "コマンド", + "paletteSourceRoute": "ナビゲーション", + "paletteSourceSettings": "設定", + "paletteSourceThread": "会話", + "paletteSourceWorkspace": "ワークスペース", + "paletteSourceSkill": "スキル", + "paletteSourceExtension": "拡張機能", + "paletteSourceAction": "アクション", + "paletteSourceModel": "モデル", + "paletteModelActiveBadge": "使用中", + "paletteActionPinThread": "この会話をピン留め", + "paletteActionUnpinThread": "この会話のピン留めを解除", + "paletteActionArchiveThread": "この会話をアーカイブ", + "paletteActionThreadScope": "現在の会話に適用されます。", + "paletteActionsSection": "アクション", + "paletteComposeWithQuery": "「{{query}}」について会話を始める", + "paletteComposeWithQueryDesc": "入力した内容を入力欄に配置します。", + "paletteEmpty": "一致する結果がありません。", + "paletteEmptyScoped": "{{scope}}に一致する結果がありません。", + "paletteResultCount_one": "{{count}}件の結果", + "paletteResultCount_other": "{{count}}件の結果", + "paletteTargetUnavailable": "この項目は利用できなくなりました。", + "paletteComposerBusy": "このコマンドを実行する前に入力欄を空にしてください。", + "paletteLockedBadge": "確認", + "paletteLockedReason": "ワークスペース権限の確認が必要です", + "paletteDisabledDefault": "現在の状況では利用できません", + "paletteUntitledThread": "無題の会話", + "paletteNavigationHint": "矢印キーで移動、Enterで選択", + "paletteEscapeHint": "Escで閉じる", + "paletteContentMatchBadge": "一致", + "paletteContentSearchSection": "会話の一致", + "paletteSearchingConversations": "会話を検索しています…", + "extensions": "拡張機能" +} diff --git a/src/renderer/src/locales/ja/settings/navigation-providers.json b/src/renderer/src/locales/ja/settings/navigation-providers.json index 3e5879313..71068977c 100644 --- a/src/renderer/src/locales/ja/settings/navigation-providers.json +++ b/src/renderer/src/locales/ja/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "端子パネルを開閉します。", "shortcutSettings": "設定", "shortcutSettingsDesc": "設定を開きます。", + "shortcutCommandPalette": "コマンドパレット", + "shortcutCommandPaletteDesc": "コマンドパレットを開いて、コマンド、会話、設定を検索します。", "shortcutQuit": "やめる", "shortcutQuitDesc": "アプリを終了します。", "shortcutUndo": "元に戻す", diff --git a/src/renderer/src/locales/ko/common.ts b/src/renderer/src/locales/ko/common.ts index e1476b267..36294577d 100644 --- a/src/renderer/src/locales/ko/common.ts +++ b/src/renderer/src/locales/ko/common.ts @@ -5,6 +5,7 @@ import commandsSdd from './common/commands-sdd.json' import sddFrameworks from './common/sdd-frameworks.json' import sddMcp from './common/sdd-mcp.json' import agentsGraph from './common/agents-graph.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -14,6 +15,7 @@ const common = { ...sddFrameworks, ...sddMcp, ...agentsGraph, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/ko/common/command-palette.json b/src/renderer/src/locales/ko/common/command-palette.json new file mode 100644 index 000000000..b8a092802 --- /dev/null +++ b/src/renderer/src/locales/ko/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "명령 팔레트", + "paletteInputLabel": "명령, 대화, 설정 검색", + "palettePlaceholder": "명령 입력 또는 검색…", + "paletteOpenTooltip": "명령 팔레트", + "paletteResultsLabel": "결과", + "paletteScopeCommands": "명령", + "paletteScopeConversations": "대화", + "paletteScopeSettings": "설정", + "paletteScopeSlash": "슬래시 명령", + "paletteSectionRecent": "최근", + "paletteSectionDefault": "빠른 작업", + "paletteSectionCommands": "명령", + "paletteSectionNavigation": "탐색", + "paletteSectionSettings": "설정", + "paletteSectionModels": "모델", + "paletteSectionConversations": "대화", + "paletteSectionProjects": "프로젝트", + "paletteSectionExtensions": "확장", + "paletteSourceCommand": "명령", + "paletteSourceRoute": "탐색", + "paletteSourceSettings": "설정", + "paletteSourceThread": "대화", + "paletteSourceWorkspace": "작업 영역", + "paletteSourceSkill": "스킬", + "paletteSourceExtension": "확장", + "paletteSourceAction": "작업", + "paletteSourceModel": "모델", + "paletteModelActiveBadge": "사용 중", + "paletteActionPinThread": "이 대화 고정", + "paletteActionUnpinThread": "이 대화 고정 해제", + "paletteActionArchiveThread": "이 대화 보관", + "paletteActionThreadScope": "현재 대화에 적용됩니다.", + "paletteActionsSection": "작업", + "paletteComposeWithQuery": "“{{query}}”에 대해 대화 시작", + "paletteComposeWithQueryDesc": "입력한 내용을 입력창에 넣습니다.", + "paletteEmpty": "일치하는 결과가 없습니다.", + "paletteEmptyScoped": "{{scope}}에서 일치하는 결과가 없습니다.", + "paletteResultCount_one": "결과 {{count}}개", + "paletteResultCount_other": "결과 {{count}}개", + "paletteTargetUnavailable": "이 항목은 더 이상 사용할 수 없습니다.", + "paletteComposerBusy": "이 명령을 실행하기 전에 입력창을 비워 주세요.", + "paletteLockedBadge": "검토", + "paletteLockedReason": "작업 영역 권한 검토 필요", + "paletteDisabledDefault": "현재 상황에서는 사용할 수 없음", + "paletteUntitledThread": "제목 없는 대화", + "paletteNavigationHint": "화살표 키로 이동, Enter로 선택", + "paletteEscapeHint": "Esc로 닫기", + "paletteContentMatchBadge": "일치", + "paletteContentSearchSection": "대화 일치", + "paletteSearchingConversations": "대화를 검색하는 중…", + "extensions": "확장" +} diff --git a/src/renderer/src/locales/ko/settings/navigation-providers.json b/src/renderer/src/locales/ko/settings/navigation-providers.json index adaaa0a63..c9d5ec336 100644 --- a/src/renderer/src/locales/ko/settings/navigation-providers.json +++ b/src/renderer/src/locales/ko/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "터미널 패널을 열거나 닫습니다.", "shortcutSettings": "설정", "shortcutSettingsDesc": "설정을 엽니다.", + "shortcutCommandPalette": "명령 팔레트", + "shortcutCommandPaletteDesc": "명령, 대화, 설정을 검색할 수 있는 명령 팔레트를 엽니다.", "shortcutQuit": "종료", "shortcutQuitDesc": "앱을 종료하세요.", "shortcutUndo": "실행 취소", diff --git a/src/renderer/src/locales/ru/common.ts b/src/renderer/src/locales/ru/common.ts index e1476b267..36294577d 100644 --- a/src/renderer/src/locales/ru/common.ts +++ b/src/renderer/src/locales/ru/common.ts @@ -5,6 +5,7 @@ import commandsSdd from './common/commands-sdd.json' import sddFrameworks from './common/sdd-frameworks.json' import sddMcp from './common/sdd-mcp.json' import agentsGraph from './common/agents-graph.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -14,6 +15,7 @@ const common = { ...sddFrameworks, ...sddMcp, ...agentsGraph, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/ru/common/command-palette.json b/src/renderer/src/locales/ru/common/command-palette.json new file mode 100644 index 000000000..e42ce1330 --- /dev/null +++ b/src/renderer/src/locales/ru/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "Палитра команд", + "paletteInputLabel": "Поиск команд, бесед и настроек", + "palettePlaceholder": "Введите команду или запрос…", + "paletteOpenTooltip": "Палитра команд", + "paletteResultsLabel": "Результаты", + "paletteScopeCommands": "Команды", + "paletteScopeConversations": "Беседы", + "paletteScopeSettings": "Настройки", + "paletteScopeSlash": "Слеш-команды", + "paletteSectionRecent": "Недавние", + "paletteSectionDefault": "Быстрые действия", + "paletteSectionCommands": "Команды", + "paletteSectionNavigation": "Навигация", + "paletteSectionSettings": "Настройки", + "paletteSectionModels": "Модели", + "paletteSectionConversations": "Беседы", + "paletteSectionProjects": "Проекты", + "paletteSectionExtensions": "Расширения", + "paletteSourceCommand": "Команда", + "paletteSourceRoute": "Навигация", + "paletteSourceSettings": "Настройки", + "paletteSourceThread": "Беседа", + "paletteSourceWorkspace": "Рабочая область", + "paletteSourceSkill": "Навык", + "paletteSourceExtension": "Расширение", + "paletteSourceAction": "Действие", + "paletteSourceModel": "Модель", + "paletteModelActiveBadge": "Текущая", + "paletteActionPinThread": "Закрепить эту беседу", + "paletteActionUnpinThread": "Открепить эту беседу", + "paletteActionArchiveThread": "Архивировать эту беседу", + "paletteActionThreadScope": "Применяется к текущей беседе.", + "paletteActionsSection": "Действия", + "paletteComposeWithQuery": "Начать чат про «{{query}}»", + "paletteComposeWithQueryDesc": "Поместить введённый текст в поле ввода.", + "paletteEmpty": "Ничего не найдено.", + "paletteEmptyScoped": "В разделе «{{scope}}» ничего не найдено.", + "paletteResultCount_one": "{{count}} результат", + "paletteResultCount_other": "{{count}} результатов", + "paletteTargetUnavailable": "Этот элемент больше недоступен.", + "paletteComposerBusy": "Очистите поле ввода перед запуском этой команды.", + "paletteLockedBadge": "Проверка", + "paletteLockedReason": "Требуется проверка разрешений рабочей области", + "paletteDisabledDefault": "Недоступно в текущем контексте", + "paletteUntitledThread": "Беседа без названия", + "paletteNavigationHint": "Стрелки — навигация, Enter — выбор", + "paletteEscapeHint": "Esc — закрыть", + "paletteContentMatchBadge": "Совпадение", + "paletteContentSearchSection": "Совпадения в беседах", + "paletteSearchingConversations": "Поиск по беседам…", + "extensions": "Расширения" +} diff --git a/src/renderer/src/locales/ru/settings/navigation-providers.json b/src/renderer/src/locales/ru/settings/navigation-providers.json index 9ee8f10ca..966441415 100644 --- a/src/renderer/src/locales/ru/settings/navigation-providers.json +++ b/src/renderer/src/locales/ru/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "Открыть или закрыть панель терминала.", "shortcutSettings": "Настройки", "shortcutSettingsDesc": "Открыть настройки.", + "shortcutCommandPalette": "Палитра команд", + "shortcutCommandPaletteDesc": "Открыть палитру команд для поиска команд, бесед и настроек.", "shortcutQuit": "Выйти", "shortcutQuitDesc": "Выйти из приложения.", "shortcutUndo": "Отменить", diff --git a/src/renderer/src/locales/th/common.ts b/src/renderer/src/locales/th/common.ts index e1476b267..36294577d 100644 --- a/src/renderer/src/locales/th/common.ts +++ b/src/renderer/src/locales/th/common.ts @@ -5,6 +5,7 @@ import commandsSdd from './common/commands-sdd.json' import sddFrameworks from './common/sdd-frameworks.json' import sddMcp from './common/sdd-mcp.json' import agentsGraph from './common/agents-graph.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -14,6 +15,7 @@ const common = { ...sddFrameworks, ...sddMcp, ...agentsGraph, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/th/common/command-palette.json b/src/renderer/src/locales/th/common/command-palette.json new file mode 100644 index 000000000..c9898e8af --- /dev/null +++ b/src/renderer/src/locales/th/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "แถบคำสั่ง", + "paletteInputLabel": "ค้นหาคำสั่ง การสนทนา และการตั้งค่า", + "palettePlaceholder": "พิมพ์คำสั่งหรือค้นหา…", + "paletteOpenTooltip": "แถบคำสั่ง", + "paletteResultsLabel": "ผลลัพธ์", + "paletteScopeCommands": "คำสั่ง", + "paletteScopeConversations": "การสนทนา", + "paletteScopeSettings": "การตั้งค่า", + "paletteScopeSlash": "คำสั่งสแลช", + "paletteSectionRecent": "ล่าสุด", + "paletteSectionDefault": "การดำเนินการด่วน", + "paletteSectionCommands": "คำสั่ง", + "paletteSectionNavigation": "การนำทาง", + "paletteSectionSettings": "การตั้งค่า", + "paletteSectionModels": "โมเดล", + "paletteSectionConversations": "บทสนทนา", + "paletteSectionProjects": "โปรเจกต์", + "paletteSectionExtensions": "ส่วนขยาย", + "paletteSourceCommand": "คำสั่ง", + "paletteSourceRoute": "การนำทาง", + "paletteSourceSettings": "การตั้งค่า", + "paletteSourceThread": "การสนทนา", + "paletteSourceWorkspace": "พื้นที่ทำงาน", + "paletteSourceSkill": "ทักษะ", + "paletteSourceExtension": "ส่วนขยาย", + "paletteSourceAction": "การกระทำ", + "paletteSourceModel": "โมเดล", + "paletteModelActiveBadge": "กำลังใช้", + "paletteActionPinThread": "ปักหมุดบทสนทนานี้", + "paletteActionUnpinThread": "เลิกปักหมุดบทสนทนานี้", + "paletteActionArchiveThread": "เก็บบทสนทนานี้", + "paletteActionThreadScope": "มีผลกับบทสนทนาที่คุณอยู่", + "paletteActionsSection": "การกระทำ", + "paletteComposeWithQuery": "เริ่มแชทเกี่ยวกับ “{{query}}”", + "paletteComposeWithQueryDesc": "ใส่ข้อความที่คุณพิมพ์ลงในกล่องข้อความ", + "paletteEmpty": "ไม่พบผลลัพธ์ที่ตรงกัน", + "paletteEmptyScoped": "ไม่พบผลลัพธ์ที่ตรงกันใน {{scope}}", + "paletteResultCount_one": "{{count}} ผลลัพธ์", + "paletteResultCount_other": "{{count}} ผลลัพธ์", + "paletteTargetUnavailable": "รายการนี้ไม่พร้อมใช้งานแล้ว", + "paletteComposerBusy": "ล้างกล่องข้อความก่อนเรียกใช้คำสั่งนี้", + "paletteLockedBadge": "ตรวจสอบ", + "paletteLockedReason": "ต้องมีการตรวจสอบสิทธิ์พื้นที่ทำงาน", + "paletteDisabledDefault": "ไม่พร้อมใช้งานในบริบทปัจจุบัน", + "paletteUntitledThread": "การสนทนาที่ไม่มีชื่อ", + "paletteNavigationHint": "นำทางด้วยปุ่มลูกศร Enter เพื่อเลือก", + "paletteEscapeHint": "Esc เพื่อปิด", + "paletteContentMatchBadge": "ตรงกัน", + "paletteContentSearchSection": "การสนทนาที่ตรงกัน", + "paletteSearchingConversations": "กำลังค้นหาบทสนทนา…", + "extensions": "ส่วนขยาย" +} diff --git a/src/renderer/src/locales/th/settings/navigation-providers.json b/src/renderer/src/locales/th/settings/navigation-providers.json index c8bb75f73..8af93efa8 100644 --- a/src/renderer/src/locales/th/settings/navigation-providers.json +++ b/src/renderer/src/locales/th/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "เปิดหรือปิดแผงขั้วต่อ", "shortcutSettings": "การตั้งค่า", "shortcutSettingsDesc": "เปิดการตั้งค่า", + "shortcutCommandPalette": "แถบคำสั่ง", + "shortcutCommandPaletteDesc": "เปิดแถบคำสั่งเพื่อค้นหาคำสั่ง การสนทนา และการตั้งค่า", "shortcutQuit": "เลิก", "shortcutQuitDesc": "ออกจากแอป", "shortcutUndo": "เลิกทำ", diff --git a/src/renderer/src/locales/zh/common.ts b/src/renderer/src/locales/zh/common.ts index c9b221e95..3c5deb614 100644 --- a/src/renderer/src/locales/zh/common.ts +++ b/src/renderer/src/locales/zh/common.ts @@ -8,6 +8,7 @@ import agentsGraph from './common/agents-graph.json' import codePersonas from './common/code-personas.json' import workWhiteboard from './common/work-whiteboard.json' import sidebar from './common/sidebar.json' +import commandPalette from './common/command-palette.json' const common = { ...shellWorkflow, @@ -20,6 +21,7 @@ const common = { ...codePersonas, ...workWhiteboard, ...sidebar, + ...commandPalette, } export default common diff --git a/src/renderer/src/locales/zh/common/command-palette.json b/src/renderer/src/locales/zh/common/command-palette.json new file mode 100644 index 000000000..317f1076c --- /dev/null +++ b/src/renderer/src/locales/zh/common/command-palette.json @@ -0,0 +1,53 @@ +{ + "paletteDialogLabel": "命令面板", + "paletteInputLabel": "搜索命令、会话和设置", + "palettePlaceholder": "输入命令或搜索…", + "paletteOpenTooltip": "命令面板", + "paletteResultsLabel": "结果", + "paletteScopeCommands": "命令", + "paletteScopeConversations": "会话", + "paletteScopeSettings": "设置", + "paletteScopeSlash": "斜杠命令", + "paletteSectionRecent": "最近使用", + "paletteSectionDefault": "快捷操作", + "paletteSectionCommands": "命令", + "paletteSectionNavigation": "导航", + "paletteSectionSettings": "设置", + "paletteSectionModels": "模型", + "paletteSectionConversations": "会话", + "paletteSectionProjects": "项目", + "paletteSectionExtensions": "扩展", + "paletteSourceCommand": "命令", + "paletteSourceRoute": "导航", + "paletteSourceSettings": "设置", + "paletteSourceThread": "会话", + "paletteSourceWorkspace": "工作区", + "paletteSourceSkill": "技能", + "paletteSourceExtension": "扩展", + "paletteSourceAction": "操作", + "paletteSourceModel": "模型", + "paletteModelActiveBadge": "当前", + "paletteActionPinThread": "置顶此会话", + "paletteActionUnpinThread": "取消置顶此会话", + "paletteActionArchiveThread": "归档此会话", + "paletteActionThreadScope": "作用于你当前所在的会话。", + "paletteActionsSection": "操作", + "paletteComposeWithQuery": "就“{{query}}”开始对话", + "paletteComposeWithQueryDesc": "把你输入的内容放入输入框。", + "paletteEmpty": "没有匹配的结果。", + "paletteEmptyScoped": "在“{{scope}}”中没有匹配的结果。", + "paletteResultCount_one": "{{count}} 个结果", + "paletteResultCount_other": "{{count}} 个结果", + "paletteTargetUnavailable": "该目标已不可用。", + "paletteComposerBusy": "运行该命令前请先清空输入框。", + "paletteLockedBadge": "需授权", + "paletteLockedReason": "需要工作区权限审核", + "paletteDisabledDefault": "当前上下文不可用", + "paletteUntitledThread": "未命名会话", + "paletteNavigationHint": "方向键浏览,Enter 选择", + "paletteEscapeHint": "Esc 关闭", + "paletteContentMatchBadge": "命中", + "paletteContentSearchSection": "会话内容匹配", + "paletteSearchingConversations": "正在搜索会话…", + "extensions": "扩展" +} diff --git a/src/renderer/src/locales/zh/settings/navigation-providers.json b/src/renderer/src/locales/zh/settings/navigation-providers.json index 32bc0a579..38fcd8d4c 100644 --- a/src/renderer/src/locales/zh/settings/navigation-providers.json +++ b/src/renderer/src/locales/zh/settings/navigation-providers.json @@ -320,6 +320,8 @@ "shortcutToggleTerminalDesc": "打开或关闭终端面板。", "shortcutSettings": "设置", "shortcutSettingsDesc": "打开设置。", + "shortcutCommandPalette": "命令面板", + "shortcutCommandPaletteDesc": "打开命令面板,搜索命令、会话和设置。", "shortcutQuit": "退出", "shortcutQuitDesc": "退出应用。", "shortcutUndo": "撤销", diff --git a/src/renderer/src/palette/CommandPaletteOverlay.test.ts b/src/renderer/src/palette/CommandPaletteOverlay.test.ts new file mode 100644 index 000000000..4ad113d81 --- /dev/null +++ b/src/renderer/src/palette/CommandPaletteOverlay.test.ts @@ -0,0 +1,302 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { act, create as createRenderer, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import i18n from '../i18n' +import { CommandPaletteOverlay } from './CommandPaletteOverlay' +import type { PaletteEntry } from './palette-model' + +function entry(id: string, overrides: Partial = {}): PaletteEntry { + return { + id, + source: 'route', + title: 'Title ' + id, + keywords: [], + activation: { kind: 'route', route: 'chat' }, + ...overrides + } +} + +type OverlayProps = { + query?: string + matchTerm?: string + scope?: 'all' | 'commands' | 'conversations' | 'settings' | 'slash' + scopeLabel?: string | null + groups?: Array<{ key: string; label: string; entries: PaletteEntry[] }> | null + results?: PaletteEntry[] + onActivate?: (entry: PaletteEntry) => void + onClose?: () => void + onQueryChange?: (query: string) => void +} + +async function render(props: OverlayProps = {}): Promise { + let renderer!: ReactTestRenderer + await act(async () => { + renderer = createRenderer( + createElement(CommandPaletteOverlay, { + query: props.query ?? '', + matchTerm: props.matchTerm ?? props.query ?? '', + scope: props.scope ?? 'all', + scopeLabel: props.scopeLabel ?? null, + groups: props.groups ?? null, + results: props.results ?? [], + sourceLabel: (source) => 'src:' + source.source, + onQueryChange: props.onQueryChange ?? vi.fn(), + onActivate: props.onActivate ?? vi.fn(), + onClose: props.onClose ?? vi.fn() + }) + ) + }) + return renderer +} + +describe('CommandPaletteOverlay', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + it('exposes combobox, listbox, and option ARIA semantics with a live result count', () => { + const results = [ + entry('one'), + entry('two', { + source: 'extension-view', + activation: { kind: 'extension-view', entryId: 'ext-x', locked: true }, + badge: 'Review' + }), + entry('three', { disabled: true, disabledReason: 'Unavailable in the current context' }) + ] + const html = renderToStaticMarkup( + createElement(CommandPaletteOverlay, { + query: 'ti', + matchTerm: 'ti', + scope: 'all', + scopeLabel: null, + groups: null, + results, + sourceLabel: (source) => 'src:' + source.source, + onQueryChange: vi.fn(), + onActivate: vi.fn(), + onClose: vi.fn() + }) + ) + expect(html).toContain('role="dialog"') + expect(html).toContain('aria-modal="true"') + expect(html).toContain('role="combobox"') + expect(html).toContain('role="listbox"') + expect(html).toContain('role="option"') + expect(html).toContain('aria-activedescendant="ds-command-palette-option-0"') + expect(html).toContain('aria-live="polite"') + expect(html).toContain('>3 results') + expect(html).toContain('aria-disabled="true"') + expect(html).toContain('Unavailable in the current context') + expect(html).toContain('src:route') + expect(html).toContain('src:extension-view') + expect(html).toContain('Review') + }) + + it('renders a scoped empty state without widening the scope', () => { + const html = renderToStaticMarkup( + createElement(CommandPaletteOverlay, { + query: '#nope', + matchTerm: 'nope', + scope: 'settings', + scopeLabel: 'Settings', + groups: null, + results: [], + sourceLabel: vi.fn(), + onQueryChange: vi.fn(), + onActivate: vi.fn(), + onClose: vi.fn() + }) + ) + expect(html).toContain('No matching results in Settings.') + expect(html).not.toContain('role="option"') + }) + + it('shows a searching indicator instead of the empty state while a deep search runs', () => { + const html = renderToStaticMarkup( + createElement(CommandPaletteOverlay, { + query: 'checkout', + matchTerm: 'checkout', + scope: 'all', + scopeLabel: null, + groups: null, + results: [], + contentSearchPending: true, + sourceLabel: vi.fn(), + onQueryChange: vi.fn(), + onActivate: vi.fn(), + onClose: vi.fn() + }) + ) + expect(html).toContain('Searching conversations') + // Claiming "no results" while results are still arriving is the bug this + // guards: a slow search read as a failed search. + expect(html).not.toContain('No matching results') + }) + + it('keeps a searching hint visible when some results already rendered', () => { + const html = renderToStaticMarkup( + createElement(CommandPaletteOverlay, { + query: 'checkout', + matchTerm: 'checkout', + scope: 'all', + scopeLabel: null, + groups: null, + results: [entry('r1')], + contentSearchPending: true, + sourceLabel: vi.fn(), + onQueryChange: vi.fn(), + onActivate: vi.fn(), + onClose: vi.fn() + }) + ) + expect(html).toContain('Searching conversations') + expect(html).toContain('role="option"') + }) + + it('reports the empty state once the deep search has settled', () => { + const html = renderToStaticMarkup( + createElement(CommandPaletteOverlay, { + query: 'checkout', + matchTerm: 'checkout', + scope: 'all', + scopeLabel: null, + groups: null, + results: [], + contentSearchPending: false, + sourceLabel: vi.fn(), + onQueryChange: vi.fn(), + onActivate: vi.fn(), + onClose: vi.fn() + }) + ) + expect(html).toContain('No matching results') + expect(html).not.toContain('Searching conversations') + }) + + it('renders recents and default destination groups for the empty query', () => { + const html = renderToStaticMarkup( + createElement(CommandPaletteOverlay, { + query: '', + matchTerm: '', + scope: 'all', + scopeLabel: null, + groups: [ + { key: 'recent', label: 'Recent', entries: [entry('r1')] }, + { key: 'default', label: 'Quick actions', entries: [entry('d1'), entry('d2')] } + ], + results: [], + sourceLabel: vi.fn(), + onQueryChange: vi.fn(), + onActivate: vi.fn(), + onClose: vi.fn() + }) + ) + expect(html).toContain('Recent') + expect(html).toContain('Quick actions') + expect(html).toContain('>3 results') + }) + + it('moves the active option with arrow keys and Home/End', async () => { + const onActivate = vi.fn() + const onClose = vi.fn() + const tree = await render({ + results: [entry('a'), entry('b'), entry('c')], + onActivate, + onClose + }) + const panel = tree.root.findAll((node) => node.props.onKeyDown)[0] + const fireKey = (key: string): void => { + act(() => { + panel.props.onKeyDown({ key, nativeEvent: { isComposing: false }, preventDefault: vi.fn() }) + }) + } + + fireKey('ArrowDown') + let options = tree.root.findAllByType('li') + expect(options[1].props['aria-selected']).toBe(true) + + fireKey('End') + options = tree.root.findAllByType('li') + expect(options[2].props['aria-selected']).toBe(true) + + fireKey('Home') + options = tree.root.findAllByType('li') + expect(options[0].props['aria-selected']).toBe(true) + + fireKey('ArrowUp') + options = tree.root.findAllByType('li') + // Movement clamps at the first option; it does not wrap around. + expect(options[0].props['aria-selected']).toBe(true) + + fireKey('ArrowDown') + options = tree.root.findAllByType('li') + expect(options[1].props['aria-selected']).toBe(true) + expect(onActivate).not.toHaveBeenCalled() + }) + + it('activates the active option on Enter and leaves disabled entries inert', async () => { + const onActivate = vi.fn() + const onClose = vi.fn() + const disabled = entry('disabled', { disabled: true }) + const tree = await render({ + results: [disabled, entry('enabled')], + onActivate, + onClose + }) + const panel = tree.root.findAll((node) => node.props.onKeyDown)[0] + const fireKey = (key: string): void => { + act(() => { + panel.props.onKeyDown({ key, nativeEvent: { isComposing: false }, preventDefault: vi.fn() }) + }) + } + + fireKey('Enter') + expect(onActivate).not.toHaveBeenCalled() + + fireKey('ArrowDown') + fireKey('Enter') + expect(onActivate).toHaveBeenCalledTimes(1) + expect(onActivate.mock.calls[0][0].id).toBe('enabled') + }) + + it('dismisses on Escape without activating', async () => { + const onActivate = vi.fn() + const onClose = vi.fn() + const tree = await render({ results: [entry('a')], onActivate, onClose }) + const panel = tree.root.findAll((node) => node.props.onKeyDown)[0] + act(() => { + panel.props.onKeyDown({ key: 'Escape', nativeEvent: { isComposing: false }, preventDefault: vi.fn() }) + }) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + }) + + it('activates a result on pointer down unless it is disabled', async () => { + const onActivate = vi.fn() + const disabled = entry('disabled', { disabled: true }) + const tree = await render({ results: [disabled, entry('ok')], onActivate }) + const options = tree.root.findAllByType('li') + act(() => options[0].props.onPointerDown({ preventDefault: vi.fn() })) + expect(onActivate).not.toHaveBeenCalled() + act(() => options[1].props.onPointerDown({ preventDefault: vi.fn() })) + expect(onActivate).toHaveBeenCalledTimes(1) + expect(onActivate.mock.calls[0][0].id).toBe('ok') + }) + + it('propagates input changes and shows the active scope hint', async () => { + const onQueryChange = vi.fn() + const tree = await render({ + query: '#pro', + matchTerm: 'pro', + scope: 'settings', + scopeLabel: 'Settings', + onQueryChange + }) + const input = tree.root.findByType('input') + act(() => input.props.onChange({ target: { value: '#prov' } })) + expect(onQueryChange).toHaveBeenCalledWith('#prov') + expect(tree.root.findByProps({ children: 'Settings' })).toBeTruthy() + }) +}) diff --git a/src/renderer/src/palette/CommandPaletteOverlay.tsx b/src/renderer/src/palette/CommandPaletteOverlay.tsx new file mode 100644 index 000000000..6f20af0e9 --- /dev/null +++ b/src/renderer/src/palette/CommandPaletteOverlay.tsx @@ -0,0 +1,378 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactElement +} from 'react' +import { useTranslation } from 'react-i18next' +import { Command, Loader2, LockKeyhole, Puzzle, Search } from 'lucide-react' +import { extensionHostIconUrl } from '../extensions/contribution-registry' +import { highlightSegments } from './palette-highlight' +import type { PaletteEntry, PaletteIcon } from './palette-model' +import type { PaletteQueryScope } from './palette-scorer' +import type { PaletteResultGroup } from './useWorkbenchCommandPalette' + +const PALETTE_LISTBOX_ID = 'ds-command-palette-listbox' +const PAGE_STEP = 8 + +type CommandPaletteOverlayProps = { + query: string + /** Normalized term to highlight: the query with any scope prefix stripped. */ + matchTerm: string + scope: PaletteQueryScope + scopeLabel: string | null + groups: PaletteResultGroup[] | null + results: PaletteEntry[] + /** True while a conversation deep search is debouncing or in flight. */ + contentSearchPending?: boolean + sourceLabel: (entry: PaletteEntry) => string + onQueryChange: (query: string) => void + onActivate: (entry: PaletteEntry) => void + onClose: () => void +} + +function PaletteRowIcon({ icon }: { icon?: PaletteIcon }): ReactElement { + const [failed, setFailed] = useState(false) + useEffect(() => setFailed(false), [icon]) + + if (icon?.kind === 'extension' && icon.iconPath && !failed) { + return ( + setFailed(true)} + /> + ) + } + if (icon?.kind === 'lucide') { + const Icon = icon.icon + return + } + return +} + +/** Renders `text` with every occurrence of `term` visually emphasized. */ +function Highlighted({ text, term }: { text: string; term: string }): ReactElement { + return ( + <> + {highlightSegments(text, term).map((segment, index) => + segment.match + ? ( + + {segment.text} + + ) + : {segment.text} + )} + + ) +} + +export function CommandPaletteOverlay({ + query, + matchTerm, + scope, + scopeLabel, + groups, + results, + contentSearchPending = false, + sourceLabel, + onQueryChange, + onActivate, + onClose +}: CommandPaletteOverlayProps): ReactElement { + const { t } = useTranslation('common') + const inputRef = useRef(null) + const optionRefs = useRef>([]) + const previousFocusRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + + const flatResults = useMemo(() => results, [results]) + const groupEntries = useMemo( + () => (groups ? groups.flatMap((group) => group.entries) : []), + [groups] + ) + const allRows = useMemo(() => [...flatResults, ...groupEntries], [flatResults, groupEntries]) + const activeEntry = allRows.length > 0 ? allRows[activeIndex] : undefined + + useEffect(() => { + if (typeof document === 'undefined') return + previousFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null + inputRef.current?.focus?.() + return () => { + previousFocusRef.current?.focus?.() + } + }, []) + + useEffect(() => { + setActiveIndex(0) + }, [allRows]) + + useEffect(() => { + optionRefs.current[activeIndex]?.scrollIntoView?.({ block: 'nearest' }) + }, [activeIndex]) + + const moveActive = (next: number): void => { + if (allRows.length === 0) return + setActiveIndex(Math.min(Math.max(next, 0), allRows.length - 1)) + } + + /** + * Keyboard navigation scrolls the list, which slides rows under a + * stationary cursor and makes the browser emit pointermove. Taking the + * selection on those synthetic moves would fight the arrow keys, so only a + * pointer that actually changed position may claim the active row. + */ + const pointerPositionRef = useRef<{ x: number; y: number } | null>(null) + const onRowPointerMove = (index: number, x: number, y: number): void => { + const previous = pointerPositionRef.current + if (previous && previous.x === x && previous.y === y) return + pointerPositionRef.current = { x, y } + if (previous) setActiveIndex(index) + } + + const onKeyDown = (event: ReactKeyboardEvent): void => { + if (event.nativeEvent.isComposing) return + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + moveActive(activeIndex + 1) + return + case 'ArrowUp': + event.preventDefault() + moveActive(activeIndex - 1) + return + case 'Home': + event.preventDefault() + moveActive(0) + return + case 'End': + event.preventDefault() + moveActive(allRows.length - 1) + return + case 'PageDown': + event.preventDefault() + moveActive(activeIndex + PAGE_STEP) + return + case 'PageUp': + event.preventDefault() + moveActive(activeIndex - PAGE_STEP) + return + case 'Enter': { + event.preventDefault() + if (activeEntry && !activeEntry.disabled) onActivate(activeEntry) + return + } + case 'Escape': + event.preventDefault() + onClose() + return + case 'Tab': { + // Trap focus between the input and the active option. + event.preventDefault() + const input = inputRef.current + if ( + typeof document !== 'undefined' && + document.activeElement === input && + allRows.length > 0 + ) { + optionRefs.current[activeIndex]?.focus?.() + } else { + input?.focus?.() + } + return + } + } + } + + const renderRow = (entry: PaletteEntry, index: number): ReactElement => { + const locked = entry.activation.kind === 'extension-view' && entry.activation.locked + return ( +
  • { + optionRefs.current[index] = node + }} + id={'ds-command-palette-option-' + index} + role="option" + aria-selected={index === activeIndex} + aria-disabled={entry.disabled === true} + tabIndex={-1} + data-palette-entry-id={entry.id} + onPointerMove={(event) => onRowPointerMove(index, event.clientX, event.clientY)} + onPointerDown={(event) => { + event.preventDefault() + if (!entry.disabled) onActivate(entry) + }} + className={ + 'mx-1.5 flex cursor-default select-none items-center gap-2.5 rounded-[var(--ds-radius-control)] px-2.5 py-2 text-[13.5px] transition first:mt-1.5 last:mb-1.5 ' + + (index === activeIndex ? 'bg-ds-hover text-ds-ink' : 'text-ds-muted') + + (entry.disabled ? ' cursor-not-allowed opacity-45' : '') + } + > + + + + + + {entry.subtitle || (entry.disabled && entry.disabledReason) ? ( + + {entry.disabled && entry.disabledReason + ? entry.disabledReason + : } + + ) : null} + + {locked ? ( + + + {entry.badge ?? t('paletteLockedBadge')} + + ) : null} + {entry.badge && !locked ? ( + + {entry.badge} + + ) : null} + + {sourceLabel(entry)} + +
  • + ) + } + + const emptyLabel = scopeLabel + ? t('paletteEmptyScoped', { scope: scopeLabel }) + : t('paletteEmpty') + + return ( +
    { + if (event.target === event.currentTarget) onClose() + }} + > +
    +
    + + 0 + ? 'ds-command-palette-option-' + activeIndex + : undefined + } + aria-autocomplete="list" + aria-label={t('paletteInputLabel')} + placeholder={t('palettePlaceholder')} + value={query} + autoComplete="off" + spellCheck={false} + onChange={(event) => onQueryChange(event.target.value)} + className="min-w-0 flex-1 bg-transparent text-[15px] text-ds-ink placeholder:text-ds-faint focus:outline-none" + /> + {scopeLabel ? ( + + {scopeLabel} + + ) : null} + + Esc + +
    + +
    + {allRows.length === 0 && contentSearchPending ? ( +
    + + {t('paletteSearchingConversations')} +
    + ) : null} + {allRows.length > 0 ? ( + <> + {flatResults.length > 0 ? ( +
      + {flatResults.map((entry, index) => renderRow(entry, index))} +
    + ) : null} + {groups + ? groups.map((group) => { + const offset = flatResults.length + + groups + .slice(0, groups.indexOf(group)) + .reduce((sum, current) => sum + current.entries.length, 0) + return ( +
    +
    + {group.label} +
    +
      + {group.entries.map((entry, index) => renderRow(entry, offset + index))} +
    +
    + ) + }) + : null} + + ) : null} + {/* Only claim there is nothing once nothing is still arriving. */} + {allRows.length === 0 && !contentSearchPending ? ( +
    + {emptyLabel} +
    + ) : null} +
    + +
    + {allRows.length > 0 && contentSearchPending ? ( + + + {t('paletteSearchingConversations')} + + ) : ( + {t('paletteNavigationHint')} + )} + {t('paletteEscapeHint')} +
    +
    +
    + {contentSearchPending + ? t('paletteSearchingConversations') + : t('paletteResultCount', { count: allRows.length })} +
    +
    + ) +} diff --git a/src/renderer/src/palette/palette-highlight.test.ts b/src/renderer/src/palette/palette-highlight.test.ts new file mode 100644 index 000000000..f0a6f6528 --- /dev/null +++ b/src/renderer/src/palette/palette-highlight.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { highlightSegments } from './palette-highlight' + +function rendered(text: string, term: string): string { + return highlightSegments(text, term) + .map((segment) => (segment.match ? `[${segment.text}]` : segment.text)) + .join('') +} + +describe('highlightSegments', () => { + it('marks every occurrence and preserves the original text', () => { + expect(rendered('checkout the checkout flow', 'checkout')) + .toBe('[checkout] the [checkout] flow') + }) + + it('matches case-insensitively while keeping the original casing', () => { + expect(rendered('Checkout and CHECKOUT', 'checkout')).toBe('[Checkout] and [CHECKOUT]') + }) + + it('returns one plain segment when the term is absent or empty', () => { + expect(highlightSegments('nothing here', 'absent')).toEqual([ + { text: 'nothing here', match: false } + ]) + expect(highlightSegments('nothing here', ' ')).toEqual([ + { text: 'nothing here', match: false } + ]) + }) + + it('treats the term literally so regex metacharacters do not throw or over-match', () => { + expect(rendered('a.b and axb', '.')).toBe('a[.]b and axb') + expect(rendered('cost is $5 (approx)', '$5')).toBe('cost is [$5] (approx)') + expect(rendered('files a+b', 'a+b')).toBe('files [a+b]') + }) + + it('handles a match at the very start and end', () => { + expect(rendered('kun runs kun', 'kun')).toBe('[kun] runs [kun]') + }) + + it('never loses characters, whatever the term', () => { + const text = 'the palette highlights the matched term' + for (const term of ['the', 'a', 'palette', 'zzz', '']) { + const joined = highlightSegments(text, term).map((segment) => segment.text).join('') + expect(joined).toBe(text) + } + }) + + it('marks the scattered characters of an acronym or subsequence match', () => { + // Nothing literal to mark, so the row would otherwise render with no + // emphasis at all and read as a false positive. + expect(rendered('Keyboard shortcuts', 'ks')).toBe('[K]eyboard [s]hortcuts') + expect(rendered('Compose', 'cmps')).toBe('[C]o[mp]o[s]e') + }) + + it('prefers a literal run over scattered characters', () => { + expect(rendered('Media generation', 'media')).toBe('[Media] generation') + }) + + it('ignores spaces when falling back to scattered matching', () => { + // Earliest-match semantics: the 'h' lands inside "chat", not on "history". + expect(rendered('New chat history', 'n c h')).toBe('[N]ew [ch]at history') + expect(rendered('New chat history', 'nch')).toBe('[N]ew [ch]at history') + }) + + it('marks nothing when the characters are not present in order', () => { + expect(highlightSegments('Compose', 'zx')).toEqual([{ text: 'Compose', match: false }]) + expect(highlightSegments('Compose', 'esopmoc')).toEqual([ + { text: 'Compose', match: false } + ]) + }) + + it('returns nothing for empty text', () => { + expect(highlightSegments('', 'anything')).toEqual([]) + }) +}) diff --git a/src/renderer/src/palette/palette-highlight.ts b/src/renderer/src/palette/palette-highlight.ts new file mode 100644 index 000000000..57f6bcc64 --- /dev/null +++ b/src/renderer/src/palette/palette-highlight.ts @@ -0,0 +1,77 @@ +export type HighlightSegment = { + text: string + /** True when this segment is a literal occurrence of the search term. */ + match: boolean +} + +/** Occurrences beyond this are left unhighlighted; rows are one line anyway. */ +const MAX_HIGHLIGHTS = 24 + +/** + * Splits `text` into alternating plain and matched segments for the literal, + * case-insensitive `term`. + * + * Matching is literal rather than regex so a query containing regex + * metacharacters highlights what the user typed instead of throwing or + * silently matching something else. Returns a single unmatched segment when + * there is nothing to highlight, so callers can render one code path. + */ +export function highlightSegments(text: string, term: string): HighlightSegment[] { + const needle = term.trim().toLowerCase() + if (!text) return [] + if (!needle) return [{ text, match: false }] + + const haystack = text.toLowerCase() + const segments: HighlightSegment[] = [] + let cursor = 0 + let found = 0 + + while (found < MAX_HIGHLIGHTS) { + const index = haystack.indexOf(needle, cursor) + if (index < 0) break + if (index > cursor) segments.push({ text: text.slice(cursor, index), match: false }) + segments.push({ text: text.slice(index, index + needle.length), match: true }) + cursor = index + needle.length + found += 1 + } + + if (segments.length === 0) return subsequenceSegments(text, needle) + if (cursor < text.length) segments.push({ text: text.slice(cursor), match: false }) + return segments +} + +/** + * Fallback for matches the literal pass cannot show: acronym and loose + * subsequence hits, where the matched characters are scattered. Without this + * such a row renders with no emphasis at all and looks like a false positive, + * because nothing on screen explains why it matched. + */ +function subsequenceSegments(text: string, lowerCaseNeedle: string): HighlightSegment[] { + const haystack = text.toLowerCase() + const matched: number[] = [] + let cursor = 0 + for (const char of lowerCaseNeedle) { + if (char === ' ') continue + const index = haystack.indexOf(char, cursor) + if (index < 0) return [{ text, match: false }] + matched.push(index) + cursor = index + 1 + } + if (matched.length === 0) return [{ text, match: false }] + + const marked = new Set(matched) + const segments: HighlightSegment[] = [] + let run = '' + let runMatch = marked.has(0) + for (let index = 0; index < text.length; index += 1) { + const isMatch = marked.has(index) + if (isMatch !== runMatch && run) { + segments.push({ text: run, match: runMatch }) + run = '' + } + runMatch = isMatch + run += text[index] + } + if (run) segments.push({ text: run, match: runMatch }) + return segments +} diff --git a/src/renderer/src/palette/palette-model.ts b/src/renderer/src/palette/palette-model.ts new file mode 100644 index 000000000..6356906aa --- /dev/null +++ b/src/renderer/src/palette/palette-model.ts @@ -0,0 +1,106 @@ +import type { LucideIcon } from 'lucide-react' +import type { KeyboardShortcutCommandId } from '@shared/keyboard-shortcuts' +import type { SlashCommandId } from '../components/chat/floating-composer-commands' +import type { AppRoute, SettingsRouteSection } from '../store/chat-store-types' + +/** + * Pure command palette entry model. Entries are produced by source + * aggregators from existing renderer registries and consumed by the + * overlay; nothing here reads stores, timers, or randomness. + */ +export type PaletteSourceKind = + | 'shortcut-command' + | 'route' + | 'settings' + | 'thread' + | 'workspace' + | 'slash-command' + | 'extension-view' + | 'compose' + | 'model' + | 'action' + +export const PALETTE_SOURCE_KINDS = [ + 'shortcut-command', + 'route', + 'settings', + 'thread', + 'workspace', + 'slash-command', + 'extension-view', + 'compose', + 'model', + 'action' +] as const satisfies readonly PaletteSourceKind[] + +export type PaletteActivation = + /** Hand the raw query text to the composer as a prompt draft. */ + | { kind: 'compose'; text: string } + /** Switch the composer's model, optionally pinning its provider. */ + | { kind: 'select-model'; modelId: string; providerId?: string } + /** Reversible action on the active conversation. */ + | { kind: 'thread-action'; action: 'pin' | 'unpin' | 'archive'; threadId: string } + | { kind: 'route'; route: AppRoute } + | { kind: 'settings'; section: SettingsRouteSection } + | { kind: 'thread'; threadId: string } + | { kind: 'workspace'; workspaceRoot: string } + | { kind: 'shortcut-command'; commandId: KeyboardShortcutCommandId } + | { kind: 'slash-command'; commandId: SlashCommandId; insertText: string } + | { kind: 'extension-view'; entryId: string; locked: boolean } + +export type PaletteIcon = + | { kind: 'lucide'; icon: LucideIcon } + | { kind: 'extension'; extensionId: string; iconPath?: string } + +export type PaletteEntry = { + /** Stable identity, unique across all sources for one snapshot. */ + id: string + source: PaletteSourceKind + title: string + subtitle?: string + keywords: string[] + /** Short right-aligned hint such as a key binding or the command text. */ + badge?: string + icon?: PaletteIcon + disabled?: boolean + disabledReason?: string + activation: PaletteActivation +} + +/** + * Tie-break priority when two entries match a query in the same tier. + * Lower sorts first. + */ +export const PALETTE_SOURCE_PRIORITY: Record = { + 'shortcut-command': 0, + 'slash-command': 1, + // Acting on the current conversation is the narrowest, most deliberate + // intent, so it outranks navigation when both match equally well. + action: 2, + route: 3, + settings: 4, + thread: 5, + model: 6, + workspace: 7, + 'extension-view': 8, + // A fallback offer only ever renders when nothing else matched. + compose: 9 +} + +export type PaletteRecentIdentity = { + source: PaletteSourceKind + /** The entry id the recent references. */ + id: string +} + +/** Entry ids the empty-query state falls back to when no recents exist. */ +export const DEFAULT_PALETTE_ENTRY_IDS = [ + 'cmd:new-chat', + 'cmd:choose-workspace', + 'slash:new', + 'slash:plan', + 'slash:research', + 'route:write', + 'route:design', + 'settings:providers' +] as const diff --git a/src/renderer/src/palette/palette-recents.test.ts b/src/renderer/src/palette/palette-recents.test.ts new file mode 100644 index 000000000..b58b88483 --- /dev/null +++ b/src/renderer/src/palette/palette-recents.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { writeBrowserStorageItem } from '../lib/browser-storage' +import type { PaletteRecentIdentity } from './palette-model' +import { + PALETTE_RECENTS_BOUND, + PALETTE_RECENTS_SCOPE_BOUND, + PALETTE_RECENTS_STORAGE_KEY, + normalizeStoredPaletteRecents, + readPaletteRecents, + recordPaletteRecent +} from './palette-recents' + +const SCOPE_A = '/Users/demo/project' +const SCOPE_B = '/Users/demo/other' + +function recent(source: PaletteRecentIdentity['source'], id: string): PaletteRecentIdentity { + return { source, id } +} + +function createMemoryStorage(): Storage { + const store = new Map() + return { + get length() { + return store.size + }, + clear: () => store.clear(), + getItem: (key) => (store.has(key) ? (store.get(key) ?? null) : null), + key: (index) => [...store.keys()][index] ?? null, + removeItem: (key) => { + store.delete(key) + }, + setItem: (key, value) => { + store.set(key, String(value)) + } + } +} + +beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('normalizeStoredPaletteRecents', () => { + it('yields an empty registry for absent, unparsable, or unversioned values', () => { + expect(normalizeStoredPaletteRecents(null)).toEqual({ version: 2, workspaces: {} }) + expect(normalizeStoredPaletteRecents(undefined)).toEqual({ version: 2, workspaces: {} }) + expect(normalizeStoredPaletteRecents('garbage')).toEqual({ version: 2, workspaces: {} }) + expect(normalizeStoredPaletteRecents({ version: 9, workspaces: {} })).toEqual({ + version: 2, + workspaces: {} + }) + expect(normalizeStoredPaletteRecents({ version: 1 })).toEqual({ version: 2, workspaces: {} }) + }) + + it('drops invalid identities and duplicates and enforces the retention bound', () => { + const value = { + version: 1, + workspaces: { + [SCOPE_A]: [ + recent('route', 'route:chat'), + { source: 'unknown', id: 'route:x' }, + { source: 'route', id: '' }, + 42, + recent('route', 'route:chat'), + ...Array.from({ length: PALETTE_RECENTS_BOUND + 5 }, (_, index) => + recent('thread', 'thread:' + index) + ) + ] + } + } + const normalized = normalizeStoredPaletteRecents(value) + expect(normalized.workspaces[SCOPE_A]).toHaveLength(PALETTE_RECENTS_BOUND) + expect(normalized.workspaces[SCOPE_A][0]).toMatchObject(recent('route', 'route:chat')) + expect(normalized.workspaces[SCOPE_A][0]).not.toEqual( + normalized.workspaces[SCOPE_A][1] + ) + }) +}) + +describe('recordPaletteRecent', () => { + it('records the most recent selection first and moves duplicates to the front', () => { + recordPaletteRecent(SCOPE_A, recent('route', 'route:chat')) + const after = recordPaletteRecent(SCOPE_A, recent('settings', 'settings:providers')) + expect(after).toEqual([ + recent('settings', 'settings:providers'), + recent('route', 'route:chat') + ]) + + const deduped = recordPaletteRecent(SCOPE_A, recent('route', 'route:chat')) + expect(deduped).toEqual([ + recent('route', 'route:chat'), + recent('settings', 'settings:providers') + ]) + }) + + it('keeps recents workspace-scoped', () => { + recordPaletteRecent(SCOPE_A, recent('route', 'route:chat')) + expect(readPaletteRecents(SCOPE_A)).toEqual([recent('route', 'route:chat')]) + expect(readPaletteRecents(SCOPE_B)).toEqual([]) + }) + + it('retains only the newest entries up to the bound', () => { + for (let index = 0; index < PALETTE_RECENTS_BOUND + 6; index += 1) { + recordPaletteRecent(SCOPE_A, recent('thread', 'thread:' + index)) + } + const stored = readPaletteRecents(SCOPE_A) + expect(stored).toHaveLength(PALETTE_RECENTS_BOUND) + expect(stored[0].id).toBe('thread:' + (PALETTE_RECENTS_BOUND + 5)) + expect(stored.some((entry) => entry.id === 'thread:0')).toBe(false) + }) + + it('bounds the number of stored workspace scopes, evicting the oldest', () => { + for (let index = 0; index < PALETTE_RECENTS_SCOPE_BOUND + 5; index += 1) { + recordPaletteRecent('/scope/' + index, recent('route', 'route:chat')) + } + const stored = JSON.parse( + localStorage.getItem(PALETTE_RECENTS_STORAGE_KEY) ?? '{}' + ) as { workspaces: Record } + const scopes = Object.keys(stored.workspaces) + expect(scopes).toHaveLength(PALETTE_RECENTS_SCOPE_BOUND) + // The five oldest scopes were dropped; the newest survived. + expect(scopes).not.toContain('/scope/0') + expect(scopes).toContain('/scope/' + (PALETTE_RECENTS_SCOPE_BOUND + 4)) + }) + + it('keeps a re-used scope alive by refreshing its position', () => { + recordPaletteRecent(SCOPE_A, recent('route', 'route:chat')) + for (let index = 0; index < PALETTE_RECENTS_SCOPE_BOUND - 1; index += 1) { + recordPaletteRecent('/filler/' + index, recent('route', 'route:chat')) + } + // Touching the oldest scope moves it to the front of the eviction order. + recordPaletteRecent(SCOPE_A, recent('route', 'route:write')) + recordPaletteRecent('/filler/overflow', recent('route', 'route:chat')) + expect(readPaletteRecents(SCOPE_A).map((entry) => entry.id)).toEqual([ + 'route:write', 'route:chat' + ]) + }) + + it('ranks a frequently used entry above a merely newer one', () => { + const day = 24 * 60 * 60 * 1000 + const base = 1_800_000_000_000 + // Used often, but not today. + for (let index = 0; index < 6; index += 1) { + recordPaletteRecent(SCOPE_A, recent('route', 'route:write'), base + index) + } + // Used once, just now. Pure recency would put this first. + recordPaletteRecent(SCOPE_A, recent('route', 'route:design'), base + day) + + expect(readPaletteRecents(SCOPE_A, base + day).map((entry) => entry.id)) + .toEqual(['route:write', 'route:design']) + }) + + it('lets an old habit decay below a fresh one', () => { + const base = 1_800_000_000_000 + const longAfter = base + 120 * 24 * 60 * 60 * 1000 + for (let index = 0; index < 6; index += 1) { + recordPaletteRecent(SCOPE_A, recent('route', 'route:write'), base + index) + } + recordPaletteRecent(SCOPE_A, recent('route', 'route:design'), longAfter) + + expect(readPaletteRecents(SCOPE_A, longAfter).map((entry) => entry.id)) + .toEqual(['route:design', 'route:write']) + }) + + it('migrates a version 1 payload without reshuffling its order', () => { + writeBrowserStorageItem(PALETTE_RECENTS_STORAGE_KEY, JSON.stringify({ + version: 1, + workspaces: { + [SCOPE_A]: [ + recent('route', 'route:write'), + recent('route', 'route:design'), + recent('settings', 'settings:providers') + ] + } + })) + expect(readPaletteRecents(SCOPE_A).map((entry) => entry.id)) + .toEqual(['route:write', 'route:design', 'settings:providers']) + }) + + it('falls back to an empty list when stored values are unparsable', () => { + writeBrowserStorageItem(PALETTE_RECENTS_STORAGE_KEY, '{not json') + expect(readPaletteRecents(SCOPE_A)).toEqual([]) + }) +}) diff --git a/src/renderer/src/palette/palette-recents.ts b/src/renderer/src/palette/palette-recents.ts new file mode 100644 index 000000000..437744628 --- /dev/null +++ b/src/renderer/src/palette/palette-recents.ts @@ -0,0 +1,192 @@ +import { readBrowserStorageItem, writeBrowserStorageItem } from '../lib/browser-storage' +import { + PALETTE_SOURCE_KINDS, + type PaletteRecentIdentity, + type PaletteSourceKind +} from './palette-model' + +export const PALETTE_RECENTS_STORAGE_KEY = 'kun.palette.recents.v1' +export const PALETTE_RECENTS_BOUND = 12 +/** + * Recents are bounded per workspace, but the number of workspaces a user + * opens over time is not. Cap the scopes too, evicting the least recently + * written, so the stored value cannot grow without limit. + */ +export const PALETTE_RECENTS_SCOPE_BOUND = 24 + +const PALETTE_RECENTS_VERSION = 2 as const +const LEGACY_PALETTE_RECENTS_VERSION = 1 + +/** Usage weight halves after this long, so old habits fade without vanishing. */ +export const PALETTE_FRECENCY_HALF_LIFE_MS = 14 * 24 * 60 * 60 * 1000 + +export type PaletteRecentUsage = PaletteRecentIdentity & { + /** How many times this entry has been activated in this workspace. */ + uses: number + /** Epoch millis of the most recent activation. */ + lastUsedAt: number +} + +export type StoredPaletteRecents = { + version: typeof PALETTE_RECENTS_VERSION + workspaces: Record +} + +function emptyStoredPaletteRecents(): StoredPaletteRecents { + return { version: PALETTE_RECENTS_VERSION, workspaces: {} } +} + +const SOURCE_KINDS = new Set(PALETTE_SOURCE_KINDS) + +function isIdentityShape(value: unknown): value is PaletteRecentIdentity { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.source === 'string' && + SOURCE_KINDS.has(candidate.source) && + typeof candidate.id === 'string' && + candidate.id.length > 0 && + candidate.id.length <= 512 + ) +} + +function normalizeUsage(value: unknown, fallbackRank: number): PaletteRecentUsage | null { + if (!isIdentityShape(value)) return null + const candidate = value as Partial & PaletteRecentIdentity + const uses = Number.isFinite(candidate.uses) && (candidate.uses as number) > 0 + ? Math.min(Math.floor(candidate.uses as number), 1_000_000) + : 1 + // Legacy v1 entries carry only their array position. Preserve that order by + // synthesizing decreasing timestamps so a migration cannot reshuffle a list + // the user already recognizes. + const lastUsedAt = Number.isFinite(candidate.lastUsedAt) && (candidate.lastUsedAt as number) > 0 + ? (candidate.lastUsedAt as number) + : -fallbackRank + return { source: candidate.source as PaletteSourceKind, id: candidate.id, uses, lastUsedAt } +} + +/** + * Absent, unparsable, or future-versioned values yield an empty registry. + * Version 1 payloads migrate in place; invalid entries and duplicates beyond + * the retention bound are dropped. + */ +export function normalizeStoredPaletteRecents(value: unknown): StoredPaletteRecents { + if (!value || typeof value !== 'object') return emptyStoredPaletteRecents() + const source = value as { version?: unknown; workspaces?: unknown } + const version = source.version + if ( + (version !== PALETTE_RECENTS_VERSION && version !== LEGACY_PALETTE_RECENTS_VERSION) || + !source.workspaces || + typeof source.workspaces !== 'object' + ) { + return emptyStoredPaletteRecents() + } + const workspaces: Record = {} + for (const [scope, rawRecents] of Object.entries( + source.workspaces as Record + )) { + if (!scope || !Array.isArray(rawRecents)) continue + const recents: PaletteRecentUsage[] = [] + for (const [rank, candidate] of rawRecents.entries()) { + const usage = normalizeUsage(candidate, rank) + if (!usage) continue + if (recents.some((recent) => recent.id === usage.id)) continue + recents.push(usage) + if (recents.length >= PALETTE_RECENTS_BOUND) break + } + if (recents.length > 0) workspaces[scope] = recents + } + return { version: PALETTE_RECENTS_VERSION, workspaces: boundScopes(workspaces) } +} + +/** + * Keeps the most recently written scopes. Insertion order is the recency + * order because `recordPaletteRecent` re-inserts the active scope last. + */ +function boundScopes( + workspaces: Record +): Record { + const scopes = Object.keys(workspaces) + if (scopes.length <= PALETTE_RECENTS_SCOPE_BOUND) return workspaces + const kept: Record = {} + for (const scope of scopes.slice(-PALETTE_RECENTS_SCOPE_BOUND)) { + kept[scope] = workspaces[scope]! + } + return kept +} + +/** + * Frecency: repeated use raises an entry, elapsed time lowers it. Something + * used ten times last week should outrank something used once this morning, + * which pure recency gets backwards. + */ +export function paletteFrecencyScore(usage: PaletteRecentUsage, now: number): number { + const ageMs = Math.max(0, now - usage.lastUsedAt) + const decay = Math.pow(0.5, ageMs / PALETTE_FRECENCY_HALF_LIFE_MS) + return usage.uses * decay +} + +function readStoredPaletteRecents(): StoredPaletteRecents { + const raw = readBrowserStorageItem(PALETTE_RECENTS_STORAGE_KEY) + if (!raw) return emptyStoredPaletteRecents() + try { + return normalizeStoredPaletteRecents(JSON.parse(raw)) + } catch { + return emptyStoredPaletteRecents() + } +} + +/** Workspace recents ordered by frecency, highest first. */ +export function readPaletteRecents( + scope: string, + now: number = Date.now() +): PaletteRecentIdentity[] { + if (!scope) return [] + return [...(readStoredPaletteRecents().workspaces[scope] ?? [])] + .sort((left, right) => + paletteFrecencyScore(right, now) - paletteFrecencyScore(left, now) || + right.lastUsedAt - left.lastUsedAt || + left.id.localeCompare(right.id)) + .map(({ source, id }) => ({ source, id })) +} + +/** + * Records one activation, incrementing its use count, and returns the + * updated frecency-ordered list so callers can render it without a re-read. + */ +export function recordPaletteRecent( + scope: string, + identity: PaletteRecentIdentity, + now: number = Date.now() +): PaletteRecentIdentity[] { + if (!scope || !isIdentityShape(identity)) return readPaletteRecents(scope, now) + const stored = readStoredPaletteRecents() + const previous = stored.workspaces[scope] ?? [] + const existing = previous.find((recent) => recent.id === identity.id) + // Several activations can land in the same millisecond. Without a strictly + // increasing stamp their frecency ties and the stable tiebreak decides the + // order, which would show the wrong entry first. + const stamp = Math.max(now, ...previous.map((recent) => recent.lastUsedAt + 1)) + const next: PaletteRecentUsage[] = [ + { + source: identity.source, + id: identity.id, + uses: (existing?.uses ?? 0) + 1, + lastUsedAt: stamp + }, + ...previous.filter((recent) => recent.id !== identity.id) + ] + .sort((left, right) => + paletteFrecencyScore(right, stamp) - paletteFrecencyScore(left, stamp) || + right.lastUsedAt - left.lastUsedAt) + .slice(0, PALETTE_RECENTS_BOUND) + + // Re-insert last so key order stays newest-scope-last for the scope bound. + delete stored.workspaces[scope] + stored.workspaces[scope] = next + writeBrowserStorageItem( + PALETTE_RECENTS_STORAGE_KEY, + JSON.stringify({ ...stored, workspaces: boundScopes(stored.workspaces) }) + ) + return next.map(({ source, id }) => ({ source, id })) +} diff --git a/src/renderer/src/palette/palette-scorer.test.ts b/src/renderer/src/palette/palette-scorer.test.ts new file mode 100644 index 000000000..35bc882e3 --- /dev/null +++ b/src/renderer/src/palette/palette-scorer.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import type { PaletteEntry, PaletteSourceKind } from './palette-model' +import { paletteMatchTier, parsePaletteQuery, rankPaletteEntries } from './palette-scorer' + +function makeEntry( + id: string, + source: PaletteSourceKind, + title: string, + overrides: Partial = {} +): PaletteEntry { + return { + id, + source, + title, + keywords: [], + activation: { kind: 'route', route: 'chat' }, + ...overrides + } +} + +describe('parsePaletteQuery', () => { + it('treats an empty query as the mixed scope', () => { + expect(parsePaletteQuery('')).toEqual({ scope: 'all', prefix: null, query: '' }) + expect(parsePaletteQuery(' ')).toEqual({ scope: 'all', prefix: null, query: '' }) + }) + + it('recognizes and strips the scope prefixes', () => { + expect(parsePaletteQuery('>plan')).toEqual({ scope: 'commands', prefix: '>', query: 'plan' }) + expect(parsePaletteQuery('@ bug')).toEqual({ scope: 'conversations', prefix: '@', query: 'bug' }) + expect(parsePaletteQuery('#pro')).toEqual({ scope: 'settings', prefix: '#', query: 'pro' }) + expect(parsePaletteQuery('/skill')).toEqual({ scope: 'slash', prefix: '/', query: 'skill' }) + }) + + it('keeps unprefixed text in the mixed scope and lower-cases it', () => { + expect(parsePaletteQuery('Write')).toEqual({ scope: 'all', prefix: null, query: 'write' }) + }) + + it('keeps a scoped query with no text as an empty scoped query', () => { + expect(parsePaletteQuery('#')).toEqual({ scope: 'settings', prefix: '#', query: '' }) + }) +}) + +describe('paletteMatchTier', () => { + const exact = makeEntry('a', 'route', 'New chat') + const prefix = makeEntry('b', 'route', 'New chat history') + const keyword = makeEntry('c', 'route', 'Conversations', { keywords: ['new thread'] }) + const subsequence = makeEntry('d', 'route', 'Compose', { keywords: [] }) + + it('ranks exact title above prefix', () => { + expect(paletteMatchTier('new chat', exact)).toBe(0) + expect(paletteMatchTier('new chat', prefix)).toBe(1) + }) + + it('matches keywords at the word-boundary tier', () => { + expect(paletteMatchTier('thread', keyword)).toBe(2) + }) + + it('matches title initials at the acronym tier', () => { + // "New chat history" -> initials "nch"; typing initials is the fastest + // way to reach a destination you already know. + expect(paletteMatchTier('nch', prefix)).toBe(3) + expect(paletteMatchTier('nh', prefix)).toBe(3) + }) + + it('does not invent an acronym for a single-word title', () => { + // "Compose" has one initial, already covered by the prefix tier, so a + // one-letter query must not win the acronym tier over a real prefix. + expect(paletteMatchTier('c', subsequence)).toBe(1) + }) + + it('matches via subsequence as the last tier', () => { + expect(paletteMatchTier('cmps', subsequence)).toBe(4) + }) + + it('returns -1 when nothing matches', () => { + expect(paletteMatchTier('zzz', subsequence)).toBe(-1) + }) + + it('returns tier 0 for an empty query', () => { + expect(paletteMatchTier('', subsequence)).toBe(0) + }) + + it('matches CJK titles through the subsequence tier', () => { + const entry = makeEntry('e', 'settings', '数据迁移', { keywords: ['迁移'] }) + expect(paletteMatchTier('数据', entry)).toBe(1) + expect(paletteMatchTier('据迁', entry)).toBe(4) + }) +}) + +describe('rankPaletteEntries', () => { + it('orders an exact match before a prefix match', () => { + const exact = makeEntry('exact', 'route', 'plan') + const prefix = makeEntry('prefix', 'route', 'planning') + const ranked = rankPaletteEntries([prefix, exact], parsePaletteQuery('plan')) + expect(ranked.map((entry) => entry.id)).toEqual(['exact', 'prefix']) + }) + + it('orders a keyword-only match below title matches', () => { + const titlePrefix = makeEntry('title', 'route', 'Plan mode') + const keywordOnly = makeEntry('keyword', 'route', 'Organize', { keywords: ['planning'] }) + const ranked = rankPaletteEntries([keywordOnly, titlePrefix], parsePaletteQuery('plan')) + expect(ranked.map((entry) => entry.id)).toEqual(['title', 'keyword']) + }) + + it('breaks same-tier ties by source priority', () => { + const command = makeEntry('cmd', 'shortcut-command', 'settings') + const thread = makeEntry('thread', 'thread', 'settings') + const ranked = rankPaletteEntries([thread, command], parsePaletteQuery('settings')) + expect(ranked.map((entry) => entry.id)).toEqual(['cmd', 'thread']) + }) + + it('breaks same-source ties by recency then stable identity', () => { + const older = makeEntry('z-older', 'route', 'chat') + const newer = makeEntry('a-newer', 'route', 'chat') + const ranked = rankPaletteEntries( + [older, newer], + parsePaletteQuery('chat'), + [{ source: 'route', id: 'a-newer' }] + ) + expect(ranked.map((entry) => entry.id)).toEqual(['a-newer', 'z-older']) + }) + + it('orders by stable identity when no recency applies', () => { + const beta = makeEntry('b', 'settings', 'general') + const alpha = makeEntry('a', 'settings', 'general') + const ranked = rankPaletteEntries([beta, alpha], parsePaletteQuery('general')) + expect(ranked.map((entry) => entry.id)).toEqual(['a', 'b']) + }) + + it('is deterministic across repeated evaluations', () => { + const entries = [ + makeEntry('c', 'thread', 'plan'), + makeEntry('a', 'settings', 'planning'), + makeEntry('b', 'route', 'plan') + ] + const first = rankPaletteEntries(entries, parsePaletteQuery('plan')) + const second = rankPaletteEntries(entries, parsePaletteQuery('plan')) + expect(second.map((entry) => entry.id)).toEqual(first.map((entry) => entry.id)) + }) + + it('restricts scoped queries to their sources', () => { + const entries = [ + makeEntry('settings', 'settings', 'providers'), + makeEntry('command', 'shortcut-command', 'providers'), + makeEntry('slash', 'slash-command', 'providers'), + makeEntry('thread', 'thread', 'providers') + ] + expect(rankPaletteEntries(entries, parsePaletteQuery('#providers')).map((e) => e.id)) + .toEqual(['settings']) + expect(rankPaletteEntries(entries, parsePaletteQuery('/providers')).map((e) => e.id)) + .toEqual(['slash']) + expect(rankPaletteEntries(entries, parsePaletteQuery('>providers')).map((e) => e.id).sort()) + .toEqual(['command', 'slash']) + expect(rankPaletteEntries(entries, parsePaletteQuery('@providers')).map((e) => e.id)) + .toEqual(['thread']) + }) + + it('returns every in-scope entry for an empty scoped query', () => { + const entries = [ + makeEntry('one', 'settings', 'general'), + makeEntry('two', 'settings', 'providers') + ] + expect(rankPaletteEntries(entries, parsePaletteQuery('#')).map((e) => e.id)) + .toEqual(['one', 'two']) + }) +}) diff --git a/src/renderer/src/palette/palette-scorer.ts b/src/renderer/src/palette/palette-scorer.ts new file mode 100644 index 000000000..44d050ab0 --- /dev/null +++ b/src/renderer/src/palette/palette-scorer.ts @@ -0,0 +1,151 @@ +import { + PALETTE_SOURCE_PRIORITY, + type PaletteEntry, + type PaletteRecentIdentity, + type PaletteSourceKind +} from './palette-model' + +export type PaletteQueryScope = 'all' | 'commands' | 'conversations' | 'settings' | 'slash' + +export type ParsedPaletteQuery = { + scope: PaletteQueryScope + /** The scope prefix character when one was recognized, otherwise null. */ + prefix: string | null + /** Normalized (lower-cased) query text with any scope prefix stripped. */ + query: string +} + +const SCOPE_PREFIXES: Record = { + '>': 'commands', + '@': 'conversations', + '#': 'settings', + '/': 'slash' +} + +const SCOPE_SOURCES: Record, readonly PaletteSourceKind[]> = { + commands: ['shortcut-command', 'slash-command'], + conversations: ['thread'], + settings: ['settings'], + slash: ['slash-command'] +} + +/** + * Splits a raw input value into a scope and a normalized query. + * Recognized leading prefixes are stripped before matching; any other + * leading character keeps the full text in the mixed 'all' scope. + */ +export function parsePaletteQuery(raw: string): ParsedPaletteQuery { + const trimmed = raw.trim() + if (!trimmed) return { scope: 'all', prefix: null, query: '' } + const prefix = trimmed[0] + const scope = SCOPE_PREFIXES[prefix] + if (!scope) return { scope: 'all', prefix: null, query: trimmed.toLowerCase() } + return { scope, prefix, query: trimmed.slice(1).trim().toLowerCase() } +} + +const WORD_SPLIT = /[^\p{L}\p{N}]+/u + +/** + * Word splitting is per-entry work that does not depend on the query, and + * matching runs over the whole catalog on every keystroke. Entries are + * immutable snapshots rebuilt by the source aggregators, so caching on the + * object identity stays correct and lets a stale snapshot be collected. + */ +const WORD_PARTS_CACHE = new WeakMap() + +function wordParts(entry: PaletteEntry): string[] { + const cached = WORD_PARTS_CACHE.get(entry) + if (cached) return cached + const parts: string[] = [] + for (const value of [entry.title, ...entry.keywords]) { + for (const word of value.toLowerCase().split(WORD_SPLIT)) { + if (word) parts.push(word) + } + } + WORD_PARTS_CACHE.set(entry, parts) + return parts +} + +function isSubsequence(query: string, value: string): boolean { + const haystack = value.toLowerCase() + let cursor = 0 + for (const char of query) { + cursor = haystack.indexOf(char, cursor) + if (cursor < 0) return false + cursor += 1 + } + return true +} + +/** + * The initials of each word in a title, e.g. "Keyboard shortcuts" -> "ks". + * Typing initials is how people reach a known destination fastest, so it + * gets its own tier rather than falling through to loose subsequence. + */ +export function acronymOf(text: string): string { + return text + .toLowerCase() + .split(WORD_SPLIT) + .filter(Boolean) + .map((word) => word[0] ?? '') + .join('') +} + +/** + * Matching tiers, lowest first: 0 exact title, 1 title prefix, + * 2 word-boundary on title or keywords, 3 title acronym, 4 subsequence on + * title or keywords, -1 no match. Purely deterministic over the query and + * entry. + */ +export function paletteMatchTier(query: string, entry: PaletteEntry): number { + const normalized = query.toLowerCase() + if (!normalized) return 0 + const title = entry.title.toLowerCase() + if (title === normalized) return 0 + if (title.startsWith(normalized)) return 1 + if (wordParts(entry).some((word) => word.startsWith(normalized))) return 2 + // Only multi-word titles have a meaningful acronym; for a single word the + // initial is already covered by the prefix tier. + const acronym = acronymOf(entry.title) + if (acronym.length > 1 && isSubsequence(normalized, acronym)) return 3 + if ( + isSubsequence(normalized, title) || + entry.keywords.some((keyword) => isSubsequence(normalized, keyword)) + ) { + return 4 + } + return -1 +} + +/** + * Filters to the parsed scope and orders matches by tier, then source + * priority, then per-workspace recency, then stable entry identity. + * An empty query ranks every in-scope entry without matching. + */ +export function rankPaletteEntries( + entries: readonly PaletteEntry[], + parsed: ParsedPaletteQuery, + recents: readonly PaletteRecentIdentity[] = [] +): PaletteEntry[] { + const allowedSources = parsed.scope === 'all' ? null : new Set(SCOPE_SOURCES[parsed.scope]) + const recencyRank = new Map() + recents.forEach((recent, index) => recencyRank.set(recent.id, index)) + + const scored: Array<{ entry: PaletteEntry; tier: number }> = [] + for (const entry of entries) { + if (allowedSources && !allowedSources.has(entry.source)) continue + const tier = parsed.query ? paletteMatchTier(parsed.query, entry) : 0 + if (tier < 0) continue + scored.push({ entry, tier }) + } + + scored.sort((left, right) => + left.tier - right.tier || + PALETTE_SOURCE_PRIORITY[left.entry.source] - PALETTE_SOURCE_PRIORITY[right.entry.source] || + (recencyRank.get(left.entry.id) ?? Number.POSITIVE_INFINITY) - + (recencyRank.get(right.entry.id) ?? Number.POSITIVE_INFINITY) || + left.entry.id.localeCompare(right.entry.id) + ) + + return scored.map((scored) => scored.entry) +} diff --git a/src/renderer/src/palette/palette-sources.test.ts b/src/renderer/src/palette/palette-sources.test.ts new file mode 100644 index 000000000..c06878594 --- /dev/null +++ b/src/renderer/src/palette/palette-sources.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from 'vitest' +import type { TFunction } from 'i18next' +import { resolveKeyboardShortcutBindings } from '@shared/keyboard-shortcuts' +import { ExtensionContributionsSchema } from '@kun/extension-api' +import { + ContributionRegistry, + ExtensionWorkbenchSnapshotSchema, + type ExtensionRightRailViewEntry +} from '../extensions/contribution-registry' +import type { AppRoute, SettingsRouteSection } from '../store/chat-store-types' +import type { PaletteThreadLike, PaletteSourcesInput } from './palette-sources' +import { + collectPaletteSources, + excludeDuplicateThreadMatches, + threadContentMatchEntries, + THREAD_SOURCE_SCAN_CAP +} from './palette-sources' + +const t = ((key: string): string => key) as TFunction +const tSettings = (key: string): string => key + +function baseInput(overrides: Partial = {}): PaletteSourcesInput { + return { + t, + tSettings, + route: 'chat', + workspaceRoot: '/Users/demo/project', + threads: [], + codeWorkspaceRoots: [], + runtimeReady: true, + busy: false, + activeThreadId: null, + activeThreadArchived: false, + canOpenGoalPanel: true, + canCreateNewThread: true, + hasPlanCommand: true, + hasBtwCommand: true, + hideBtwCommand: false, + hasReviewCommand: true, + skillCommands: [], + disabledSkillIds: [], + extensionRightRailItems: [], + shortcutBindings: resolveKeyboardShortcutBindings(null, 'darwin'), + hasComposerDraft: false, + composerModel: 'deepseek-v4-flash', + composerModelGroups: [ + { providerId: 'deepseek', label: 'DeepSeek', modelIds: ['deepseek-v4-flash', 'deepseek-v4'] }, + { providerId: 'anthropic', label: 'Anthropic', modelIds: ['claude-sonnet-5'] } + ], + activeThreadPinned: false, + ...overrides + } +} + +function rightRailEntries(trusted: boolean): ExtensionRightRailViewEntry[] { + const registry = new ContributionRegistry() + registry.replaceExtensions(ExtensionWorkbenchSnapshotSchema.parse({ + schemaVersion: 1, + revision: 1, + extensions: [{ + id: 'acme.issues', + version: '1.0.0', + workspaceTrusted: trusted, + grantedPermissions: ['ui.views', 'webview'], + // Untrusted workspaces surface only bounded discovery launchers, whose + // ids must not collide with any contributes declaration. + contributes: ExtensionContributionsSchema.parse(trusted + ? { + 'views.rightSidebar': [{ + id: 'issues', + title: 'Issues', + entry: 'dist/index.html', + icon: 'assets/issues.svg', + showInRightRail: true, + order: 10 + }] + } + : {}), + rightRailDiscovery: trusted + ? {} + : { + views: [{ + id: 'issues', + title: 'Issues', + icon: 'assets/issues.svg', + showInRightRail: true, + order: 10 + }] + } + }] + })) + return registry.listRightRailViewEntries({ workspaceOpen: true }) +} + +function thread(id: string, title: string, updatedAt: string, extra: Partial = {}): PaletteThreadLike { + return { id, title, updatedAt, archived: false, ...extra } +} + +describe('collectPaletteSources', () => { + it('aggregates every source kind with stable entry ids', () => { + const entries = collectPaletteSources(baseInput({ + threads: [thread('t1', 'Fix build', '2026-01-03T00:00:00Z')], + codeWorkspaceRoots: ['/Users/demo/project'], + extensionRightRailItems: rightRailEntries(false) + })) + const sources = new Set(entries.map((entry) => entry.source)) + expect(sources).toEqual(new Set([ + 'shortcut-command', 'slash-command', 'route', 'settings', 'thread', 'workspace', + 'extension-view', 'model' + ])) + const ids = new Set(entries.map((entry) => entry.id)) + expect(ids.size).toBe(entries.length) + }) + + it('lists every configured model and marks the active one', () => { + const entries = collectPaletteSources(baseInput()) + const models = entries.filter((entry) => entry.source === 'model') + expect(models.map((entry) => entry.title)) + .toEqual(['deepseek-v4-flash', 'deepseek-v4', 'claude-sonnet-5']) + expect(models[0].badge).toBe('paletteModelActiveBadge') + expect(models[1].badge).toBeUndefined() + expect(models[2].subtitle).toBe('Anthropic') + expect(models[2].activation).toEqual({ + kind: 'select-model', modelId: 'claude-sonnet-5', providerId: 'anthropic' + }) + // The provider is searchable, so "anthropic" narrows to its models. + expect(models[2].keywords).toContain('anthropic') + }) + + it('offers reversible actions on the active conversation, but never delete', () => { + const entries = collectPaletteSources(baseInput({ activeThreadId: 'thr_1' })) + const actions = entries.filter((entry) => entry.source === 'action') + expect(actions.map((entry) => entry.id)).toEqual(['action:pin', 'action:archive']) + expect(actions[0].activation).toEqual({ + kind: 'thread-action', action: 'pin', threadId: 'thr_1' + }) + // Deleting from a fuzzy-matched row is a trap; the sidebar keeps that + // behind an explicit confirmation instead. + expect(entries.some((entry) => JSON.stringify(entry.activation).includes('delete'))) + .toBe(false) + }) + + it('flips the pin action for an already pinned conversation', () => { + const entries = collectPaletteSources( + baseInput({ activeThreadId: 'thr_1', activeThreadPinned: true }) + ) + const pin = entries.find((entry) => entry.id === 'action:pin') + expect(pin?.activation).toEqual({ + kind: 'thread-action', action: 'unpin', threadId: 'thr_1' + }) + }) + + it('offers no conversation actions without an active or unarchived thread', () => { + expect(collectPaletteSources(baseInput({ activeThreadId: null })) + .some((entry) => entry.source === 'action')).toBe(false) + expect(collectPaletteSources( + baseInput({ activeThreadId: 'thr_1', activeThreadArchived: true }) + ).some((entry) => entry.source === 'action')).toBe(false) + }) + + it('lists every settings destination without a palette registration', () => { + const entries = collectPaletteSources(baseInput()) + const sections = entries + .filter((entry) => entry.source === 'settings') + .map((entry) => entry.activation.kind === 'settings' ? entry.activation.section : null) + .filter((section): section is SettingsRouteSection => Boolean(section)) + expect(sections).toContain('general') + expect(sections).toContain('providers') + expect(sections).toContain('shortcuts') + expect(sections).toContain('dataMigration') + for (const entry of entries.filter((candidate) => candidate.source === 'settings')) { + expect(entry.title).not.toBe('') + } + }) + + it('lists every top-level route with its localized label key', () => { + const entries = collectPaletteSources(baseInput()) + const routes = entries + .filter((entry) => entry.source === 'route') + .map((entry) => entry.activation.kind === 'route' ? entry.activation.route : null) + .filter((route): route is AppRoute => Boolean(route)) + expect(routes.sort()).toEqual([ + 'chat', 'claw', 'design', 'extensions', 'plugins', 'schedule', 'settings', 'workflow', 'write' + ]) + expect(entries.find((entry) => entry.id === 'route:workflow')?.title).toBe('workflowCreate') + }) + + it('includes every shortcut command except the palette itself', () => { + const entries = collectPaletteSources(baseInput()) + const commands = entries.filter((entry) => entry.source === 'shortcut-command') + expect(commands.some((entry) => entry.id === 'cmd:new-chat')).toBe(true) + // Opening the palette from inside the palette is not a destination. + expect(commands.some((entry) => entry.id === 'cmd:command-palette')).toBe(false) + expect(commands.find((entry) => entry.id === 'cmd:new-chat')?.badge).toBe('Ctrl+N') + }) + + it('lists unbound commands without a binding badge instead of hiding them', () => { + const entries = collectPaletteSources(baseInput()) + const commands = entries.filter((entry) => entry.source === 'shortcut-command') + // `minimize` and `toggle-maximize` ship with no default chord, which is + // exactly when a palette is the only way to reach them. + for (const id of ['cmd:minimize', 'cmd:toggle-maximize']) { + const entry = commands.find((candidate) => candidate.id === id) + expect(entry).toBeDefined() + expect(entry?.badge).toBeUndefined() + } + }) + + it('builds slash entries from the shared catalog with insert text and disabled reasons', () => { + const entries = collectPaletteSources(baseInput({ + skillCommands: [{ id: 'ppt', name: 'PPT Master', description: 'make decks', scope: 'global' }] + })) + const newCommand = entries.find((entry) => entry.id === 'slash:new') + expect(newCommand?.activation.kind === 'slash-command' && newCommand.activation.insertText).toBe('/new') + expect(newCommand?.badge).toBe('/new') + + const skill = entries.find((entry) => entry.id === 'slash:skill:ppt') + expect(skill?.activation.kind === 'slash-command' && skill.activation.insertText).toBe('/skill:ppt ') + expect(skill?.activation.kind === 'slash-command' && skill.activation.commandId).toBe('skill:ppt') + + const research = entries.find((entry) => entry.id === 'slash:research') + expect(research?.activation.kind === 'slash-command' && research.activation.insertText).toBe('/research ') + expect(research?.disabledReason).toBeUndefined() + + const disabled = collectPaletteSources(baseInput({ runtimeReady: false })) + .find((entry) => entry.id === 'slash:research') + expect(disabled?.disabled).toBe(true) + expect(disabled?.disabledReason).toBe('paletteDisabledDefault') + }) + + it('scans threads recency-first, skips archived threads, and enforces the scan cap', () => { + const many = Array.from({ length: THREAD_SOURCE_SCAN_CAP + 20 }, (_, index) => + thread('t' + index, 'Thread ' + index, new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString()) + ) + const archived = thread('archived', 'Archived', '2030-01-01T00:00:00Z', { archived: true }) + const entries = collectPaletteSources(baseInput({ threads: [...many, archived] })) + const threadEntries = entries.filter((entry) => entry.source === 'thread') + expect(threadEntries).toHaveLength(THREAD_SOURCE_SCAN_CAP) + expect(threadEntries[0].id).toBe('thread:t' + (THREAD_SOURCE_SCAN_CAP + 19)) + expect(threadEntries.some((entry) => entry.id === 'thread:archived')).toBe(false) + }) + + it('marks unreviewed extension contributions locked with bounded metadata', () => { + const entries = collectPaletteSources(baseInput({ + extensionRightRailItems: rightRailEntries(false) + })) + const locked = entries.find((entry) => entry.source === 'extension-view') + expect(locked?.id).toBe('ext:extension:acme.issues/issues') + expect(locked?.activation).toEqual({ + kind: 'extension-view', + entryId: 'extension:acme.issues/issues', + locked: true + }) + expect(locked?.badge).toBe('paletteLockedBadge') + expect(locked?.subtitle).toBe('paletteLockedReason') + expect(locked?.icon).toEqual({ + kind: 'extension', + extensionId: 'acme.issues', + iconPath: 'assets/issues.svg' + }) + }) + + it('marks trusted extension contributions unlocked', () => { + const entries = collectPaletteSources(baseInput({ + extensionRightRailItems: rightRailEntries(true) + })) + const unlocked = entries.find((entry) => entry.source === 'extension-view') + expect(unlocked?.activation).toEqual({ + kind: 'extension-view', + entryId: 'extension:acme.issues/issues', + locked: false + }) + expect(unlocked?.badge).toBeUndefined() + }) + + it('omits disabled extension contributions', () => { + const registry = new ContributionRegistry() + registry.replaceExtensions(ExtensionWorkbenchSnapshotSchema.parse({ + schemaVersion: 1, + revision: 1, + extensions: [{ + id: 'acme.disabled', + version: '1.0.0', + workspaceTrusted: false, + enabled: false, + grantedPermissions: ['ui.views', 'webview'], + contributes: ExtensionContributionsSchema.parse({}), + rightRailDiscovery: { + views: [{ id: 'panel', title: 'Panel', icon: 'a.svg', showInRightRail: true, order: 1 }] + } + }] + })) + const entries = collectPaletteSources(baseInput({ + extensionRightRailItems: registry.listRightRailViewEntries({ workspaceOpen: true }) + })) + expect(entries.some((entry) => entry.source === 'extension-view')).toBe(false) + }) + + it('falls back to a host icon when an extension declares none', () => { + const registry = new ContributionRegistry() + registry.replaceExtensions(ExtensionWorkbenchSnapshotSchema.parse({ + schemaVersion: 1, + revision: 1, + extensions: [{ + id: 'acme.plain', + version: '1.0.0', + workspaceTrusted: false, + grantedPermissions: ['ui.views', 'webview'], + contributes: ExtensionContributionsSchema.parse({}), + rightRailDiscovery: { + views: [{ id: 'panel', title: 'Panel', showInRightRail: true, order: 1 }] + } + }] + })) + const entries = collectPaletteSources(baseInput({ + extensionRightRailItems: registry.listRightRailViewEntries({ workspaceOpen: true }) + })) + const entry = entries.find((candidate) => candidate.source === 'extension-view') + expect(entry?.icon?.kind).toBe('lucide') + }) + + it('omits a source that cannot resolve and keeps the others', () => { + const entries = collectPaletteSources(baseInput({ extensionRightRailItems: [] })) + expect(entries.some((entry) => entry.source === 'extension-view')).toBe(false) + expect(entries.some((entry) => entry.source === 'route')).toBe(true) + expect(entries.some((entry) => entry.source === 'settings')).toBe(true) + }) + + it('maps deep-search matches to selectable conversation entries', () => { + const entries = threadContentMatchEntries([ + { + threadId: 'thr_1', + title: ' Payment flow ', + snippet: ' …checkout must be faster… ', + workspace: '/Users/demo/mocklyst' + } + ]) + expect(entries).toHaveLength(1) + expect(entries[0].id).toBe('content:thr_1') + expect(entries[0].title).toBe('Payment flow') + expect(entries[0].subtitle).toBe('…checkout must be faster…') + expect(entries[0].activation).toEqual({ kind: 'thread', threadId: 'thr_1' }) + }) + + it('badges each match with the project it belongs to', () => { + // Content search spans every project, so a row from elsewhere must say so + // rather than looking like a conversation in the current project. + const entries = threadContentMatchEntries([ + { threadId: 'thr_1', title: 'A', snippet: 's', workspace: '/Users/demo/mocklyst' }, + { threadId: 'thr_2', title: 'B', snippet: 's', workspace: '/Users/demo/kun/' }, + { threadId: 'thr_3', title: 'C', snippet: 's' } + ]) + expect(entries.map((entry) => entry.badge)).toEqual(['mocklyst', 'kun', undefined]) + // The project name is searchable too, so "mocklyst checkout" narrows down. + expect(entries[0].keywords).toContain('mocklyst') + }) + + it('drops content matches already surfaced by the regular thread source', () => { + const ranked = [{ + id: 'thread:thr_1', + source: 'thread' as const, + title: 'Payment flow', + keywords: [], + activation: { kind: 'thread' as const, threadId: 'thr_1' } + }] + const matches = threadContentMatchEntries([ + { threadId: 'thr_1', title: 'Payment flow', snippet: 'checkout' }, + { threadId: 'thr_2', title: 'Other', snippet: 'checkout' } + ]) + const visible = excludeDuplicateThreadMatches(matches, ranked) + expect(visible.map((entry) => entry.id)).toEqual(['content:thr_2']) + }) +}) diff --git a/src/renderer/src/palette/palette-sources.ts b/src/renderer/src/palette/palette-sources.ts new file mode 100644 index 000000000..3b5be06bc --- /dev/null +++ b/src/renderer/src/palette/palette-sources.ts @@ -0,0 +1,543 @@ +import type { TFunction } from 'i18next' +import type { KeyboardShortcutBindingsV1 } from '@shared/keyboard-shortcuts' +import { KEYBOARD_SHORTCUT_COMMANDS } from '@shared/keyboard-shortcuts' +import { + Archive, + Clock3, + Code2, + Command, + Cpu, + FolderOpen, + GitFork, + LayoutGrid, + ListTodo, + MessageCircleMore, + MessageSquare, + MessageSquarePlus, + Minimize2, + Palette, + PencilLine, + Pin, + Plus, + Puzzle, + RotateCcw, + Search, + SearchCode, + Settings, + Smartphone, + Sparkles, + Target, + Workflow, + type LucideIcon +} from 'lucide-react' +import type { ModelProviderModelGroup } from '@shared/kun-gui-api' +import type { NormalizedThread } from '../agent/types' +import type { AppRoute, SettingsRouteSection } from '../store/chat-store-types' +import { + CANONICAL_SLASH_COMMAND_TEXT, + type BuiltinSlashCommandId, + type SlashCommand +} from '../components/chat/floating-composer-commands' +import { + buildComposerSlashCommands, + type ComposerSkillCommand +} from '../components/chat/use-composer-slash-command-menu' +import { + type ExtensionRightRailViewEntry +} from '../extensions/contribution-registry' +import { boundedPlainText } from '../extensions/safe-text' +import type { PaletteEntry, PaletteIcon } from './palette-model' + +export const THREAD_SOURCE_SCAN_CAP = 100 + +export type PaletteThreadLike = Pick< + NormalizedThread, + 'id' | 'title' | 'preview' | 'summary' | 'updatedAt' | 'archived' +> + +export type PaletteSourcesInput = { + t: TFunction + tSettings: (key: string) => string + route: AppRoute + workspaceRoot: string + /** In-scope code conversations, already filtered by the sidebar scope rules. */ + threads: readonly PaletteThreadLike[] + codeWorkspaceRoots: readonly string[] + runtimeReady: boolean + busy: boolean + activeThreadId: string | null + activeThreadArchived: boolean + canOpenGoalPanel: boolean + canCreateNewThread: boolean + hasPlanCommand: boolean + hasBtwCommand: boolean + hideBtwCommand: boolean + hasReviewCommand: boolean + skillCommands: ComposerSkillCommand[] + disabledSkillIds?: string[] + extensionRightRailItems: readonly ExtensionRightRailViewEntry[] + shortcutBindings: Required + /** Blocks the compose fallback so it can never discard a pending draft. */ + hasComposerDraft: boolean + /** Model id the composer will send with, used to mark the active row. */ + composerModel: string + /** Configured provider groups; the palette lists every model they expose. */ + composerModelGroups: readonly ModelProviderModelGroup[] + activeThreadPinned: boolean +} + +/** + * Route label keys live in the common namespace; the Record type makes a + * newly added AppRoute fail compilation until it gets localized copy. + */ +const ROUTE_LABEL_KEYS: Record = { + chat: 'code', + write: 'write', + design: 'design', + settings: 'settings', + plugins: 'plugins', + extensions: 'extensions', + claw: 'claw', + schedule: 'schedule', + workflow: 'workflowCreate' +} + +const ROUTE_ICONS: Record = { + chat: Code2, + write: PencilLine, + design: Palette, + settings: Settings, + plugins: LayoutGrid, + extensions: Puzzle, + claw: Smartphone, + schedule: Clock3, + workflow: Workflow +} + +/** + * Settings destination labels live in the settings namespace; the Record + * type makes a newly added SettingsRouteSection fail compilation until it + * gets localized copy, so new destinations are reachable from the palette + * without a separate palette registration. + */ +const SETTINGS_SECTION_LABEL_KEYS: Record = { + general: 'general', + providers: 'providers', + extensions: 'extensions', + write: 'write', + design: 'design', + imageGeneration: 'mediaGeneration', + mediaGeneration: 'mediaGeneration', + speechToText: 'settingsNavSpeech', + agents: 'settingsNavAssistant', + laboratory: 'agentsQuickLaboratory', + subagents: 'subagents', + archives: 'settingsNavArchives', + worktree: 'worktree', + memory: 'memory', + permissions: 'permissions', + skill: 'skill', + mcp: 'mcp', + shortcuts: 'keyboardShortcuts', + easterEgg: 'settingsNavAppearance', + claw: 'settingsNavPhone', + updates: 'settingsNavUpdates', + terminal: 'terminal', + debug: 'debug', + storage: 'storageRelocation', + dataMigration: 'settingsNavMigration' +} + +const SETTINGS_SECTION_DESCRIPTION_KEYS: Partial> = { + general: 'subtitle', + providers: 'providersDesc', + extensions: 'extensionsDesc', + write: 'writeDesc', + design: 'designDesc', + imageGeneration: 'mediaGenerationDesc', + mediaGeneration: 'mediaGenerationDesc', + speechToText: 'speechToTextEnabledDesc', + agents: 'kunProviderDesc', + laboratory: 'laboratorySettingsDesc', + subagents: 'subagentsSettingsIntro', + archives: 'archivesOverviewDesc', + worktree: 'worktreeOverviewDesc', + memory: 'memoryOverviewDesc', + shortcuts: 'shortcutsDesc', + easterEgg: 'uiModeWorkshopDesc', + claw: 'clawEnabledDesc', + updates: 'guiUpdateDesc', + debug: 'llmDebugDesc', + terminal: 'terminalColorModeDesc', + storage: 'storageRelocationSubtitle', + dataMigration: 'dataMigrationSubtitle' +} + +const BUILTIN_SLASH_ICONS: Record = { + new: Plus, + plan: ListTodo, + goal: Target, + research: Search, + review: SearchCode, + compact: Minimize2, + fork: GitFork, + archive: Archive, + restore: RotateCcw, + btw: MessageCircleMore +} + +const PALETTE_HIDDEN_SHORTCUT_COMMANDS = new Set(['command-palette']) + +/** + * Settings sections that resolve to a destination another section already + * offers. `imageGeneration` is a legacy alias that opens the same category as + * `mediaGeneration` (see SettingsView), so listing both would put two rows + * with different labels in front of one destination. The label Record above + * still covers every section, so a genuinely new destination keeps failing + * compilation until it gets copy. + */ +const PALETTE_ALIAS_SETTINGS_SECTIONS = new Set(['imageGeneration']) + +function slashCommandIcon(command: SlashCommand): LucideIcon { + if (command.kind === 'skill') return Sparkles + return BUILTIN_SLASH_ICONS[command.id as BuiltinSlashCommandId] ?? Command +} + +function workspaceDisplayName(root: string): string { + const trimmed = root.trim() + const segments = trimmed.split('/').filter(Boolean) + const plain = (segments.at(-1) ?? trimmed).split('\\').filter(Boolean).pop() + return plain ?? trimmed +} + +function canonicalSlashInsert(command: SlashCommand): string { + if (command.kind === 'skill') { + return command.skillPrompt ?? '/skill:' + command.id.replace(/^skill:/, '') + ' ' + } + return CANONICAL_SLASH_COMMAND_TEXT[command.id as BuiltinSlashCommandId] +} + +function shortcutCommandEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { tSettings, shortcutBindings } = input + const entries: PaletteEntry[] = [] + for (const command of KEYBOARD_SHORTCUT_COMMANDS) { + if (PALETTE_HIDDEN_SHORTCUT_COMMANDS.has(command.id)) continue + // A command with no chord is exactly the one a palette is most useful + // for, so it is listed without a binding badge rather than skipped. + const binding = shortcutBindings[command.id]?.[0] + entries.push({ + id: 'cmd:' + command.id, + source: 'shortcut-command', + title: tSettings(command.labelKey), + subtitle: tSettings(command.descriptionKey), + keywords: [command.id], + ...(binding ? { badge: binding } : {}), + icon: { kind: 'lucide', icon: Command }, + activation: { kind: 'shortcut-command', commandId: command.id } + }) + } + return entries +} + +function routeEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { t } = input + return (Object.keys(ROUTE_LABEL_KEYS) as AppRoute[]).map((route) => { + const title = t(ROUTE_LABEL_KEYS[route]) + return { + id: 'route:' + route, + source: 'route' as const, + title, + keywords: [route, title], + icon: { kind: 'lucide' as const, icon: ROUTE_ICONS[route] }, + activation: { kind: 'route' as const, route } + } + }) +} + +function settingsEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { tSettings } = input + const entries: PaletteEntry[] = [] + for (const section of Object.keys(SETTINGS_SECTION_LABEL_KEYS) as SettingsRouteSection[]) { + if (PALETTE_ALIAS_SETTINGS_SECTIONS.has(section)) continue + const title = tSettings(SETTINGS_SECTION_LABEL_KEYS[section]) + const descriptionKey = SETTINGS_SECTION_DESCRIPTION_KEYS[section] + const subtitle = descriptionKey ? tSettings(descriptionKey) : undefined + entries.push({ + id: 'settings:' + section, + source: 'settings', + title, + subtitle: subtitle && subtitle !== title ? subtitle : undefined, + keywords: [section, title], + icon: { kind: 'lucide', icon: Settings }, + activation: { kind: 'settings', section } + }) + } + return entries +} + +function threadEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { t, threads } = input + const sorted = [...threads] + .filter((thread) => thread.archived !== true) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) + .slice(0, THREAD_SOURCE_SCAN_CAP) + return sorted.map((thread) => { + const title = thread.title?.trim() || t('paletteUntitledThread') + // Bound the preview before it reaches keywords: matching splits every + // keyword on each keystroke, and thread previews are unbounded. + const preview = boundedPlainText( + thread.preview?.trim() || thread.summary?.trim() || '', + 160 + ) + return { + id: 'thread:' + thread.id, + source: 'thread' as const, + title, + subtitle: preview || undefined, + keywords: [thread.id, title, preview], + icon: { kind: 'lucide' as const, icon: MessageSquare }, + activation: { kind: 'thread' as const, threadId: thread.id } + } + }) +} + +function workspaceEntries(input: PaletteSourcesInput): PaletteEntry[] { + return input.codeWorkspaceRoots.map((root) => ({ + id: 'workspace:' + root, + source: 'workspace' as const, + title: workspaceDisplayName(root), + subtitle: root, + keywords: [root, workspaceDisplayName(root)], + icon: { kind: 'lucide' as const, icon: FolderOpen }, + activation: { kind: 'workspace' as const, workspaceRoot: root } + })) +} + +function slashCommandEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { + t, route, runtimeReady, busy, activeThreadId, activeThreadArchived, + canOpenGoalPanel, canCreateNewThread, workspaceRoot, hasPlanCommand, + hasBtwCommand, hideBtwCommand, hasReviewCommand, skillCommands, disabledSkillIds + } = input + const commands = buildComposerSlashCommands({ + t, + route, + runtimeReady, + busy, + activeThreadId, + activeThreadArchived, + canOpenGoalPanel, + canCreateNewThread, + workspaceRoot, + hasPlanCommand, + hasBtwCommand, + hideBtwCommand, + hasReviewCommand, + skillCommands, + disabledSkillIds + }) + return commands.map((command) => { + const insertText = canonicalSlashInsert(command) + return { + id: 'slash:' + command.id, + source: 'slash-command' as const, + title: command.title, + subtitle: command.description, + keywords: [command.id, ...command.keywords], + badge: insertText.trim(), + icon: { kind: 'lucide' as const, icon: slashCommandIcon(command) }, + disabled: command.disabled === true, + disabledReason: command.disabled === true ? t('paletteDisabledDefault') : undefined, + activation: { kind: 'slash-command' as const, commandId: command.id, insertText } + } + }) +} + +function extensionEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { t, extensionRightRailItems } = input + const entries: PaletteEntry[] = [] + for (const item of extensionRightRailItems) { + if (item.owner.kind !== 'extension') continue + const payload = item.payload + const locked = item.workspaceTrusted === false + let icon: PaletteIcon = { kind: 'lucide', icon: Puzzle } + if (payload.icon) { + icon = { kind: 'extension', extensionId: item.owner.extensionId, iconPath: payload.icon } + } + entries.push({ + id: 'ext:' + item.id, + source: 'extension-view', + title: boundedPlainText(payload.title, 128), + subtitle: locked ? t('paletteLockedReason') : undefined, + keywords: [item.id, item.owner.extensionId, payload.id], + badge: locked ? t('paletteLockedBadge') : undefined, + icon, + activation: { kind: 'extension-view', entryId: item.id, locked } + }) + } + return entries +} + +/** + * Aggregates palette results from the existing registries and store state. + * Any source that cannot resolve is simply omitted; nothing here mutates + * state or executes extension code. + */ +export function collectPaletteSources(input: PaletteSourcesInput): PaletteEntry[] { + return [ + ...shortcutCommandEntries(input), + ...slashCommandEntries(input), + ...routeEntries(input), + ...settingsEntries(input), + ...activeThreadActionEntries(input), + ...threadEntries(input), + ...modelEntries(input), + ...workspaceEntries(input), + ...extensionEntries(input) + ] +} + +/** + * Every model the user has configured, so switching is one query away instead + * of a trip to the composer's picker. The active model is listed but marked, + * since selecting it again is a harmless no-op and hiding it would make the + * list look wrong. + */ +function modelEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { t, composerModel, composerModelGroups } = input + const entries: PaletteEntry[] = [] + const seen = new Set() + for (const group of composerModelGroups) { + for (const modelId of group.modelIds) { + const id = 'model:' + group.providerId + ':' + modelId + if (seen.has(id)) continue + seen.add(id) + const active = modelId === composerModel + entries.push({ + id, + source: 'model', + title: modelId, + subtitle: group.label, + keywords: [group.providerId, group.label], + ...(active ? { badge: t('paletteModelActiveBadge') } : {}), + icon: { kind: 'lucide', icon: Cpu }, + activation: { kind: 'select-model', modelId, providerId: group.providerId } + }) + } + } + return entries +} + +/** + * Reversible actions on the conversation you are already in. + * + * Deleting a thread is deliberately absent: a destructive action reachable by + * fuzzy-matching a mistyped query is a trap, and the sidebar already offers it + * behind an explicit confirmation. + */ +function activeThreadActionEntries(input: PaletteSourcesInput): PaletteEntry[] { + const { t, activeThreadId, activeThreadArchived, activeThreadPinned } = input + if (!activeThreadId || activeThreadArchived) return [] + return [ + { + id: 'action:pin', + source: 'action' as const, + title: activeThreadPinned ? t('paletteActionUnpinThread') : t('paletteActionPinThread'), + subtitle: t('paletteActionThreadScope'), + keywords: ['pin', 'unpin', 'favorite'], + icon: { kind: 'lucide' as const, icon: Pin }, + activation: { + kind: 'thread-action' as const, + action: activeThreadPinned ? ('unpin' as const) : ('pin' as const), + threadId: activeThreadId + } + }, + { + id: 'action:archive', + source: 'action' as const, + title: t('paletteActionArchiveThread'), + subtitle: t('paletteActionThreadScope'), + keywords: ['archive', 'hide'], + icon: { kind: 'lucide' as const, icon: Archive }, + activation: { + kind: 'thread-action' as const, + action: 'archive' as const, + threadId: activeThreadId + } + } + ] +} + +/** + * Turns an unmatched query into an offer rather than a dead end: the text the + * user typed is almost always something they wanted to say. Only offered with + * an empty composer, so this can never discard a pending draft. + */ +export function composeFallbackEntries(input: { + t: TFunction + rawQuery: string + canCreateNewThread: boolean + hasComposerDraft: boolean +}): PaletteEntry[] { + const text = input.rawQuery.trim() + if (!text || !input.canCreateNewThread || input.hasComposerDraft) return [] + return [{ + id: 'compose:new-chat', + source: 'compose', + title: input.t('paletteComposeWithQuery', { query: boundedPlainText(text, 80) }), + subtitle: input.t('paletteComposeWithQueryDesc'), + keywords: [], + icon: { kind: 'lucide', icon: MessageSquarePlus }, + activation: { kind: 'compose', text } + }] +} + +export type ThreadContentMatchLike = { + threadId: string + title: string + snippet: string + workspace?: string +} + +/** + * Maps runtime deep-search matches to conversation palette entries. + * + * Content search spans every project, so each row is badged with the project + * it belongs to; without that a result from another project reads as one from + * the current one and activating it silently switches context. + */ +export function threadContentMatchEntries( + matches: readonly ThreadContentMatchLike[] +): PaletteEntry[] { + return matches.map((match) => { + const project = match.workspace ? workspaceDisplayName(match.workspace) : '' + return { + id: 'content:' + match.threadId, + source: 'thread' as const, + title: match.title.trim() || match.threadId, + subtitle: match.snippet.trim() || undefined, + keywords: [match.threadId, project], + ...(project ? { badge: project } : {}), + icon: { kind: 'lucide' as const, icon: MessageSquare }, + activation: { kind: 'thread' as const, threadId: match.threadId } + } + }) +} + +/** + * Drops content matches for conversations the regular thread source already + * surfaced, so a term matching both a title and a message shows once. + */ +export function excludeDuplicateThreadMatches( + matches: readonly PaletteEntry[], + ranked: readonly PaletteEntry[] +): PaletteEntry[] { + const covered = new Set() + for (const entry of ranked) { + if (entry.source !== 'thread' || entry.activation.kind !== 'thread') continue + covered.add(entry.activation.threadId) + } + return matches.filter((match) => + match.activation.kind === 'thread' && !covered.has(match.activation.threadId) + ) +} diff --git a/src/renderer/src/palette/palette-store.ts b/src/renderer/src/palette/palette-store.ts new file mode 100644 index 000000000..98aac27a6 --- /dev/null +++ b/src/renderer/src/palette/palette-store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' + +type CommandPaletteStoreState = { + open: boolean + openPalette: () => void + closePalette: () => void +} + +/** + * Minimal renderer-owned state slice for the palette overlay. Opening is + * idempotent so a repeated chord never resets an in-progress query. + */ +export const useCommandPaletteStore = create((set) => ({ + open: false, + openPalette: () => set({ open: true }), + closePalette: () => set({ open: false }) +})) diff --git a/src/renderer/src/palette/useSettingsCommandPaletteShortcut.ts b/src/renderer/src/palette/useSettingsCommandPaletteShortcut.ts new file mode 100644 index 000000000..b2f40e171 --- /dev/null +++ b/src/renderer/src/palette/useSettingsCommandPaletteShortcut.ts @@ -0,0 +1,40 @@ +import { useEffect, useMemo } from 'react' +import { resolveKeyboardShortcutBindings } from '@shared/keyboard-shortcuts' +import { useKeyboardShortcutSettings } from '../lib/keyboard-shortcut-settings' +import { isNativeDialogOpen } from '../lib/native-dialog-activity' +import { resolveWorkbenchShortcutKeyDown } from '../components/workbench/useWorkbenchKeyboardShortcuts' +import { useCommandPaletteStore } from './palette-store' + +/** + * Keeps the palette chord alive on the Settings route. + * + * AppShell renders SettingsView *instead of* Workbench, so the workbench + * keydown listener and the palette overlay are both unmounted here. Without + * this the palette would be dead on the one route its own settings entries + * navigate to. Settings owns no palette sources of its own, so the chord + * returns to the route the user came from and opens the palette there. + */ +export function useSettingsCommandPaletteShortcut(closeSettings: () => void): void { + const keyboardShortcuts = useKeyboardShortcutSettings() + const shortcutPlatform = typeof window === 'undefined' ? undefined : window.kunGui?.platform + const bindings = useMemo( + () => resolveKeyboardShortcutBindings(keyboardShortcuts, shortcutPlatform), + [keyboardShortcuts, shortcutPlatform] + ) + const openPalette = useCommandPaletteStore((state) => state.openPalette) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + const commandId = resolveWorkbenchShortcutKeyDown(event, bindings, { + slashMenuOpen: false, + nativeDialogOpen: isNativeDialogOpen() + }) + if (commandId !== 'command-palette') return + event.preventDefault() + closeSettings() + openPalette() + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [bindings, closeSettings, openPalette]) +} diff --git a/src/renderer/src/palette/useWorkbenchCommandPalette.test.ts b/src/renderer/src/palette/useWorkbenchCommandPalette.test.ts new file mode 100644 index 000000000..d1d09e969 --- /dev/null +++ b/src/renderer/src/palette/useWorkbenchCommandPalette.test.ts @@ -0,0 +1,506 @@ +import { createElement, type ReactElement } from 'react' +import { act, create as createRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TFunction } from 'i18next' +import { resolveKeyboardShortcutBindings } from '@shared/keyboard-shortcuts' +import type { PaletteEntry } from './palette-model' +import { DEFAULT_PALETTE_ENTRY_IDS } from './palette-model' +import { PALETTE_RECENTS_STORAGE_KEY } from './palette-recents' +import type { PaletteSourcesInput, PaletteThreadLike } from './palette-sources' +import { useCommandPaletteStore } from './palette-store' +import { + useWorkbenchCommandPalette, + type PaletteActivationHandlers, + type PaletteContentSearch, + type PaletteResultGroup +} from './useWorkbenchCommandPalette' + +const t = ((key: string): string => key) as TFunction +const tSettings = (key: string): string => key + +function thread(id: string, title: string, updatedAt: string): PaletteThreadLike { + return { id, title, updatedAt, archived: false } +} + +function baseInput(overrides: Partial = {}): PaletteSourcesInput { + return { + t, + tSettings, + route: 'chat', + workspaceRoot: '/Users/demo/project', + threads: [ + thread('thr_a', 'Alpha billing', '2026-08-14T10:00:00.000Z'), + thread('thr_b', 'Beta checkout', '2026-08-13T10:00:00.000Z') + ], + codeWorkspaceRoots: ['/Users/demo/project'], + runtimeReady: true, + busy: false, + activeThreadId: 'thr_a', + activeThreadArchived: false, + canOpenGoalPanel: true, + canCreateNewThread: true, + hasPlanCommand: true, + hasBtwCommand: true, + hideBtwCommand: false, + hasReviewCommand: true, + skillCommands: [], + disabledSkillIds: [], + extensionRightRailItems: [], + shortcutBindings: resolveKeyboardShortcutBindings(null, 'darwin'), + hasComposerDraft: false, + composerModel: 'deepseek-v4-flash', + composerModelGroups: [ + { providerId: 'deepseek', label: 'DeepSeek', modelIds: ['deepseek-v4-flash', 'deepseek-v4'] }, + { providerId: 'anthropic', label: 'Anthropic', modelIds: ['claude-sonnet-5'] } + ], + activeThreadPinned: false, + ...overrides + } +} + +type PaletteApi = ReturnType + +function noopHandlers(): PaletteActivationHandlers { + return { + route: vi.fn(), + settings: vi.fn(), + thread: vi.fn(), + workspace: vi.fn(), + 'shortcut-command': vi.fn(), + 'slash-command': vi.fn(), + 'extension-view': vi.fn(() => true), + compose: vi.fn(), + 'select-model': vi.fn(), + 'thread-action': vi.fn(), + unavailable: vi.fn() + } +} + +async function mountPalette(options: { + handlers?: PaletteActivationHandlers + sources?: Partial + searchThreadContent?: PaletteContentSearch +} = {}): Promise<{ current: () => PaletteApi }> { + let latest!: PaletteApi + function Probe(): ReactElement | null { + latest = useWorkbenchCommandPalette({ + ...baseInput(options.sources), + handlers: options.handlers ?? noopHandlers(), + ...(options.searchThreadContent + ? { searchThreadContent: options.searchThreadContent } + : {}) + }) + return null + } + await act(async () => { + createRenderer(createElement(Probe)) + }) + return { current: () => latest } +} + +function allRenderedIds(api: PaletteApi): string[] { + // Mirrors how the overlay concatenates: results first, then groups. + const groupEntries = (api.groups ?? []).flatMap((group: PaletteResultGroup) => group.entries) + return [...api.results, ...groupEntries].map((entry: PaletteEntry) => entry.id) +} + +function createMemoryStorage(): Storage { + const store = new Map() + return { + get length() { + return store.size + }, + clear: () => store.clear(), + getItem: (key) => (store.has(key) ? (store.get(key) ?? null) : null), + key: (index) => [...store.keys()][index] ?? null, + removeItem: (key) => { + store.delete(key) + }, + setItem: (key, value) => { + store.set(key, String(value)) + } + } +} + +let storage: Storage + +describe('useWorkbenchCommandPalette', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + storage = createMemoryStorage() + vi.stubGlobal('localStorage', storage) + // Timer calls delegate at call time so `vi.useFakeTimers` still applies. + vi.stubGlobal('window', { + localStorage: storage, + setTimeout: (handler: () => void, ms?: number) => globalThis.setTimeout(handler, ms), + clearTimeout: (id: number) => globalThis.clearTimeout(id) + }) + act(() => { + useCommandPaletteStore.setState({ open: true }) + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('browses the whole capability surface on the empty query, without repeats', async () => { + const palette = await mountPalette() + const api = palette.current() + + // The catalog is rendered as grouped sections, never as a flat list + // duplicated beneath them — that duplication was the original bug. + expect(api.results).toEqual([]) + expect((api.groups ?? []).map((group) => group.key)).toEqual([ + 'default', + 'browse:actions', + 'browse:commands', + 'browse:navigation', + 'browse:settings', + 'browse:models', + 'browse:conversations', + 'browse:projects' + ]) + + const rendered = allRenderedIds(api) + expect(new Set(rendered).size).toBe(rendered.length) + // Quick actions stay first, and every one of them appears exactly once. + expect((api.groups ?? [])[0].entries.map((entry) => entry.id)) + .toEqual(DEFAULT_PALETTE_ENTRY_IDS.filter((id) => rendered.includes(id))) + }) + + it('lists every command, route, setting and model in the browse sections', async () => { + const palette = await mountPalette() + const api = palette.current() + const rendered = new Set(allRenderedIds(api)) + + // Nothing in the catalog is unreachable from the opening view. + const missing = api.results.length === 0 + ? [...new Set( + (api.groups ?? []).flatMap((group) => group.entries).map((entry) => entry.source) + )] + : [] + expect(missing).toContain('settings') + expect(rendered.has('route:write')).toBe(true) + expect(rendered.has('settings:providers')).toBe(true) + expect(rendered.has('model:anthropic:claude-sonnet-5')).toBe(true) + expect(rendered.has('cmd:toggle-terminal')).toBe(true) + }) + + it('previews conversations rather than listing every one', async () => { + const many = Array.from({ length: 30 }, (_, index) => + thread('thr_' + index, 'Conversation ' + index, '2026-08-1' + (index % 5) + 'T10:00:00.000Z')) + const palette = await mountPalette({ sources: { threads: many } }) + const conversations = (palette.current().groups ?? []) + .find((group) => group.key === 'browse:conversations') + // Content, not capability: the rest are one keystroke away. + expect(conversations?.entries.length).toBe(8) + }) + + it('lists recents ahead of defaults and never twice', async () => { + storage.setItem(PALETTE_RECENTS_STORAGE_KEY, JSON.stringify({ + version: 1, + workspaces: { + '/Users/demo/project': [ + { source: 'route', id: 'route:write' }, + { source: 'settings', id: 'settings:providers' } + ] + } + })) + const palette = await mountPalette() + const api = palette.current() + + const groups = api.groups ?? [] + // Recents lead, quick actions follow, then the browsable catalog. + expect(groups.slice(0, 2).map((group) => group.key)).toEqual(['recent', 'default']) + expect(groups[0].entries.map((entry) => entry.id)).toEqual([ + 'route:write', 'settings:providers' + ]) + // A promoted entry must not also appear in its browse section. + const rendered = allRenderedIds(api) + expect(new Set(rendered).size).toBe(rendered.length) + expect(rendered.filter((id) => id === 'route:write')).toHaveLength(1) + expect(rendered.filter((id) => id === 'settings:providers')).toHaveLength(1) + }) + + it('ranks the catalog once a query is typed', async () => { + const palette = await mountPalette() + await act(async () => { + palette.current().setQuery('billing') + }) + const api = palette.current() + expect(api.results.some((entry) => entry.id === 'thread:thr_a')).toBe(true) + expect(api.results.some((entry) => entry.id === 'thread:thr_b')).toBe(false) + // No curated groups compete with a real query. + expect(api.groups).toBeNull() + }) + + it('searches every project and skips short queries', async () => { + vi.useFakeTimers() + try { + const searchThreadContent = vi.fn(async () => []) + const palette = await mountPalette({ + searchThreadContent, + sources: { workspaceRoot: '/Users/demo/project' } + }) + + // Set the query and advance in separate acts: effects only flush when + // act exits, so the debounce timer does not exist until then. + await act(async () => { + palette.current().setQuery('x') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + expect(searchThreadContent).not.toHaveBeenCalled() + + await act(async () => { + palette.current().setQuery('checkout') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + // No workspace is sent: recalling a discussion rarely comes with + // recalling which project it happened in. + expect(searchThreadContent).toHaveBeenCalledWith('checkout', { limit: 8 }) + } finally { + vi.useRealTimers() + } + }) + + it('reports pending from the keystroke until the search settles', async () => { + vi.useFakeTimers() + try { + let release: (value: never[]) => void = () => {} + const searchThreadContent = vi.fn( + () => new Promise((resolve) => { release = resolve }) + ) + const palette = await mountPalette({ searchThreadContent }) + expect(palette.current().contentSearchPending).toBe(false) + + await act(async () => { + palette.current().setQuery('checkout') + }) + // Pending covers the debounce window, before any request exists. + expect(searchThreadContent).not.toHaveBeenCalled() + expect(palette.current().contentSearchPending).toBe(true) + + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + expect(searchThreadContent).toHaveBeenCalled() + expect(palette.current().contentSearchPending).toBe(true) + + await act(async () => { + release([]) + await vi.advanceTimersByTimeAsync(0) + }) + expect(palette.current().contentSearchPending).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('clears pending when a search fails', async () => { + vi.useFakeTimers() + try { + const searchThreadContent = vi.fn( + async () => { throw new Error('runtime unavailable') } + ) + const palette = await mountPalette({ searchThreadContent }) + await act(async () => { + palette.current().setQuery('checkout') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + expect(palette.current().contentSearchPending).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('is not pending for queries that never reach deep search', async () => { + vi.useFakeTimers() + try { + const palette = await mountPalette({ searchThreadContent: async () => [] }) + for (const query of ['x', '>terminal', '#media']) { + await act(async () => { + palette.current().setQuery(query) + }) + expect(palette.current().contentSearchPending).toBe(false) + } + } finally { + vi.useRealTimers() + } + }) + + it('does not run deep search for command or settings scopes', async () => { + vi.useFakeTimers() + try { + const searchThreadContent = vi.fn(async () => []) + const palette = await mountPalette({ searchThreadContent }) + await act(async () => { + palette.current().setQuery('>terminal') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + expect(searchThreadContent).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('offers to turn an unmatched query into a prompt instead of dead-ending', async () => { + vi.useFakeTimers() + const handlers = noopHandlers() + const palette = await mountPalette({ handlers, searchThreadContent: async () => [] }) + await act(async () => { + palette.current().setQuery('zzzz nothing matches this zzzz') + }) + // The offer waits for deep search to settle, so it can never appear while + // a conversation match might still arrive. + expect(palette.current().groups).toBeNull() + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + vi.useRealTimers() + const api = palette.current() + expect(api.results).toEqual([]) + const compose = (api.groups ?? []).find((group) => group.key === 'compose') + expect(compose?.entries[0]?.id).toBe('compose:new-chat') + + await act(async () => { + palette.current().activate(compose!.entries[0]!) + }) + expect(handlers.compose).toHaveBeenCalledWith('zzzz nothing matches this zzzz') + }) + + it('never offers the compose fallback over a pending draft', async () => { + vi.useFakeTimers() + try { + const palette = await mountPalette({ + sources: { hasComposerDraft: true }, + searchThreadContent: async () => [] + }) + await act(async () => { + palette.current().setQuery('zzzz nothing matches this zzzz') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(400) + }) + expect(palette.current().groups).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('does not offer the compose fallback when real results matched', async () => { + const palette = await mountPalette() + await act(async () => { + palette.current().setQuery('billing') + }) + const api = palette.current() + expect(api.results.length).toBeGreaterThan(0) + expect((api.groups ?? []).some((group) => group.key === 'compose')).toBe(false) + }) + + it('reports an unavailable target instead of navigating to a stale thread', async () => { + const handlers = noopHandlers() + const palette = await mountPalette({ handlers }) + const stale: PaletteEntry = { + id: 'thread:gone', + source: 'thread', + title: 'Gone', + keywords: [], + activation: { kind: 'thread', threadId: 'gone' } + } + await act(async () => { + palette.current().activate(stale) + }) + expect(handlers.thread).not.toHaveBeenCalled() + expect(handlers.unavailable).toHaveBeenCalledTimes(1) + expect(storage.getItem(PALETTE_RECENTS_STORAGE_KEY)).toBeNull() + }) + + it('switches the composer model from a model row', async () => { + const handlers = noopHandlers() + const palette = await mountPalette({ handlers }) + await act(async () => { + palette.current().setQuery('claude-sonnet-5') + }) + const row = palette.current().results.find((entry) => entry.source === 'model') + expect(row).toBeDefined() + await act(async () => { + palette.current().activate(row!) + }) + expect(handlers['select-model']).toHaveBeenCalledWith('claude-sonnet-5', 'anthropic') + }) + + it('applies a conversation action to the active thread', async () => { + const handlers = noopHandlers() + const palette = await mountPalette({ + handlers, + // The action targets thr_a, which is present in the thread list. + sources: { activeThreadId: 'thr_a' } + }) + await act(async () => { + palette.current().setQuery('pin') + }) + const row = palette.current().results.find((entry) => entry.id === 'action:pin') + expect(row).toBeDefined() + await act(async () => { + palette.current().activate(row!) + }) + expect(handlers['thread-action']).toHaveBeenCalledWith('pin', 'thr_a') + }) + + it('reports unavailable when the action targets a thread that is gone', async () => { + const handlers = noopHandlers() + const palette = await mountPalette({ handlers }) + const stale: PaletteEntry = { + id: 'action:pin', + source: 'action', + title: 'Pin', + keywords: [], + activation: { kind: 'thread-action', action: 'pin', threadId: 'gone' } + } + await act(async () => { + palette.current().activate(stale) + }) + expect(handlers['thread-action']).not.toHaveBeenCalled() + expect(handlers.unavailable).toHaveBeenCalledTimes(1) + }) + + it('leaves disabled entries inert', async () => { + const handlers = noopHandlers() + const palette = await mountPalette({ handlers }) + const disabled: PaletteEntry = { + id: 'route:write', + source: 'route', + title: 'Write', + keywords: [], + disabled: true, + activation: { kind: 'route', route: 'write' } + } + await act(async () => { + palette.current().activate(disabled) + }) + expect(handlers.route).not.toHaveBeenCalled() + expect(handlers.unavailable).not.toHaveBeenCalled() + }) + + it('records a recent only for an activation that resolved', async () => { + const handlers = noopHandlers() + const palette = await mountPalette({ handlers }) + const entry = palette.current().results.find(() => true) + ?? (palette.current().groups ?? [])[0]?.entries[0] + expect(entry).toBeDefined() + await act(async () => { + palette.current().activate(entry!) + }) + const stored = JSON.parse(storage.getItem(PALETTE_RECENTS_STORAGE_KEY) ?? '{}') + expect(stored.workspaces['/Users/demo/project'][0].id).toBe(entry!.id) + }) +}) diff --git a/src/renderer/src/palette/useWorkbenchCommandPalette.ts b/src/renderer/src/palette/useWorkbenchCommandPalette.ts new file mode 100644 index 000000000..33f389d0d --- /dev/null +++ b/src/renderer/src/palette/useWorkbenchCommandPalette.ts @@ -0,0 +1,474 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { TFunction } from 'i18next' +import type { KeyboardShortcutCommandId } from '@shared/keyboard-shortcuts' +import type { AppRoute, SettingsRouteSection } from '../store/chat-store-types' +import type { SlashCommandId } from '../components/chat/floating-composer-commands' +import { workspaceRootScopeKey } from '../lib/workspace-path' +import { + DEFAULT_PALETTE_ENTRY_IDS, + type PaletteEntry, + type PaletteRecentIdentity, + type PaletteSourceKind +} from './palette-model' +import { readPaletteRecents, recordPaletteRecent } from './palette-recents' +import { parsePaletteQuery, rankPaletteEntries, type PaletteQueryScope } from './palette-scorer' +import { + collectPaletteSources, + composeFallbackEntries, + excludeDuplicateThreadMatches, + threadContentMatchEntries, + type PaletteSourcesInput +} from './palette-sources' +import { useCommandPaletteStore } from './palette-store' +import { getProvider } from '../agent/registry' +import { KunRuntimeProvider, type ThreadContentMatch } from '../agent/kun-runtime' + +export type PaletteActivationHandlers = { + route: (route: AppRoute) => void + settings: (section: SettingsRouteSection) => void + thread: (threadId: string) => void + workspace: (workspaceRoot: string) => void + 'shortcut-command': (commandId: KeyboardShortcutCommandId) => void + 'slash-command': (commandId: SlashCommandId, insertText: string) => void + 'extension-view': (entryId: string, locked: boolean) => boolean + /** Hand the query to the composer as a prompt draft. */ + compose: (text: string) => void + /** Switch the composer model. */ + 'select-model': (modelId: string, providerId?: string) => void + /** Apply a reversible action to the active conversation. */ + 'thread-action': (action: 'pin' | 'unpin' | 'archive', threadId: string) => void + /** Surfaced when an activated target no longer resolves. */ + unavailable: () => void +} + +export type PaletteResultGroup = { + key: string + label: string + entries: PaletteEntry[] +} + +const SOURCE_LABEL_KEYS: Record = { + 'shortcut-command': 'paletteSourceCommand', + route: 'paletteSourceRoute', + settings: 'paletteSourceSettings', + thread: 'paletteSourceThread', + workspace: 'paletteSourceWorkspace', + 'slash-command': 'paletteSourceCommand', + 'extension-view': 'paletteSourceExtension', + compose: 'paletteSourceAction', + model: 'paletteSourceModel', + action: 'paletteSourceAction' +} + +/** + * Sections the empty-query state browses through, in the order they appear. + * + * Conversations and projects are content rather than capability, so + * conversations are previewed rather than listed in full — typing reaches the + * rest, including message content. Everything else is listed exhaustively so + * the palette doubles as the app's capability map. + */ +const PALETTE_BROWSE_SECTIONS: ReadonlyArray<{ + key: string + labelKey: string + sources: readonly PaletteSourceKind[] + limit?: number +}> = [ + { key: 'actions', labelKey: 'paletteActionsSection', sources: ['action'] }, + { key: 'commands', labelKey: 'paletteSectionCommands', sources: ['shortcut-command', 'slash-command'] }, + { key: 'navigation', labelKey: 'paletteSectionNavigation', sources: ['route'] }, + { key: 'settings', labelKey: 'paletteSectionSettings', sources: ['settings'] }, + { key: 'models', labelKey: 'paletteSectionModels', sources: ['model'] }, + { key: 'conversations', labelKey: 'paletteSectionConversations', sources: ['thread'], limit: 8 }, + { key: 'projects', labelKey: 'paletteSectionProjects', sources: ['workspace'] }, + { key: 'extensions', labelKey: 'paletteSectionExtensions', sources: ['extension-view'] } +] + +const SCOPE_LABEL_KEYS: Record = { + all: null, + commands: 'paletteScopeCommands', + conversations: 'paletteScopeConversations', + settings: 'paletteScopeSettings', + slash: 'paletteScopeSlash' +} + +function paletteWorkspaceScope(workspaceRoot: string): string { + return workspaceRootScopeKey(workspaceRoot) || '__global__' +} + +export type PaletteContentSearch = ( + query: string, + options: { limit: number } +) => Promise + +export type UseWorkbenchCommandPaletteInput = PaletteSourcesInput & { + handlers: PaletteActivationHandlers + /** Injectable deep-search backend; defaults to the Kun runtime route. */ + searchThreadContent?: PaletteContentSearch +} + +/** + * Deep search is only available on the Kun runtime provider. Other providers + * simply contribute no content matches rather than throwing into the palette. + */ +const defaultContentSearch: PaletteContentSearch = (query, options) => { + const provider = getProvider() + if (!(provider instanceof KunRuntimeProvider)) return Promise.resolve([]) + return provider.searchThreadContent(query, options) +} + +/** + * Owns palette open state, source aggregation, query scoping, ranking, + * recents, and activation. Every action here routes through existing store + * actions and desktop commands via the handler callbacks. + */ +export function useWorkbenchCommandPalette(input: UseWorkbenchCommandPaletteInput): { + open: boolean + query: string + /** Query with any scope prefix stripped; what result highlighting matches. */ + matchTerm: string + scope: PaletteQueryScope + scopeLabel: string | null + results: PaletteEntry[] + groups: PaletteResultGroup[] | null + /** True while a conversation deep search is debouncing or in flight. */ + contentSearchPending: boolean + setQuery: (query: string) => void + sourceLabelFor: (entry: PaletteEntry) => string + activate: (entry: PaletteEntry) => void + close: () => void +} { + const { + handlers, + t, + tSettings, + route, + workspaceRoot, + threads, + codeWorkspaceRoots, + runtimeReady, + busy, + activeThreadId, + activeThreadArchived, + canOpenGoalPanel, + canCreateNewThread, + hasPlanCommand, + hasBtwCommand, + hideBtwCommand, + hasReviewCommand, + skillCommands, + disabledSkillIds, + extensionRightRailItems, + shortcutBindings, + hasComposerDraft, + composerModel, + composerModelGroups, + activeThreadPinned + } = input + + const open = useCommandPaletteStore((state) => state.open) + const closePalette = useCommandPaletteStore((state) => state.closePalette) + const [query, setQuery] = useState('') + const [recents, setRecents] = useState([]) + const [contentMatches, setContentMatches] = useState([]) + const [contentSearchPending, setContentSearchPending] = useState(false) + const contentSearch = input.searchThreadContent ?? defaultContentSearch + + const scope = useMemo(() => paletteWorkspaceScope(workspaceRoot), [workspaceRoot]) + + useEffect(() => { + if (!open) return + setQuery('') + setRecents(readPaletteRecents(scope)) + setContentMatches([]) + setContentSearchPending(false) + }, [open, scope]) + + const entries = useMemo( + () => collectPaletteSources({ + t, + tSettings, + route, + workspaceRoot, + threads, + codeWorkspaceRoots, + runtimeReady, + busy, + activeThreadId, + activeThreadArchived, + canOpenGoalPanel, + canCreateNewThread, + hasPlanCommand, + hasBtwCommand, + hideBtwCommand, + hasReviewCommand, + skillCommands, + disabledSkillIds, + extensionRightRailItems, + shortcutBindings, + hasComposerDraft, + composerModel, + composerModelGroups, + activeThreadPinned + }), + [ + t, + tSettings, + route, + workspaceRoot, + threads, + codeWorkspaceRoots, + runtimeReady, + busy, + activeThreadId, + activeThreadArchived, + canOpenGoalPanel, + canCreateNewThread, + hasPlanCommand, + hasBtwCommand, + hideBtwCommand, + hasReviewCommand, + skillCommands, + disabledSkillIds, + extensionRightRailItems, + shortcutBindings, + hasComposerDraft, + composerModel, + composerModelGroups, + activeThreadPinned + ] + ) + + const entriesById = useMemo(() => { + const map = new Map() + for (const entry of entries) map.set(entry.id, entry) + return map + }, [entries]) + + const parsed = useMemo(() => parsePaletteQuery(query), [query]) + + const scopeLabel = useMemo(() => { + const labelKey = SCOPE_LABEL_KEYS[parsed.scope] + return labelKey ? t(labelKey) : null + }, [parsed.scope, t]) + + /** + * With no query and no scope the palette shows curated groups (recents, + * then defaults) instead of the catalog. The overlay renders `results` + * followed by `groups`, so this state must yield no flat results or every + * curated entry would also appear in a full catalog listing above it. + */ + const showsCuratedGroups = parsed.query === '' && parsed.scope === 'all' + + const ranked = useMemo( + () => rankPaletteEntries(entries, parsed, recents), + [entries, parsed, recents] + ) + const results = useMemo( + () => (showsCuratedGroups ? [] : ranked), + [ranked, showsCuratedGroups] + ) + + const contentSearchScopeAllowed = parsed.scope === 'all' || parsed.scope === 'conversations' + + useEffect(() => { + const normalized = parsed.query.trim() + if (!open || !contentSearchScopeAllowed || normalized.length < 2) { + setContentMatches([]) + setContentSearchPending(false) + return + } + let cancelled = false + // Pending starts at the keystroke, not at the request, so the debounce + // window is covered too. Otherwise the palette renders its "no results" + // state for the whole debounce plus round-trip and a search that is + // simply still running looks like a search that found nothing. + setContentSearchPending(true) + const timer = window.setTimeout(() => { + // Intentionally unscoped: content search spans every project, because + // recalling a discussion rarely comes with recalling which project it + // was in. Each row shows the project it came from. + void contentSearch(normalized, { limit: 8 }) + .then((matches) => { + if (cancelled) return + setContentMatches(threadContentMatchEntries(matches)) + }) + .catch(() => { + if (!cancelled) setContentMatches([]) + }) + .finally(() => { + if (!cancelled) setContentSearchPending(false) + }) + }, 250) + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [contentSearch, contentSearchScopeAllowed, open, parsed.query]) + + const visibleContentMatches = useMemo( + () => excludeDuplicateThreadMatches(contentMatches, results), + [contentMatches, results] + ) + + const groups = useMemo(() => { + if (showsCuratedGroups) { + const recentEntries = recents + .map((recent) => entriesById.get(recent.id)) + .filter((entry): entry is PaletteEntry => Boolean(entry)) + const seen = new Set(recentEntries.map((entry) => entry.id)) + const defaultEntries = DEFAULT_PALETTE_ENTRY_IDS + .map((id) => entriesById.get(id)) + .filter((entry): entry is PaletteEntry => Boolean(entry)) + .filter((entry) => !seen.has(entry.id)) + const nextGroups: PaletteResultGroup[] = [] + if (recentEntries.length > 0) { + nextGroups.push({ key: 'recent', label: t('paletteSectionRecent'), entries: recentEntries }) + } + if (defaultEntries.length > 0) { + nextGroups.push({ key: 'default', label: t('paletteSectionDefault'), entries: defaultEntries }) + } + for (const entry of defaultEntries) seen.add(entry.id) + + // Everything else follows, grouped by section, so opening the palette + // shows the whole capability surface rather than only a curated few. + // `seen` is what keeps the original duplication bug from returning: + // an entry promoted into Recent or Quick actions must not repeat here. + for (const section of PALETTE_BROWSE_SECTIONS) { + const sectionEntries = entries.filter((entry) => + section.sources.includes(entry.source) && !seen.has(entry.id)) + if (sectionEntries.length === 0) continue + const bounded = section.limit ? sectionEntries.slice(0, section.limit) : sectionEntries + for (const entry of bounded) seen.add(entry.id) + nextGroups.push({ + key: 'browse:' + section.key, + label: t(section.labelKey), + entries: bounded + }) + } + return nextGroups + } + const nextGroups: PaletteResultGroup[] = [] + if (visibleContentMatches.length > 0) { + nextGroups.push({ + key: 'content', + label: t('paletteContentSearchSection'), + entries: visibleContentMatches + }) + } + // Offered only once everything else has come up empty, so it never + // competes with a real destination the user was aiming for. + if (results.length === 0 && visibleContentMatches.length === 0 && !contentSearchPending) { + const fallback = composeFallbackEntries({ + t, + rawQuery: query, + canCreateNewThread, + hasComposerDraft + }) + if (fallback.length > 0) { + nextGroups.push({ key: 'compose', label: t('paletteActionsSection'), entries: fallback }) + } + } + return nextGroups.length > 0 ? nextGroups : null + }, [ + canCreateNewThread, + contentSearchPending, + entries, + entriesById, + hasComposerDraft, + query, + recents, + results, + showsCuratedGroups, + t, + visibleContentMatches + ]) + + const sourceLabelFor = useCallback((entry: PaletteEntry): string => { + if (entry.source === 'slash-command' && entry.activation.kind === 'slash-command') { + if (entry.activation.commandId.startsWith('skill:')) return t('paletteSourceSkill') + return t('paletteSourceCommand') + } + return t(SOURCE_LABEL_KEYS[entry.source]) + }, [t]) + + const activate = useCallback((entry: PaletteEntry): void => { + if (entry.disabled) return + const activation = entry.activation + let resolved = true + switch (activation.kind) { + case 'route': + handlers.route(activation.route) + break + case 'settings': + handlers.settings(activation.section) + break + case 'thread': { + resolved = threads.some((thread) => thread.id === activation.threadId) || + contentMatches.some((match) => + match.activation.kind === 'thread' && match.activation.threadId === activation.threadId + ) + if (resolved) handlers.thread(activation.threadId) + break + } + case 'workspace': { + resolved = codeWorkspaceRoots.includes(activation.workspaceRoot) + if (resolved) handlers.workspace(activation.workspaceRoot) + break + } + case 'shortcut-command': + handlers['shortcut-command'](activation.commandId) + break + case 'slash-command': { + resolved = entriesById.has('slash:' + activation.commandId) + if (resolved) handlers['slash-command'](activation.commandId, activation.insertText) + break + } + case 'extension-view': + resolved = handlers['extension-view'](activation.entryId, activation.locked) + break + case 'compose': + handlers.compose(activation.text) + break + case 'select-model': + handlers['select-model'](activation.modelId, activation.providerId) + break + case 'thread-action': { + // The active thread can change between rendering the row and + // activating it, so re-check before acting on a stale id. + resolved = threads.some((thread) => thread.id === activation.threadId) + if (resolved) handlers['thread-action'](activation.action, activation.threadId) + break + } + } + closePalette() + if (!resolved) { + handlers.unavailable() + return + } + setRecents(recordPaletteRecent(scope, { source: entry.source, id: entry.id })) + }, [ + closePalette, + codeWorkspaceRoots, + contentMatches, + entriesById, + handlers, + scope, + threads + ]) + + return { + open, + query, + matchTerm: parsed.query, + scope: parsed.scope, + scopeLabel, + results, + groups, + contentSearchPending, + setQuery, + sourceLabelFor, + activate, + close: closePalette + } +} diff --git a/src/renderer/src/store/chat-store-navigation-workspace-actions.ts b/src/renderer/src/store/chat-store-navigation-workspace-actions.ts index ef375306b..4dfd310a2 100644 --- a/src/renderer/src/store/chat-store-navigation-workspace-actions.ts +++ b/src/renderer/src/store/chat-store-navigation-workspace-actions.ts @@ -80,6 +80,7 @@ import { writeWorkspaceForThreadId } from '../write/write-thread-registry' import { useWriteWorkspaceStore } from '../write/write-workspace-store' +import { withNativeDialog } from '../lib/native-dialog-activity' import { pendingDesignDocumentClones } from '../design/design-document-clone-registry' import { reconcilePendingDesignDocumentClones } from '../design/design-document-fork' import { @@ -163,7 +164,9 @@ export function createNavigationWorkspaceActions( if (typeof window.kunGui === 'undefined' || typeof window.kunGui.pickWorkspaceDirectory !== 'function') { throw new Error(i18n.t('common:workspacePickerUnavailable')) } - const picked = await window.kunGui.pickWorkspaceDirectory(get().workspaceRoot || undefined) + const pickWorkspaceDirectory = window.kunGui.pickWorkspaceDirectory + const picked = await withNativeDialog(() => + pickWorkspaceDirectory(get().workspaceRoot || undefined)) if (picked.canceled || !picked.path) { if (createThreadAfter) { set({ error: i18n.t('common:workspaceRequiredToCreateThread') }) diff --git a/src/shared/keyboard-shortcuts.ts b/src/shared/keyboard-shortcuts.ts index 6be3ef853..81003c0e2 100644 --- a/src/shared/keyboard-shortcuts.ts +++ b/src/shared/keyboard-shortcuts.ts @@ -127,6 +127,16 @@ export const KEYBOARD_SHORTCUT_COMMANDS = [ labelKey: 'shortcutToggleMaximize', descriptionKey: 'shortcutToggleMaximizeDesc', defaultBindings: [] + }, + // Registered last on purpose: `findKeyboardShortcutCommand` resolves the + // first command whose bindings match, so any command a user has bound to + // the palette's chord keeps its own behavior. + { + id: 'command-palette', + labelKey: 'shortcutCommandPalette', + descriptionKey: 'shortcutCommandPaletteDesc', + defaultBindings: ['Ctrl+K'], + platformDefaultBindings: { darwin: ['Meta+K'] } } ] as const satisfies readonly KeyboardShortcutCommandDefinition[] From a1d374fae006169b1778826b1835918d139a4094 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:14:21 +0800 Subject: [PATCH 02/13] fix(runtime): restore SQLite thread page counts --- .../hybrid/hybrid-thread-store.test.ts | 35 +++++++++++++++++++ .../adapters/hybrid/hybrid-thread-store.ts | 1 + 2 files changed, 36 insertions(+) diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.test.ts b/kun/src/adapters/hybrid/hybrid-thread-store.test.ts index 403224a79..6d6c39209 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store.test.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store.test.ts @@ -131,6 +131,41 @@ describe('HybridThreadStore filesystem surface fallback', () => { }) }) +describe('HybridThreadStore SQLite pagination', () => { + it('uses the index count for filtered pages without scanning JSONL', async () => { + const { store } = await createStore() + const records = [ + createThreadRecord({ id: 'thread_alpha', title: 'Alpha', workspace: '/tmp/a', model: 'test-model' }), + createThreadRecord({ id: 'thread_beta', title: 'Beta', workspace: '/tmp/a', model: 'test-model' }), + createThreadRecord({ id: 'thread_other', title: 'Alpha other', workspace: '/tmp/b', model: 'test-model' }) + ] + await Promise.all(records.map((record) => store.upsert(record))) + const source = store as unknown as { listFromFilesystem(): Promise } + const filesystemScan = vi.spyOn(source, 'listFromFilesystem') + + try { + const first = await store.listPage({ + workspace: '/tmp/a', search: 'a', includeArchived: true, limit: 1 + }) + expect(first).toMatchObject({ total: 2, hasMore: true }) + expect(first.threads).toHaveLength(1) + expect(first.nextCursor).toEqual(expect.any(String)) + + const second = await store.listPage({ + workspace: '/tmp/a', search: 'a', includeArchived: true, + limit: 1, cursor: first.nextCursor + }) + expect(second).toMatchObject({ hasMore: false }) + expect(second.threads).toHaveLength(1) + expect(second).not.toHaveProperty('total') + expect(filesystemScan).not.toHaveBeenCalled() + } finally { + filesystemScan.mockRestore() + store.close() + } + }) +}) + function legacyWorkThread(id: string, title: string): ThreadRecord { const turnId = `${id}_turn` const prompt = '[写作上下文]\n交互约定: 需要更多信息时通常直接用普通文本向用户提问。仅当当前激活的专用工作流明确要求结构化确认(例如 PPT 视觉评审)时,调用该工作流提供的确认工具;其他写作任务不要滥用结构化交互。\n\n润色当前文件' diff --git a/kun/src/adapters/hybrid/hybrid-thread-store.ts b/kun/src/adapters/hybrid/hybrid-thread-store.ts index ff6c100b9..0a62acb76 100644 --- a/kun/src/adapters/hybrid/hybrid-thread-store.ts +++ b/kun/src/adapters/hybrid/hybrid-thread-store.ts @@ -491,6 +491,7 @@ export class HybridThreadStore implements ThreadStore { private queryThreadRows(options: ThreadStoreListOptions): ThreadRow[] { return this.index?.query(options) ?? [] } + private indexCount(options: ThreadStoreListOptions): number | undefined { return this.index?.count(options) } private findRow(threadId: string): ThreadRow | null { return this.index?.find(threadId) ?? null From e994fe01ef5d4a63f571c77ce3cf201510a690ca Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:22:05 +0800 Subject: [PATCH 03/13] fix(windows): skip unsupported POSIX chmod --- kun/src/artifacts/artifact-store.ts | 5 ++-- kun/src/attachments/attachment-store.ts | 5 ++-- .../delegation-runtime-contracts.ts | 5 ++-- kun/src/memory/memory-store.ts | 5 ++-- kun/src/security/posix-permissions.test.ts | 27 +++++++++++++++++++ kun/src/security/posix-permissions.ts | 17 ++++++++++++ kun/src/services/background-shell-output.ts | 9 ++++--- kun/src/services/model-request-trace-store.ts | 7 ++--- kun/src/telemetry/agent-observability.ts | 7 ++--- src/main/browser-use/browser-use-audit-log.ts | 8 +++--- src/main/data-migration/kunpack-zip.ts | 7 ++--- src/main/data-migration/workspace-staging.ts | 5 ++-- src/main/runtime-data-dir-migration-copy.ts | 16 +++++------ .../runtime-data-dir-recovery-candidates.ts | 6 ++--- 14 files changed, 89 insertions(+), 40 deletions(-) create mode 100644 kun/src/security/posix-permissions.test.ts create mode 100644 kun/src/security/posix-permissions.ts diff --git a/kun/src/artifacts/artifact-store.ts b/kun/src/artifacts/artifact-store.ts index 859243651..c0143c7b5 100644 --- a/kun/src/artifacts/artifact-store.ts +++ b/kun/src/artifacts/artifact-store.ts @@ -10,10 +10,11 @@ */ import { randomUUID } from 'node:crypto' -import { chmod, mkdir, readFile, readdir, rename, rm, writeFile, stat as fsStat, open as fsOpen } from 'node:fs/promises' +import { mkdir, readFile, readdir, rename, rm, writeFile, stat as fsStat, open as fsOpen } from 'node:fs/promises' import { join } from 'node:path' import { StringDecoder } from 'node:string_decoder' import { artifactId, summarizeForModel, type ArtifactSummary } from './artifact-summary.js' +import { applyPosixMode } from '../security/posix-permissions.js' export type ArtifactSourceKind = 'mcp' | 'web' | 'bash' | 'attachment' | 'remote-log' | 'tool' | 'other' @@ -286,7 +287,7 @@ export class FileArtifactStore implements ArtifactStore { private async ensureDir(): Promise { if (!this.ready) { this.ready = mkdir(this.dir, { recursive: true, mode: 0o700 }) - .then(async () => { await chmod(this.dir, 0o700) }) + .then(async () => { await applyPosixMode(this.dir, 0o700) }) } return this.ready } diff --git a/kun/src/attachments/attachment-store.ts b/kun/src/attachments/attachment-store.ts index c331a54e7..f96e4e44f 100644 --- a/kun/src/attachments/attachment-store.ts +++ b/kun/src/attachments/attachment-store.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { z } from 'zod' import type { AttachmentsCapabilityConfig } from '../contracts/capabilities.js' @@ -10,6 +10,7 @@ import type { AttachmentVisualPreview } from '../contracts/attachments.js' import { AttachmentMetadata as AttachmentMetadataSchema } from '../contracts/attachments.js' +import { applyPosixMode } from '../security/posix-permissions.js' const ATTACHMENT_ID_PATTERN = /^att_[0-9a-f]{24}$/ const PendingAttachmentLeaseSchema = z.object({ @@ -422,7 +423,7 @@ export class FileAttachmentStore implements AttachmentStore { private async ensureRoot(): Promise { await mkdir(this.options.rootDir, { recursive: true, mode: 0o700 }) - await chmod(this.options.rootDir, 0o700) + await applyPosixMode(this.options.rootDir, 0o700) } } diff --git a/kun/src/delegation/delegation-runtime-contracts.ts b/kun/src/delegation/delegation-runtime-contracts.ts index be293f501..9f3fd060a 100644 --- a/kun/src/delegation/delegation-runtime-contracts.ts +++ b/kun/src/delegation/delegation-runtime-contracts.ts @@ -1,7 +1,8 @@ -import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { createHash } from 'node:crypto' import { z } from 'zod' +import { applyPosixMode } from '../security/posix-permissions.js' import { ModelReasoningEffort, SubagentProfileConfig, @@ -470,7 +471,7 @@ export class FileDelegationStore { private async ensureRoot(): Promise { await mkdir(this.rootDir, { recursive: true, mode: 0o700 }) - await chmod(this.rootDir, 0o700) + await applyPosixMode(this.rootDir, 0o700) } } diff --git a/kun/src/memory/memory-store.ts b/kun/src/memory/memory-store.ts index 74e73a650..cf15a5226 100644 --- a/kun/src/memory/memory-store.ts +++ b/kun/src/memory/memory-store.ts @@ -1,7 +1,8 @@ -import { chmod, mkdir, readFile, readdir, rm } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm } from 'node:fs/promises' import { join, resolve } from 'node:path' import type { MemoryCapabilityConfig } from '../contracts/capabilities.js' import { atomicWriteFile } from '../adapters/file/atomic-write.js' +import { applyPosixMode } from '../security/posix-permissions.js' import { MemoryDiagnostics, MemoryRecord, @@ -220,7 +221,7 @@ export class FileMemoryStore implements MemoryStore { private async ensureRoot(): Promise { await mkdir(this.options.rootDir, { recursive: true, mode: 0o700 }) - await chmod(this.options.rootDir, 0o700) + await applyPosixMode(this.options.rootDir, 0o700) } private now(): string { diff --git a/kun/src/security/posix-permissions.test.ts b/kun/src/security/posix-permissions.test.ts new file mode 100644 index 000000000..ec6433a67 --- /dev/null +++ b/kun/src/security/posix-permissions.test.ts @@ -0,0 +1,27 @@ +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { + applyPosixMode, + applyPosixModeSync, + shouldApplyPosixMode +} from './posix-permissions.js' + +describe('POSIX permission compatibility', () => { + it('classifies Windows as non-POSIX without weakening Unix platforms', () => { + expect(shouldApplyPosixMode('win32')).toBe(false) + expect(shouldApplyPosixMode('darwin')).toBe(true) + expect(shouldApplyPosixMode('linux')).toBe(true) + }) + + it('skips chmod on Windows and preserves errors on POSIX', async () => { + const missing = join(tmpdir(), `kun-missing-permissions-${process.pid}`) + if (process.platform === 'win32') { + await expect(applyPosixMode(missing, 0o700)).resolves.toBeUndefined() + expect(() => applyPosixModeSync(missing, 0o700)).not.toThrow() + return + } + await expect(applyPosixMode(missing, 0o700)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(() => applyPosixModeSync(missing, 0o700)).toThrow(expect.objectContaining({ code: 'ENOENT' })) + }) +}) diff --git a/kun/src/security/posix-permissions.ts b/kun/src/security/posix-permissions.ts new file mode 100644 index 000000000..23bb9def0 --- /dev/null +++ b/kun/src/security/posix-permissions.ts @@ -0,0 +1,17 @@ +import { chmodSync } from 'node:fs' +import { chmod } from 'node:fs/promises' + +export function shouldApplyPosixMode(platform: NodeJS.Platform = process.platform): boolean { + return platform !== 'win32' +} + +/** Windows ACLs do not implement POSIX modes; permission hardening is non-applicable there. */ +export async function applyPosixMode(path: string, mode: number): Promise { + if (!shouldApplyPosixMode()) return + await chmod(path, mode) +} + +export function applyPosixModeSync(path: string, mode: number): void { + if (!shouldApplyPosixMode()) return + chmodSync(path, mode) +} diff --git a/kun/src/services/background-shell-output.ts b/kun/src/services/background-shell-output.ts index df7f3e7a9..c0c9e566a 100644 --- a/kun/src/services/background-shell-output.ts +++ b/kun/src/services/background-shell-output.ts @@ -1,6 +1,7 @@ -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { createWriteStream, type WriteStream } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { applyPosixMode } from '../security/posix-permissions.js' /** Shared per-thread folder for all background shell logs (alongside messages.jsonl). */ export const BACKGROUND_SHELL_OUTPUT_SUBDIR = 'background-shells' @@ -105,7 +106,7 @@ export class BackgroundShellOutputWriter { async open(): Promise { await this.ensureOutputDir() await writeFile(this.paths.outputFilePath, '', { encoding: 'utf-8', mode: 0o600 }) - await chmod(this.paths.outputFilePath, 0o600) + await applyPosixMode(this.paths.outputFilePath, 0o600) this.stream = createWriteStream(this.paths.outputFilePath, { flags: 'a', mode: 0o600 }) } @@ -132,7 +133,7 @@ export class BackgroundShellOutputWriter { if (!this.stream) { await this.ensureOutputDir() await writeFile(this.paths.outputFilePath, '', { encoding: 'utf-8', mode: 0o600 }) - await chmod(this.paths.outputFilePath, 0o600) + await applyPosixMode(this.paths.outputFilePath, 0o600) return } const stream = this.stream @@ -166,6 +167,6 @@ export class BackgroundShellOutputWriter { private async ensureOutputDir(): Promise { await mkdir(this.paths.outputDir, { recursive: true, mode: 0o700 }) - await chmod(this.paths.outputDir, 0o700) + await applyPosixMode(this.paths.outputDir, 0o700) } } diff --git a/kun/src/services/model-request-trace-store.ts b/kun/src/services/model-request-trace-store.ts index 0a0fd1fd1..93f87242a 100644 --- a/kun/src/services/model-request-trace-store.ts +++ b/kun/src/services/model-request-trace-store.ts @@ -1,7 +1,8 @@ import { createReadStream } from 'node:fs' -import { appendFile, chmod, mkdir, rm } from 'node:fs/promises' +import { appendFile, mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import { createInterface } from 'node:readline' +import { applyPosixMode } from '../security/posix-permissions.js' import { MODEL_REQUEST_TRACE_SCHEMA_VERSION, ModelRequestTraceRecordSchema, @@ -41,7 +42,7 @@ export class ModelRequestTraceStore { await this.ensureReady() const path = this.pathForThread(threadId) await appendFile(path, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600 }) - await chmod(path, 0o600) + await applyPosixMode(path, 0o600) this.rememberRecent(threadId, record) } catch (error) { this.rememberWarning(classifyTracePersistenceError(error)) @@ -139,7 +140,7 @@ export class ModelRequestTraceStore { private async ensureReady(): Promise { this.ready ??= mkdir(this.root, { recursive: true, mode: 0o700 }) - .then(async () => { await chmod(this.root, 0o700) }) + .then(async () => { await applyPosixMode(this.root, 0o700) }) await this.ready } diff --git a/kun/src/telemetry/agent-observability.ts b/kun/src/telemetry/agent-observability.ts index 1e554d6b2..39f962261 100644 --- a/kun/src/telemetry/agent-observability.ts +++ b/kun/src/telemetry/agent-observability.ts @@ -1,11 +1,12 @@ import { createHash } from 'node:crypto' -import { appendFile, chmod, mkdir } from 'node:fs/promises' +import { appendFile, mkdir } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' import type { RuntimeEvent } from '../contracts/events.js' import type { UsageSnapshot } from '../contracts/usage.js' import type { ObservabilityConfig } from '../config/kun-config.js' import type { RuntimeEventObserver } from '../services/runtime-event-recorder.js' import { OtlpHttpJsonAgentObservabilitySink } from './otlp-http-json-sink.js' +import { applyPosixMode } from '../security/posix-permissions.js' export type AgentObservabilityAttributeValue = string | number | boolean | string[] @@ -59,11 +60,11 @@ export class JsonlAgentObservabilitySink implements AgentObservabilitySink { async emit(span: AgentObservabilitySpan): Promise { this.ready ??= mkdir(dirname(this.outputPath), { recursive: true, mode: 0o700 }) - .then(async () => { await chmod(dirname(this.outputPath), 0o700) }) + .then(async () => { await applyPosixMode(dirname(this.outputPath), 0o700) }) await this.ready await appendFile(this.outputPath, JSON.stringify({ span }) + '\n', { encoding: 'utf8', mode: 0o600 }) if (!this.hardened) { - await chmod(this.outputPath, 0o600) + await applyPosixMode(this.outputPath, 0o600) this.hardened = true } } diff --git a/src/main/browser-use/browser-use-audit-log.ts b/src/main/browser-use/browser-use-audit-log.ts index 96a64b0d7..ee28ae47a 100644 --- a/src/main/browser-use/browser-use-audit-log.ts +++ b/src/main/browser-use/browser-use-audit-log.ts @@ -1,12 +1,12 @@ import { appendFile, - chmod, mkdir, rename, stat, unlink } from 'node:fs/promises' import { dirname } from 'node:path' +import { applyPosixMode } from '../../../kun/src/security/posix-permissions.js' export const BROWSER_USE_AUDIT_MAX_FILE_BYTES = 5 * 1024 * 1024 export const BROWSER_USE_AUDIT_MAX_ARCHIVES = 2 @@ -38,7 +38,7 @@ export async function appendBrowserUseAuditLine( const auditDirectory = dirname(auditPath) await mkdir(auditDirectory, { recursive: true, mode: 0o700 }) - await chmod(auditDirectory, 0o700) + await applyPosixMode(auditDirectory, 0o700) const currentBytes = await fileSize(auditPath) if (currentBytes > maxFileBytes) { await unlinkIfPresent(auditPath) @@ -46,7 +46,7 @@ export async function appendBrowserUseAuditLine( await rotateAuditFiles(auditPath, maxArchives) } await appendFile(auditPath, payload, { encoding: 'utf8', mode: 0o600 }) - await chmod(auditPath, 0o600) + await applyPosixMode(auditPath, 0o600) } async function rotateAuditFiles(auditPath: string, maxArchives: number): Promise { @@ -92,7 +92,7 @@ async function unlinkIfPresent(path: string): Promise { async function chmodIfPresent(path: string, mode: number): Promise { try { - await chmod(path, mode) + await applyPosixMode(path, mode) } catch (error) { if (!isMissingFile(error)) throw error } diff --git a/src/main/data-migration/kunpack-zip.ts b/src/main/data-migration/kunpack-zip.ts index 59a2bb86f..de0bfd448 100644 --- a/src/main/data-migration/kunpack-zip.ts +++ b/src/main/data-migration/kunpack-zip.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { createReadStream, createWriteStream } from 'node:fs' -import { chmod, lstat, mkdir, rm } from 'node:fs/promises' +import { lstat, mkdir, rm } from 'node:fs/promises' import { dirname, isAbsolute, relative, resolve } from 'node:path' import { pipeline } from 'node:stream/promises' import { Transform } from 'node:stream' @@ -13,6 +13,7 @@ import { type DataMigrationPackageEntryKind, type PackageRelativePath } from '../../shared/data-migration' +import { applyPosixMode } from '../../../kun/src/security/posix-permissions.js' const FIXED_ZIP_TIME = new Date('1980-01-01T00:00:00.000Z') const DEFAULT_FILE_MODE = 0o100600 @@ -241,7 +242,7 @@ export async function extractZip64ArchiveEntries(input: { }): Promise<{ bytes: number; entries: number }> { const root = resolve(input.destinationRoot) await mkdir(root, { recursive: true, mode: 0o700 }) - await chmod(root, 0o700) + await applyPosixMode(root, 0o700) const declarations = new Map(input.entries.map((entry) => [entry.path, entry])) const extracted = new Set() let bytes = 0 @@ -295,7 +296,7 @@ export async function extractZip64ArchiveEntries(input: { await rm(outputPath, { force: true }).catch(() => undefined) throw new Error(`Kunpack extracted entry integrity mismatch: ${declaration.path}`) } - await chmod(outputPath, 0o600 | ((declaration.mode ?? 0) & 0o111)) + await applyPosixMode(outputPath, 0o600 | ((declaration.mode ?? 0) & 0o111)) extracted.add(declaration.path) bytes += entryBytes entries += 1 diff --git a/src/main/data-migration/workspace-staging.ts b/src/main/data-migration/workspace-staging.ts index 1b5e6e2a5..e90440b29 100644 --- a/src/main/data-migration/workspace-staging.ts +++ b/src/main/data-migration/workspace-staging.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' -import { chmod, lstat, mkdir, readlink, rename, rm, symlink } from 'node:fs/promises' +import { lstat, mkdir, readlink, rename, rm, symlink } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { buildMigrationDestinationPath, @@ -14,6 +14,7 @@ import { import { validateKunpackLinkMetadata } from './archive-security' import { stableImportedSiblingPath } from './import-planner' import { extractZip64ArchiveEntries } from './kunpack-zip' +import { applyPosixMode } from '../../../kun/src/security/posix-permissions.js' export type StagedWorkspaceFile = { entry: DataMigrationPackageEntry @@ -423,7 +424,7 @@ function assertBelow(root: string, path: string): void { async function hardenTreePermissions(root: string): Promise { const details = await lstat(root) if (details.isSymbolicLink()) return - await chmod(root, details.isDirectory() ? 0o700 : 0o600 | (details.mode & 0o111)) + await applyPosixMode(root, details.isDirectory() ? 0o700 : 0o600 | (details.mode & 0o111)) if (!details.isDirectory()) return const { readdir } = await import('node:fs/promises') for (const name of await readdir(root)) await hardenTreePermissions(join(root, name)) diff --git a/src/main/runtime-data-dir-migration-copy.ts b/src/main/runtime-data-dir-migration-copy.ts index f20ce163b..6ada177b9 100644 --- a/src/main/runtime-data-dir-migration-copy.ts +++ b/src/main/runtime-data-dir-migration-copy.ts @@ -1,5 +1,4 @@ import { - chmodSync, closeSync, constants, copyFileSync, @@ -48,6 +47,7 @@ import { runtimeStoreInventory, uniqueSiblingBackup } from './runtime-data-dir-migration-inventory' +import { applyPosixModeSync } from '../../kun/src/security/posix-permissions.js' @@ -96,7 +96,7 @@ export function copyRegularFilePreservingMetadata(sourcePath: string, targetPath const targetState = pathState(targetPath) if (targetState !== 'missing') { if (targetState === 'other' && sameRegularFileContent(sourcePath, targetPath)) { - chmodSync(targetPath, sourceMetadata.mode & 0o7777) + applyPosixModeSync(targetPath, sourceMetadata.mode & 0o7777) utimesSync(targetPath, sourceMetadata.atime, sourceMetadata.mtime) return } @@ -119,7 +119,7 @@ export function copyRegularFilePreservingMetadata(sourcePath: string, targetPath partialPath, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE ) - chmodSync(partialPath, sourceMetadata.mode & 0o7777) + applyPosixModeSync(partialPath, sourceMetadata.mode & 0o7777) utimesSync(partialPath, sourceMetadata.atime, sourceMetadata.mtime) // Source files may intentionally be read-only. A read descriptor is // sufficient for fsync and avoids requiring write permission after the @@ -155,7 +155,7 @@ export function copyRuntimeTreePreservingSource(sourcePath: string, targetPath: } else if (targetState !== 'dir') { throw new Error(`Runtime copy target is not a directory: ${targetPath}`) } else { - chmodSync(targetPath, (sourceMetadata.mode & 0o7777) | 0o700) + applyPosixModeSync(targetPath, (sourceMetadata.mode & 0o7777) | 0o700) } for (const targetName of readdirSync(targetPath)) { @@ -190,7 +190,7 @@ export function copyRuntimeTreePreservingSource(sourcePath: string, targetPath: } throw new Error(`Runtime source contains an unsupported entry: ${sourceEntry}`) } - chmodSync(targetPath, sourceMetadata.mode & 0o7777) + applyPosixModeSync(targetPath, sourceMetadata.mode & 0o7777) utimesSync(targetPath, sourceMetadata.atime, sourceMetadata.mtime) fsyncDirectoryBestEffort(targetPath) } @@ -348,11 +348,7 @@ export function backUpRegularFile( } const backupPath = uniqueSiblingBackup(path, label, now) copyFileSync(path, backupPath, constants.COPYFILE_EXCL) - try { - chmodSync(backupPath, 0o600) - } catch { - // Windows ACLs are not represented by POSIX mode bits. - } + applyPosixModeSync(backupPath, 0o600) const backupHandle = openSync(backupPath, 'r+') try { fsyncFileBestEffort(backupHandle) diff --git a/src/main/runtime-data-dir-recovery-candidates.ts b/src/main/runtime-data-dir-recovery-candidates.ts index b8a864a93..447994910 100644 --- a/src/main/runtime-data-dir-recovery-candidates.ts +++ b/src/main/runtime-data-dir-recovery-candidates.ts @@ -1,5 +1,4 @@ import { - chmodSync, closeSync, constants, copyFileSync, @@ -43,6 +42,7 @@ import { inspectMigrationJournalVerifiedCandidate, migrationJournalPhaseCanProveStaging } from './runtime-data-dir-recovery-discovery' +import { applyPosixModeSync } from '../../kun/src/security/posix-permissions.js' import { stringArraysEqual } from './runtime-data-dir-recovery-evidence' @@ -354,14 +354,14 @@ export function copyRuntimeTree(sourcePath: string, targetPath: string): void { symlinkSync(readlinkSync(sourceEntry), targetEntry) } else if (metadata.isFile()) { copyFileSync(sourceEntry, targetEntry, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE) - chmodSync(targetEntry, metadata.mode & 0o7777) + applyPosixModeSync(targetEntry, metadata.mode & 0o7777) utimesSync(targetEntry, metadata.atime, metadata.mtime) fsyncFileBestEffort(targetEntry) } else { throw new Error('copy source contains an unsupported entry') } } - chmodSync(targetPath, source.mode & 0o7777) + applyPosixModeSync(targetPath, source.mode & 0o7777) utimesSync(targetPath, source.atime, source.mtime) fsyncDirectoryBestEffort(targetPath) } From ad9d58708949e2aa9b496cdd63f7d23728c1b224 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:28:10 +0800 Subject: [PATCH 04/13] fix(installer): recreate desktop shortcut after updates --- build/installer.nsh | 6 ++++++ src/main/packaging-config.hooks.test.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/build/installer.nsh b/build/installer.nsh index 0bfb93b1d..351677ceb 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -176,6 +176,12 @@ Var /GLOBAL KunInstallerStopDiagnosticPath Quit ${endif} + ${if} ${isUpdated} + # electron-builder keeps existing shortcuts during --updated installs, but + # a scope/directory migration may already have removed the old link. + !insertmacro addDesktopLink "false" + ${endif} + ${if} $KunInstallerInPlaceUpdate == 1 !insertmacro kunRunMigrationHelper CleanupInPlaceLeftovers ${if} $KunInstallerHelperExitCode != 0 diff --git a/src/main/packaging-config.hooks.test.ts b/src/main/packaging-config.hooks.test.ts index 3038da708..aefa40d43 100644 --- a/src/main/packaging-config.hooks.test.ts +++ b/src/main/packaging-config.hooks.test.ts @@ -280,6 +280,7 @@ it('passes the nested OfficeCLI executable through the Windows signing manager', expect(builderConfig.nsis.include).toBe('build/installer.nsh') expect(builderConfig.nsis.allowToChangeInstallationDirectory).toBe(false) expect(builderConfig.nsis.deleteAppDataOnUninstall).toBe(false) + expect(builderConfig.nsis.createDesktopShortcut).toBe('always') expect(installerScript).toContain('!include "${PROJECT_DIR}\\build\\installer-process-check.nsh"') expect(installerScript).toContain('!macro customInit') expect(installerScript).toContain('${if} ${isUpdated}') @@ -377,6 +378,10 @@ it('passes the nested OfficeCLI executable through the Windows signing manager', 'suppressed the selected-scope uninstaller until the new payload is installed' ) expect(installerScript).toContain('!insertmacro kunRunMigrationHelper CleanupInPlaceLeftovers') + expect(installerScript).toContain('!insertmacro addDesktopLink "false"') + expect(installerScript.indexOf('!insertmacro kunRunMigrationHelper ValidatePayload')).toBeLessThan( + installerScript.indexOf('!insertmacro addDesktopLink "false"') + ) expect(installerScript).toContain('KUN_INSTALLER_IN_PLACE_UPDATE') expect(installerScript).toContain('Function KunSecureSelectedUninstallRegistration') expect(installerScript).toContain('Function KunSecureCurrentUserUninstallRegistration') From 11e1b1d56533bcf84ec22844d20cde40b118c120 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:34:04 +0800 Subject: [PATCH 05/13] fix(chat): keep branch picker above scroll containers --- .../src/components/chat/GitBranchPicker.tsx | 36 ++++--- .../composer-worktree-launch-settings.test.ts | 28 ++++++ .../chat/use-git-branch-picker-popover.ts | 94 +++++++++++++++++++ 3 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/chat/use-git-branch-picker-popover.ts diff --git a/src/renderer/src/components/chat/GitBranchPicker.tsx b/src/renderer/src/components/chat/GitBranchPicker.tsx index 7c6e32b92..01b70f0dd 100644 --- a/src/renderer/src/components/chat/GitBranchPicker.tsx +++ b/src/renderer/src/components/chat/GitBranchPicker.tsx @@ -33,6 +33,7 @@ import { } from '../../lib/thread-worktree-registry' import { useChatStore } from '../../store/chat-store' import { rememberCodeWorkspaceRoots } from '../../store/chat-store-helpers' +import { useGitBranchPickerPopover } from './use-git-branch-picker-popover' const BRANCH_ROW_LABEL_MAX_LENGTH = 42 const BRANCH_TRIGGER_LABEL_MAX_LENGTH = 32 @@ -79,6 +80,12 @@ export function GitBranchPicker({ const [branchPrefix, setBranchPrefix] = useState(DEFAULT_GIT_BRANCH_PREFIX) const wrapRef = useRef(null) const inputRef = useRef(null) + const closePanel = useCallback((): void => setOpen(false), []) + const { panelRef, panelStyle, updatePanelPosition } = useGitBranchPickerPopover({ + open, + anchorRef: wrapRef, + onClose: closePanel + }) const load = useCallback(async (): Promise => { if (!root || typeof window.kunGui?.getGitBranches !== 'function') return @@ -130,17 +137,6 @@ export function GitBranchPicker({ window.setTimeout(() => inputRef.current?.focus(), 0) }, [load, open]) - useEffect(() => { - if (!open) return - const onPointerDown = (event: PointerEvent): void => { - const target = event.target - if (target instanceof Node && wrapRef.current?.contains(target)) return - setOpen(false) - } - window.addEventListener('pointerdown', onPointerDown) - return () => window.removeEventListener('pointerdown', onPointerDown) - }, [open]) - useEffect(() => { if (!open) setTooltip(null) }, [open]) @@ -395,7 +391,12 @@ export function GitBranchPicker({ data-composer-launch-settings-trigger data-composer-launch-mode={useWorktreePool ? 'worktree' : 'current-directory'} className="flex h-8 max-w-[360px] min-w-0 items-center gap-2 rounded-lg px-2 text-[14px] font-medium text-ds-muted transition hover:bg-ds-hover hover:text-ds-ink" - onClick={() => setOpen((v) => !v)} + onClick={() => { + updatePanelPosition() + setOpen((v) => !v) + }} + aria-expanded={open} + aria-haspopup="dialog" aria-label={t('composerLaunchTriggerLabel', { branch: label, mode: launchModeLabel })} > @@ -407,10 +408,14 @@ export function GitBranchPicker({ )} - {open ? ( + {open && typeof document !== 'undefined' ? createPortal(
    @@ -671,7 +676,8 @@ export function GitBranchPicker({ {t('composerLaunchDone')}
    -
    +
    , + document.body ) : null} {tooltip ? createPortal(
    ({ + createPortalMock: vi.fn((children: unknown) => children) +})) + +vi.mock('react-dom', () => ({ createPortal: createPortalMock })) const BRANCH_RESULT: GitBranchesResult = { ok: true, @@ -67,6 +74,7 @@ describe('composer worktree launch settings', () => { let previousLanguage: string beforeEach(async () => { + createPortalMock.mockClear() previousLanguage = i18n.language await i18n.changeLanguage('en') rendererRuntimeClient.invalidateSettings() @@ -89,6 +97,19 @@ describe('composer worktree launch settings', () => { await i18n.changeLanguage(previousLanguage) }) + it('bounds a tall branch panel inside a short viewport', () => { + const placement = calculateComposerPopoverPlacement({ + anchorRect: { left: 240, right: 434, top: 440, bottom: 472 }, + popoverHeight: 580, + viewportHeight: 562, + viewportWidth: 675, + preferredWidth: 560, + maximumHeight: 640 + }) + + expect(placement).toEqual({ left: 57, top: 12, width: 560, maxHeight: 420 }) + }) + it('summarizes the selected branch and toggles isolated-worktree mode', async () => { installWindow() const onToggleWorktreeMode = vi.fn() @@ -122,6 +143,13 @@ describe('composer worktree launch settings', () => { const toggle = renderer!.root.findByProps({ 'data-composer-worktree-mode-toggle': true }) + const panel = renderer!.root.findByProps({ + 'data-composer-launch-settings-panel': true + }) + expect(createPortalMock).toHaveBeenCalledWith(expect.anything(), document.body) + expect(panel.props.className).toContain('fixed') + expect(panel.props.className).toContain('z-[1000]') + expect(panel.props.role).toBe('dialog') expect(toggle.findByProps({ role: 'switch' }).props['aria-checked']).toBe(true) await act(async () => { diff --git a/src/renderer/src/components/chat/use-git-branch-picker-popover.ts b/src/renderer/src/components/chat/use-git-branch-picker-popover.ts new file mode 100644 index 000000000..5c0020ebd --- /dev/null +++ b/src/renderer/src/components/chat/use-git-branch-picker-popover.ts @@ -0,0 +1,94 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type RefObject +} from 'react' +import { + calculateComposerPopoverPlacement, + currentComposerBodyZoom, + type ComposerPopoverPlacement +} from './floating-composer-popover-placement' + +const POPOVER_WIDTH = 560 +const POPOVER_MAX_HEIGHT = 640 +const POPOVER_ESTIMATED_HEIGHT = 580 + +export function useGitBranchPickerPopover({ + open, + anchorRef, + onClose +}: { + open: boolean + anchorRef: RefObject + onClose: () => void +}): { + panelRef: RefObject + panelStyle: CSSProperties + updatePanelPosition: () => void +} { + const panelRef = useRef(null) + const [placement, setPlacement] = useState(null) + + const updatePanelPosition = useCallback((): void => { + const anchor = anchorRef.current + if (!anchor) return + setPlacement(calculateComposerPopoverPlacement({ + anchorRect: anchor.getBoundingClientRect(), + popoverHeight: Math.max( + panelRef.current?.scrollHeight ?? 0, + POPOVER_ESTIMATED_HEIGHT + ), + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + preferredWidth: POPOVER_WIDTH, + maximumHeight: POPOVER_MAX_HEIGHT, + coordinateScale: currentComposerBodyZoom() + })) + }, [anchorRef]) + + useEffect(() => { + if (!open) return + updatePanelPosition() + const frame = window.requestAnimationFrame(updatePanelPosition) + const onPointerDown = (event: PointerEvent): void => { + const target = event.target + if (!(target instanceof Node)) return + if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) return + onClose() + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('pointerdown', onPointerDown) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('resize', updatePanelPosition) + window.addEventListener('scroll', updatePanelPosition, true) + return () => { + window.cancelAnimationFrame(frame) + window.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('resize', updatePanelPosition) + window.removeEventListener('scroll', updatePanelPosition, true) + } + }, [anchorRef, onClose, open, updatePanelPosition]) + + const panelStyle: CSSProperties = placement + ? { + left: `${placement.left}px`, + top: `${placement.top}px`, + width: `${placement.width}px`, + maxHeight: `${placement.maxHeight}px` + } + : { + left: 0, + top: 0, + width: `${POPOVER_WIDTH}px`, + maxHeight: `${POPOVER_MAX_HEIGHT}px`, + visibility: 'hidden' + } + + return { panelRef, panelStyle, updatePanelPosition } +} From e554b566c6a84e5e046fe6bcebd16648d3636a69 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:10:17 +0800 Subject: [PATCH 06/13] fix(runtime): reject recycled PID lock owners --- kun/src/cli/gui-settings-bridge.test.ts | 2 +- kun/src/server/runtime-data-dir-lease.test.ts | 2 +- kun/src/server/runtime-data-dir-lease.ts | 29 ++-- .../runtime-data-dir-migration-lock.test.ts | 35 ++++ .../server/runtime-data-dir-migration-lock.ts | 49 +++--- .../server/runtime-process-identity.test.ts | 46 ++++++ kun/src/server/runtime-process-identity.ts | 154 ++++++++++++++++++ .../runtime-data-dir-migration-lock.test.ts | 35 ++++ src/main/runtime-data-dir-migration-lock.ts | 41 +++-- 9 files changed, 339 insertions(+), 54 deletions(-) create mode 100644 kun/src/server/runtime-process-identity.test.ts create mode 100644 kun/src/server/runtime-process-identity.ts diff --git a/kun/src/cli/gui-settings-bridge.test.ts b/kun/src/cli/gui-settings-bridge.test.ts index 86dfe27a4..c63f804f3 100644 --- a/kun/src/cli/gui-settings-bridge.test.ts +++ b/kun/src/cli/gui-settings-bridge.test.ts @@ -221,7 +221,7 @@ describe('GUI settings bridge', () => { schemaVersion: 1, pid: process.pid, token: 'legacy-runtime-owner', - startedAt: '2026-08-05T00:00:00.000Z' + startedAt: new Date().toISOString() })) await expect(syncGuiProviderCatalogToConfig(fixture.dataDir, settings!)) diff --git a/kun/src/server/runtime-data-dir-lease.test.ts b/kun/src/server/runtime-data-dir-lease.test.ts index 08ea513dc..7e9294223 100644 --- a/kun/src/server/runtime-data-dir-lease.test.ts +++ b/kun/src/server/runtime-data-dir-lease.test.ts @@ -58,7 +58,7 @@ describe('Runtime data directory lease', () => { schemaVersion: 1, pid: process.pid, token: 'legacy-migration-lock', - startedAt: '2026-08-05T00:00:00.000Z', + startedAt: new Date().toISOString(), dataDir })) diff --git a/kun/src/server/runtime-data-dir-lease.ts b/kun/src/server/runtime-data-dir-lease.ts index f27419444..3e32cb0f2 100644 --- a/kun/src/server/runtime-data-dir-lease.ts +++ b/kun/src/server/runtime-data-dir-lease.ts @@ -8,6 +8,12 @@ import { reclaimLockFileIfUnchanged, runtimeDataDirOwnerPath } from './runtime-data-dir-migration-lock.js' +import { + isValidRuntimeProcessIdentity, + runtimeProcessIdentity, + runtimeProcessIsAlive, + type RuntimeProcessIsAlive +} from './runtime-process-identity.js' export { RUNTIME_DATA_DIR_OWNER_FILE } from './runtime-data-dir-migration-lock.js' @@ -16,6 +22,7 @@ type RuntimeDataDirOwner = { pid: number token: string startedAt: string + processIdentity?: string } export type RuntimeDataDirLease = { @@ -96,15 +103,6 @@ function isErrno(error: unknown, code: string): boolean { (error as NodeJS.ErrnoException).code === code } -function defaultProcessIsAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return !isErrno(error, 'ESRCH') - } -} - function parseOwner(raw: string): RuntimeDataDirOwner | null { try { const parsed = JSON.parse(raw) as Partial @@ -113,7 +111,8 @@ function parseOwner(raw: string): RuntimeDataDirOwner | null { (parsed.pid ?? 0) > 0 && typeof parsed.token === 'string' && parsed.token.length > 0 && - typeof parsed.startedAt === 'string' + typeof parsed.startedAt === 'string' && + isValidRuntimeProcessIdentity(parsed.processIdentity) ? parsed as RuntimeDataDirOwner : null } catch { @@ -144,7 +143,7 @@ async function writeOwnerExclusively(path: string, owner: RuntimeDataDirOwner): type RuntimeDataDirLeaseOptions = { pid?: number now?: () => Date - processIsAlive?: (pid: number) => boolean + processIsAlive?: RuntimeProcessIsAlive beforeStaleReclaim?: (path: string, expectedRaw: string) => void | Promise } @@ -155,7 +154,7 @@ async function acquireRuntimeDataDirWriterLease( ): Promise { const pid = options.pid ?? process.pid const now = options.now ?? (() => new Date()) - const processIsAlive = options.processIsAlive ?? defaultProcessIsAlive + const processIsAlive = options.processIsAlive ?? runtimeProcessIsAlive const path = runtimeDataDirOwnerPath(dataDir) const writerClaim = await acquireRuntimeDataDirWriterClaim(dataDir, claimKind, { pid, @@ -166,11 +165,13 @@ async function acquireRuntimeDataDirWriterLease( // Check both sides of owner-file creation. If a migration wins between the // checks, this contender removes only its token-matched owner record and // exits before constructing any persistent Runtime stores. + const processIdentity = runtimeProcessIdentity(pid) const owner: RuntimeDataDirOwner = { schemaVersion: 1, pid, token: randomUUID(), - startedAt: now().toISOString() + startedAt: now().toISOString(), + ...(processIdentity ? { processIdentity } : {}) } let ownerCreated = false let dataDirCreated = false @@ -203,7 +204,7 @@ async function acquireRuntimeDataDirWriterLease( if (!existing) { throw new Error(`Kun Runtime data directory owner record is invalid: ${path}`) } - if (processIsAlive(existing.pid)) { + if (processIsAlive(existing.pid, existing)) { throw new Error( `Kun Runtime data directory is already owned by active process ${existing.pid}: ${dataDir}` ) diff --git a/kun/src/server/runtime-data-dir-migration-lock.test.ts b/kun/src/server/runtime-data-dir-migration-lock.test.ts index ac9513f8f..2d685b031 100644 --- a/kun/src/server/runtime-data-dir-migration-lock.test.ts +++ b/kun/src/server/runtime-data-dir-migration-lock.test.ts @@ -89,6 +89,41 @@ describe('Runtime data migration lock', () => { )) }) + it('reclaims a writer claim after its PID is reused by another process', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-runtime-migration-lock-')) + roots.push(root) + const dataDir = join(root, 'runtime') + const claimsPath = runtimeDataDirClaimsPath(dataDir) + const token = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const stalePath = join(claimsPath, `claim-701-${token}.json`) + await mkdir(claimsPath, { recursive: true }) + await writeFile(stalePath, JSON.stringify({ + schemaVersion: 1, + kind: 'runtime', + pid: 701, + token, + startedAt: '2026-08-16T00:00:00.000Z', + processIdentity: 'win32-v1:original-process', + dataDir + })) + let inspectedIdentity: string | undefined + + const lock = await acquireRuntimeDataDirMigrationLock(dataDir, { + pid: 702, + processIsAlive: (pid, record) => { + if (pid === 701) { + inspectedIdentity = record?.processIdentity + return record?.processIdentity === 'win32-v1:reused-process' + } + return pid === 702 + } + }) + + expect(inspectedIdentity).toBe('win32-v1:original-process') + await expect(readFile(stalePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + await lock.release() + }) + it('fails closed on unknown or non-regular claim entries', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-runtime-migration-lock-')) roots.push(root) diff --git a/kun/src/server/runtime-data-dir-migration-lock.ts b/kun/src/server/runtime-data-dir-migration-lock.ts index 0b17a96f5..6201f330d 100644 --- a/kun/src/server/runtime-data-dir-migration-lock.ts +++ b/kun/src/server/runtime-data-dir-migration-lock.ts @@ -1,6 +1,12 @@ import { randomUUID } from 'node:crypto' import { link, mkdir, open, readFile, readdir, rename, rm, unlink } from 'node:fs/promises' import { basename, dirname, join, resolve } from 'node:path' +import { + isValidRuntimeProcessIdentity, + runtimeProcessIdentity, + runtimeProcessIsAlive, + type RuntimeProcessIsAlive +} from './runtime-process-identity.js' export const RUNTIME_DATA_DIR_MIGRATION_LOCK_SUFFIX = '.kun-runtime-migration.lock' export const RUNTIME_DATA_DIR_OWNER_FILE = '.kun-runtime-owner.json' @@ -14,6 +20,7 @@ type RuntimeDataDirWriterClaimRecord = { pid: number token: string startedAt: string + processIdentity?: string dataDir: string } @@ -27,6 +34,7 @@ type RuntimeDataDirMigrationLockOwner = { pid: number token: string startedAt: string + processIdentity?: string dataDir: string } @@ -35,6 +43,7 @@ type RuntimeDataDirOwner = { pid: number token: string startedAt: string + processIdentity?: string } function isErrno(error: unknown, code: string): boolean { @@ -44,15 +53,6 @@ function isErrno(error: unknown, code: string): boolean { (error as NodeJS.ErrnoException).code === code } -function defaultProcessIsAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return !isErrno(error, 'ESRCH') - } -} - function parseOwner(raw: string): RuntimeDataDirMigrationLockOwner | null { try { const parsed = JSON.parse(raw) as Partial @@ -62,6 +62,7 @@ function parseOwner(raw: string): RuntimeDataDirMigrationLockOwner | null { typeof parsed.token === 'string' && parsed.token.length > 0 && typeof parsed.startedAt === 'string' && + isValidRuntimeProcessIdentity(parsed.processIdentity) && typeof parsed.dataDir === 'string' && parsed.dataDir.length > 0 ? parsed as RuntimeDataDirMigrationLockOwner @@ -79,7 +80,8 @@ function parseRuntimeOwner(raw: string): RuntimeDataDirOwner | null { (parsed.pid ?? 0) > 0 && typeof parsed.token === 'string' && parsed.token.length > 0 && - typeof parsed.startedAt === 'string' + typeof parsed.startedAt === 'string' && + isValidRuntimeProcessIdentity(parsed.processIdentity) ? parsed as RuntimeDataDirOwner : null } catch { @@ -102,6 +104,7 @@ function parseWriterClaim(raw: string): RuntimeDataDirWriterClaimRecord | null { typeof parsed.token === 'string' && parsed.token.length > 0 && typeof parsed.startedAt === 'string' && + isValidRuntimeProcessIdentity(parsed.processIdentity) && typeof parsed.dataDir === 'string' && parsed.dataDir.length > 0 ? parsed as RuntimeDataDirWriterClaimRecord @@ -165,23 +168,25 @@ export async function acquireRuntimeDataDirWriterClaim( options: { pid?: number now?: () => Date - processIsAlive?: (pid: number) => boolean + processIsAlive?: RuntimeProcessIsAlive afterClaimCreated?: (path: string) => void | Promise } = {} ): Promise { const pid = options.pid ?? process.pid const now = options.now ?? (() => new Date()) - const processIsAlive = options.processIsAlive ?? defaultProcessIsAlive + const processIsAlive = options.processIsAlive ?? runtimeProcessIsAlive const canonicalDataDir = resolve(dataDir) const claimsPath = runtimeDataDirClaimsPath(canonicalDataDir) const token = randomUUID() const path = join(claimsPath, writerClaimFilename(pid, token)) + const processIdentity = runtimeProcessIdentity(pid) const record: RuntimeDataDirWriterClaimRecord = { schemaVersion: 1, kind, pid, token, startedAt: now().toISOString(), + ...(processIdentity ? { processIdentity } : {}), dataDir: canonicalDataDir } await mkdir(claimsPath, { recursive: true, mode: 0o700 }) @@ -229,7 +234,7 @@ export async function acquireRuntimeDataDirWriterClaim( } throw new Error(`Kun Runtime writer claim is invalid: ${contenderPath}`) } - if (processIsAlive(contender.pid)) { + if (processIsAlive(contender.pid, contender)) { if (writerClaimsConflict(kind, contender.kind)) { throw new Error(claimConflictMessage(contender, dataDir)) } @@ -328,12 +333,12 @@ export async function assertRuntimeDataDirMigrationInactive( dataDir: string, options: { pid?: number - processIsAlive?: (pid: number) => boolean + processIsAlive?: RuntimeProcessIsAlive beforeStaleReclaim?: (path: string, expectedRaw: string) => void | Promise } = {} ): Promise { const pid = options.pid ?? process.pid - const processIsAlive = options.processIsAlive ?? defaultProcessIsAlive + const processIsAlive = options.processIsAlive ?? runtimeProcessIsAlive const path = runtimeDataDirMigrationLockPath(dataDir) for (;;) { let raw: string @@ -349,7 +354,7 @@ export async function assertRuntimeDataDirMigrationInactive( if (!owner) { throw new Error(`Kun Runtime migration lock is invalid: ${path}`) } - if (processIsAlive(owner.pid)) { + if (processIsAlive(owner.pid, owner)) { throw new Error( `Kun Runtime data migration is active in process ${owner.pid}: ${dataDir}` ) @@ -365,12 +370,12 @@ export async function assertRuntimeDataDirLeaseInactive( dataDir: string, options: { pid?: number - processIsAlive?: (pid: number) => boolean + processIsAlive?: RuntimeProcessIsAlive beforeStaleReclaim?: (path: string, expectedRaw: string) => void | Promise } = {} ): Promise { const pid = options.pid ?? process.pid - const processIsAlive = options.processIsAlive ?? defaultProcessIsAlive + const processIsAlive = options.processIsAlive ?? runtimeProcessIsAlive const path = runtimeDataDirOwnerPath(dataDir) for (;;) { let raw: string @@ -384,7 +389,7 @@ export async function assertRuntimeDataDirLeaseInactive( } const owner = parseRuntimeOwner(raw) if (!owner) throw new Error(`Kun Runtime data directory owner record is invalid: ${path}`) - if (processIsAlive(owner.pid)) { + if (processIsAlive(owner.pid, owner)) { throw new Error( `Kun Runtime data directory is already owned by active process ${owner.pid}: ${dataDir}` ) @@ -408,13 +413,13 @@ export async function acquireRuntimeDataDirMigrationLock( options: { pid?: number now?: () => Date - processIsAlive?: (pid: number) => boolean + processIsAlive?: RuntimeProcessIsAlive beforeStaleReclaim?: (path: string, expectedRaw: string) => void | Promise } = {} ): Promise { const pid = options.pid ?? process.pid const now = options.now ?? (() => new Date()) - const processIsAlive = options.processIsAlive ?? defaultProcessIsAlive + const processIsAlive = options.processIsAlive ?? runtimeProcessIsAlive const path = runtimeDataDirMigrationLockPath(dataDir) await mkdir(dirname(path), { recursive: true, mode: 0o700 }) const writerClaim = await acquireRuntimeDataDirWriterClaim(dataDir, 'migration', { @@ -432,11 +437,13 @@ export async function acquireRuntimeDataDirMigrationLock( await writerClaim.release().catch(() => undefined) throw error } + const processIdentity = runtimeProcessIdentity(pid) const owner: RuntimeDataDirMigrationLockOwner = { schemaVersion: 1, pid, token: randomUUID(), startedAt: now().toISOString(), + ...(processIdentity ? { processIdentity } : {}), dataDir: resolve(dataDir) } try { diff --git a/kun/src/server/runtime-process-identity.test.ts b/kun/src/server/runtime-process-identity.test.ts new file mode 100644 index 000000000..1131cab1f --- /dev/null +++ b/kun/src/server/runtime-process-identity.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + inspectRuntimeProcess, + runtimeProcessInspectionMatchesRecord, + runtimeProcessIsAlive +} from './runtime-process-identity.js' + +describe('runtime process identity', () => { + it('reads a stable identity for the current process', () => { + const inspection = inspectRuntimeProcess(process.pid) + expect(inspection?.identity).toMatch(/-v1:/u) + const startedAt = new Date().toISOString() + expect(runtimeProcessIsAlive(process.pid, { + startedAt, + processIdentity: inspection?.identity + })).toBe(true) + expect(runtimeProcessIsAlive(process.pid, { + startedAt, + processIdentity: `${inspection?.identity}:reused` + })).toBe(false) + }) + + it('rejects a live PID whose process birth identity changed', () => { + expect(runtimeProcessInspectionMatchesRecord( + { startedAt: '2026-08-16T00:00:00.000Z', processIdentity: 'win32-v1:old' }, + { identity: 'win32-v1:reused', startedAtMs: Date.parse('2026-08-16T00:01:00.000Z') } + )).toBe(false) + }) + + it('recovers legacy records when the current process started later', () => { + const record = { startedAt: '2026-08-16T00:00:00.000Z' } + expect(runtimeProcessInspectionMatchesRecord(record, { + startedAtMs: Date.parse('2026-08-16T00:01:00.000Z') + })).toBe(false) + expect(runtimeProcessInspectionMatchesRecord(record, { + startedAtMs: Date.parse('2026-08-15T23:59:00.000Z') + })).toBe(true) + }) + + it('fails closed when process birth identity cannot be inspected', () => { + expect(runtimeProcessInspectionMatchesRecord({ + startedAt: '2026-08-16T00:00:00.000Z', + processIdentity: 'win32-v1:owner' + }, undefined)).toBe(true) + }) +}) diff --git a/kun/src/server/runtime-process-identity.ts b/kun/src/server/runtime-process-identity.ts new file mode 100644 index 000000000..9aa68b179 --- /dev/null +++ b/kun/src/server/runtime-process-identity.ts @@ -0,0 +1,154 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export type RuntimeProcessRecordIdentity = { + startedAt: string + processIdentity?: string +} + +export type RuntimeProcessIsAlive = ( + pid: number, + record?: RuntimeProcessRecordIdentity +) => boolean + +export type RuntimeProcessInspection = { + identity?: string + startedAtMs?: number +} + +export function isValidRuntimeProcessIdentity(value: unknown): value is string | undefined { + return value === undefined || ( + typeof value === 'string' && value.length > 0 && value.length <= 512 + ) +} + +const PROCESS_INSPECTION_TIMEOUT_MS = 3_000 +const PROCESS_INSPECTION_MAX_BUFFER = 16 * 1024 +let cachedCurrentProcessInspection: RuntimeProcessInspection | undefined + +function errnoCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as NodeJS.ErrnoException).code) + : undefined +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + // EPERM means the process exists but cannot be signalled. Unknown errors + // also fail closed instead of reclaiming a potentially live owner. + return errnoCode(error) !== 'ESRCH' + } +} + +function boundedExec(executable: string, args: string[]): string { + return execFileSync(executable, args, { + encoding: 'utf8', + timeout: PROCESS_INSPECTION_TIMEOUT_MS, + maxBuffer: PROCESS_INSPECTION_MAX_BUFFER, + windowsHide: true, + env: { ...process.env, LANG: 'C', LC_ALL: 'C' } + }).trim() +} + +function inspectWindowsProcess(pid: number): RuntimeProcessInspection | undefined { + const systemRoot = process.env.SystemRoot?.trim() + const powershell = systemRoot + ? join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + : 'powershell.exe' + const command = [ + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop`, + 'if ($null -eq $p) { exit 3 }', + `[Console]::Out.Write($p.CreationDate.ToUniversalTime().ToString('O'))` + ].join('; ') + const startedAt = boundedExec(powershell, [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + command + ]) + const startedAtMs = Date.parse(startedAt) + if (!startedAt || !Number.isFinite(startedAtMs)) return undefined + return { identity: `win32-v1:${startedAt}`, startedAtMs } +} + +function inspectProcProcess(pid: number): RuntimeProcessInspection | undefined { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const commandEnd = stat.lastIndexOf(')') + if (commandEnd < 0) return undefined + const fields = stat.slice(commandEnd + 1).trim().split(/\s+/u) + const startTicks = fields[19] + if (!startTicks || !/^\d+$/u.test(startTicks)) return undefined + const bootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() + if (!bootId) return undefined + let startedAtMs: number | undefined + try { + startedAtMs = inspectPosixStartedAt(pid) + } catch { + // The opaque boot/tick identity is still authoritative for new records. + } + return { + identity: `linux-v1:${bootId}:${startTicks}`, + ...(startedAtMs === undefined ? {} : { startedAtMs }) + } +} + +function inspectPosixStartedAt(pid: number): number | undefined { + const value = boundedExec('/bin/ps', ['-o', 'lstart=', '-p', String(pid)]) + const startedAtMs = Date.parse(value) + return Number.isFinite(startedAtMs) ? startedAtMs : undefined +} + +function inspectPosixProcess(pid: number): RuntimeProcessInspection | undefined { + const startedAt = boundedExec('/bin/ps', ['-o', 'lstart=', '-p', String(pid)]) + const startedAtMs = Date.parse(startedAt) + if (!startedAt || !Number.isFinite(startedAtMs)) return undefined + return { identity: `${process.platform}-v1:${startedAt}`, startedAtMs } +} + +export function inspectRuntimeProcess(pid: number): RuntimeProcessInspection | undefined { + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined + if (pid === process.pid && cachedCurrentProcessInspection) { + return cachedCurrentProcessInspection + } + let inspection: RuntimeProcessInspection | undefined + try { + if (process.platform === 'win32') inspection = inspectWindowsProcess(pid) + else if (process.platform === 'linux') inspection = inspectProcProcess(pid) + else inspection = inspectPosixProcess(pid) + } catch { + inspection = undefined + } + if (pid === process.pid && inspection) cachedCurrentProcessInspection = inspection + return inspection +} + +export function runtimeProcessIdentity(pid = process.pid): string | undefined { + return inspectRuntimeProcess(pid)?.identity +} + +export function runtimeProcessInspectionMatchesRecord( + record: RuntimeProcessRecordIdentity | undefined, + inspection: RuntimeProcessInspection | undefined +): boolean { + if (!record || !inspection) return true + if (record.processIdentity) { + return inspection.identity === undefined || inspection.identity === record.processIdentity + } + const recordedAtMs = Date.parse(record.startedAt) + if (!Number.isFinite(recordedAtMs) || inspection.startedAtMs === undefined) return true + // A process that started after the record was written cannot be its owner. + return inspection.startedAtMs <= recordedAtMs +} + +export const runtimeProcessIsAlive: RuntimeProcessIsAlive = (pid, record) => { + if (!processExists(pid)) return false + const inspection = inspectRuntimeProcess(pid) + if (!inspection) return processExists(pid) + return runtimeProcessInspectionMatchesRecord(record, inspection) +} diff --git a/src/main/runtime-data-dir-migration-lock.test.ts b/src/main/runtime-data-dir-migration-lock.test.ts index 5f8f1c52f..00fa2f9e7 100644 --- a/src/main/runtime-data-dir-migration-lock.test.ts +++ b/src/main/runtime-data-dir-migration-lock.test.ts @@ -227,6 +227,41 @@ describe('canonical Runtime migration startup lock', () => { })).toThrow(/claim is not a regular file/) }) + it('reclaims a common writer claim after its PID is reused', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-main-migration-lock-')) + roots.push(root) + const dataDir = join(root, '.kun', 'data') + const claimsPath = runtimeDataDirClaimsPath(dataDir) + const token = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const stalePath = join(claimsPath, `claim-561-${token}.json`) + await mkdir(claimsPath, { recursive: true }) + await writeFile(stalePath, JSON.stringify({ + schemaVersion: 1, + kind: 'runtime', + pid: 561, + token, + startedAt: '2026-08-16T00:00:00.000Z', + processIdentity: 'win32-v1:original-process', + dataDir + })) + let inspectedIdentity: string | undefined + + const migration = acquireCanonicalRuntimeMigrationLock([dataDir], { + pid: 562, + processIsAlive: (pid, record) => { + if (pid === 561) { + inspectedIdentity = record?.processIdentity + return record?.processIdentity === 'win32-v1:reused-process' + } + return pid === 562 + } + }) + + expect(inspectedIdentity).toBe('win32-v1:original-process') + await expect(readFile(stalePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + migration.release() + }) + it('preserves a replacement lock observed during stale reclamation', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-main-migration-lock-')) roots.push(root) diff --git a/src/main/runtime-data-dir-migration-lock.ts b/src/main/runtime-data-dir-migration-lock.ts index cbd97862d..8cdddda95 100644 --- a/src/main/runtime-data-dir-migration-lock.ts +++ b/src/main/runtime-data-dir-migration-lock.ts @@ -18,12 +18,19 @@ import { runtimeDataDirMigrationLockPath, runtimeDataDirOwnerPath } from '../../kun/src/server/runtime-data-dir-migration-lock.js' +import { + isValidRuntimeProcessIdentity, + runtimeProcessIdentity, + runtimeProcessIsAlive, + type RuntimeProcessIsAlive +} from '../../kun/src/server/runtime-process-identity.js' type MigrationLockOwner = { schemaVersion: 1 pid: number token: string startedAt: string + processIdentity?: string dataDir: string } @@ -32,6 +39,7 @@ type RuntimeDataDirOwner = { pid: number token: string startedAt: string + processIdentity?: string } type RuntimeDataDirWriterClaim = { @@ -40,6 +48,7 @@ type RuntimeDataDirWriterClaim = { pid: number token: string startedAt: string + processIdentity?: string dataDir: string } @@ -61,15 +70,6 @@ function errnoCode(error: unknown): string | undefined { : undefined } -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return errnoCode(error) !== 'ESRCH' - } -} - function parseOwner(raw: string): MigrationLockOwner | null { try { const value = JSON.parse(raw) as Partial @@ -79,6 +79,7 @@ function parseOwner(raw: string): MigrationLockOwner | null { typeof value.token === 'string' && value.token.length > 0 && typeof value.startedAt === 'string' && + isValidRuntimeProcessIdentity(value.processIdentity) && typeof value.dataDir === 'string' && value.dataDir.length > 0 ? value as MigrationLockOwner @@ -96,7 +97,8 @@ function parseRuntimeOwner(raw: string): RuntimeDataDirOwner | null { (value.pid ?? 0) > 0 && typeof value.token === 'string' && value.token.length > 0 && - typeof value.startedAt === 'string' + typeof value.startedAt === 'string' && + isValidRuntimeProcessIdentity(value.processIdentity) ? value as RuntimeDataDirOwner : null } catch { @@ -119,6 +121,7 @@ function parseWriterClaim(raw: string): RuntimeDataDirWriterClaim | null { typeof value.token === 'string' && value.token.length > 0 && typeof value.startedAt === 'string' && + isValidRuntimeProcessIdentity(value.processIdentity) && typeof value.dataDir === 'string' && value.dataDir.length > 0 ? value as RuntimeDataDirWriterClaim @@ -144,7 +147,7 @@ function acquireWriterClaimSync( input: { pid: number now: () => Date - processIsAlive: (pid: number) => boolean + processIsAlive: RuntimeProcessIsAlive } ): { path: string; owner: RuntimeDataDirWriterClaim } { const canonicalDataDir = resolve(dataDir) @@ -152,12 +155,14 @@ function acquireWriterClaimSync( mkdirSync(claimsPath, { recursive: true, mode: 0o700 }) const token = randomUUID() const path = join(claimsPath, writerClaimFilename(input.pid, token)) + const processIdentity = runtimeProcessIdentity(input.pid) const owner: RuntimeDataDirWriterClaim = { schemaVersion: 1, kind: 'migration', pid: input.pid, token, startedAt: input.now().toISOString(), + ...(processIdentity ? { processIdentity } : {}), dataDir: canonicalDataDir } let handle: number | undefined @@ -199,7 +204,7 @@ function acquireWriterClaimSync( } throw new Error(`Kun Runtime writer claim is invalid: ${contenderPath}`) } - if (input.processIsAlive(contender.pid)) { + if (input.processIsAlive(contender.pid, contender)) { throw new Error( contender.kind === 'migration' ? `Kun Runtime data migration is already active in process ${contender.pid}` @@ -283,7 +288,7 @@ function assertRuntimeDataDirLeaseInactiveSync( dataDir: string, input: { pid: number - processIsAlive: (pid: number) => boolean + processIsAlive: RuntimeProcessIsAlive beforeReclaim?: (path: string, expectedRaw: string) => void } ): void { @@ -298,7 +303,7 @@ function assertRuntimeDataDirLeaseInactiveSync( } const owner = parseRuntimeOwner(raw) if (!owner) throw new Error(`Kun Runtime data directory owner record is invalid: ${path}`) - if (input.processIsAlive(owner.pid)) { + if (input.processIsAlive(owner.pid, owner)) { throw new Error( `Kun Runtime data directory is already owned by active process ${owner.pid}: ${dataDir}` ) @@ -316,14 +321,14 @@ export function acquireCanonicalRuntimeMigrationLock( options: { pid?: number now?: () => Date - processIsAlive?: (pid: number) => boolean + processIsAlive?: RuntimeProcessIsAlive beforeStaleReclaim?: (path: string, expectedRaw: string) => void unlinkLock?: (path: string) => void } = {} ): CanonicalRuntimeMigrationLock { const pid = options.pid ?? process.pid const now = options.now ?? (() => new Date()) - const isAlive = options.processIsAlive ?? processIsAlive + const isAlive = options.processIsAlive ?? runtimeProcessIsAlive const unlinkLock = options.unlinkLock ?? unlinkSync const canonicalDirs = [...new Set(dataDirs.map((path) => resolve(path)))].sort() const owned: Array<{ path: string; owner: MigrationLockOwner }> = [] @@ -342,11 +347,13 @@ export function acquireCanonicalRuntimeMigrationLock( processIsAlive: isAlive, beforeReclaim: options.beforeStaleReclaim }) + const processIdentity = runtimeProcessIdentity(pid) const owner: MigrationLockOwner = { schemaVersion: 1, pid, token: randomUUID(), startedAt: now().toISOString(), + ...(processIdentity ? { processIdentity } : {}), dataDir } for (;;) { @@ -382,7 +389,7 @@ export function acquireCanonicalRuntimeMigrationLock( } const current = parseOwner(currentRaw) if (!current) throw new Error(`Kun Runtime migration lock is invalid: ${path}`) - if (isAlive(current.pid)) { + if (isAlive(current.pid, current)) { throw new Error( `Kun Runtime data migration is already active in process ${current.pid}` ) From 4828cf1d0f514268b189317143048ec093e243a9 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 15:58:31 +0800 Subject: [PATCH 07/13] fix(runtime): fence JSONL compaction against appends --- kun/src/adapters/file/atomic-write.ts | 9 ++- kun/src/adapters/file/file-session-jsonl.ts | 45 ++++++++--- .../file/file-session-store.ordering.test.ts | 28 ++++++- kun/src/adapters/file/file-session-store.ts | 80 ++++++++++++++++--- kun/src/adapters/session-event-query.test.ts | 41 +++++++++- 5 files changed, 171 insertions(+), 32 deletions(-) diff --git a/kun/src/adapters/file/atomic-write.ts b/kun/src/adapters/file/atomic-write.ts index 9f54959d8..de645a0c9 100644 --- a/kun/src/adapters/file/atomic-write.ts +++ b/kun/src/adapters/file/atomic-write.ts @@ -3,6 +3,7 @@ import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' export type AtomicWriteFileOptions = { + allowDirectWriteFallback?: boolean renameRetry?: { attempts?: number baseDelayMs?: number @@ -23,9 +24,9 @@ export async function atomicWriteFile( try { await writeFile(tmp, contents, { encoding: 'utf-8', mode: 0o600 }) try { - await renameWithRetry(tmp, path, options.renameRetry) + await renameFileWithRetry(tmp, path, options.renameRetry) } catch (error) { - if (!shouldFallbackToDirectWrite(error)) { + if (options.allowDirectWriteFallback === false || !shouldFallbackToDirectWrite(error)) { throw error } await writeFile(path, contents, { encoding: 'utf-8', mode: 0o600 }) @@ -61,10 +62,10 @@ function describeAtomicWriteError(path: string, error: unknown): unknown { return prefixed } -async function renameWithRetry( +export async function renameFileWithRetry( from: string, to: string, - options: NonNullable | undefined + options?: NonNullable ): Promise { const attempts = Math.max(1, Math.floor(options?.attempts ?? DEFAULT_RENAME_RETRY_ATTEMPTS)) const baseDelayMs = Math.max(0, Math.floor(options?.baseDelayMs ?? DEFAULT_RENAME_RETRY_BASE_DELAY_MS)) diff --git a/kun/src/adapters/file/file-session-jsonl.ts b/kun/src/adapters/file/file-session-jsonl.ts index 742b65cb6..6764fde23 100644 --- a/kun/src/adapters/file/file-session-jsonl.ts +++ b/kun/src/adapters/file/file-session-jsonl.ts @@ -1,11 +1,12 @@ import { randomUUID } from 'node:crypto' import { createReadStream, createWriteStream } from 'node:fs' -import { rename, rm, type FileHandle } from 'node:fs/promises' +import { rm, type FileHandle } from 'node:fs/promises' import type { RuntimeEvent } from '../../contracts/events.js' import { isPublicTurnItem, type TurnItem } from '../../contracts/items.js' import type { ItemHistoryPage, ItemHistoryPageOptions } from '../../ports/session-store.js' import { buildPublicItemHistoryPage, timelineSafeItem } from '../../services/item-history-page.js' import { yieldToEventLoop } from '../hybrid/hybrid-thread-support.js' +import { renameFileWithRetry } from './atomic-write.js' const MS_PER_DAY = 86_400_000 const DEFAULT_ITEM_HISTORY_MAX_RECORD_BYTES = 16 * 1024 * 1024 @@ -34,7 +35,12 @@ export function compactUsageEvents( */ export async function compactUsageEventsJsonlFile( path: string, - options: { nowIso: string; retentionDays: number; maxRecordBytes: number } + options: { + nowIso: string + retentionDays: number + maxRecordBytes: number + commitReplacement?: (replace: () => Promise) => Promise + } ): Promise { const cutoffMs = Date.parse(options.nowIso) - options.retentionDays * MS_PER_DAY if (!Number.isFinite(cutoffMs)) return false @@ -57,11 +63,17 @@ export async function compactUsageEventsJsonlFile( maxRecordBytes: options.maxRecordBytes, keepLine: (event, index) => event.kind !== 'usage' || keepUsageIndexes.has(index) }) - await rename(tmp, path) + const replace = async (): Promise => renameFileWithRetry(tmp, path) + if (options.commitReplacement) { + const committed = await options.commitReplacement(replace) + return committed + } + await replace() return true - } catch (error) { + } finally { + // A stale snapshot deliberately declines replacement. Always remove its + // prepared rewrite; after a successful rename this is a harmless no-op. await rm(tmp, { force: true }).catch(() => undefined) - throw error } } @@ -225,7 +237,12 @@ export async function readLatestItemsFromJsonl( maxRecordBytes?: number rejectMalformed?: boolean } = {} -): Promise<{ items: TurnItem[]; rawCount: number; malformedCount: number }> { +): Promise<{ + items: TurnItem[] + rawCount: number + malformedCount: number + incompleteTrailingRecord: boolean +}> { const maxRecordBytes = Math.max( 1, Math.floor(options.maxRecordBytes ?? DEFAULT_ITEM_HISTORY_MAX_RECORD_BYTES) @@ -235,9 +252,10 @@ export async function readLatestItemsFromJsonl( let remainder = '' let rawCount = 0 let malformedCount = 0 + let incompleteTrailingRecord = false let linesSinceYield = 0 - const acceptLine = async (line: string): Promise => { + const acceptLine = async (line: string, trailing = false): Promise => { if (!line.trim()) return if (Buffer.byteLength(line, 'utf-8') > maxRecordBytes) { throw new Error(`item history record exceeds ${maxRecordBytes} bytes`) @@ -252,7 +270,8 @@ export async function readLatestItemsFromJsonl( if (!latestById.has(item.id)) firstSeenIds.push(item.id) latestById.set(item.id, item) } catch { - malformedCount += 1 + if (trailing) incompleteTrailingRecord = true + else malformedCount += 1 } linesSinceYield += 1 if (linesSinceYield >= YIELD_EVERY_LINES) { @@ -278,18 +297,20 @@ export async function readLatestItemsFromJsonl( throw new Error(`item history record exceeds ${maxRecordBytes} bytes`) } } - await acceptLine(remainder) + await acceptLine(remainder, true) } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error } - if (options.rejectMalformed && malformedCount > 0) { - throw new Error(`item history contains ${malformedCount} malformed record(s)`) + const rejectedRecords = malformedCount + (incompleteTrailingRecord ? 1 : 0) + if (options.rejectMalformed && rejectedRecords > 0) { + throw new Error(`item history contains ${rejectedRecords} malformed record(s)`) } return { items: firstSeenIds.map((id) => latestById.get(id)!), rawCount, - malformedCount + malformedCount, + incompleteTrailingRecord } } diff --git a/kun/src/adapters/file/file-session-store.ordering.test.ts b/kun/src/adapters/file/file-session-store.ordering.test.ts index eb620d398..52bc18df2 100644 --- a/kun/src/adapters/file/file-session-store.ordering.test.ts +++ b/kun/src/adapters/file/file-session-store.ordering.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { makeAssistantTextItem, makeToolCallItem, makeToolResultItem, makeUserItem } from '../../domain/item.js' -import { FileSessionStore } from './file-session-store.js' +import { FileSessionStore, readLatestItemsFromJsonl } from './file-session-store.js' const roots: string[] = [] @@ -143,6 +143,32 @@ describe('FileSessionStore item ordering', () => { expect(await readFile(path, 'utf-8')).toBe(before) }) + it('distinguishes an unterminated trailing write from a malformed completed row', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-session-incomplete-tail-')) + roots.push(root) + const path = join(root, 'messages.jsonl') + const item = makeUserItem({ + id: 'user_1', + threadId: 'thread_incomplete_tail', + turnId: 'turn_1', + text: 'valid' + }) + await appendFile(path, `${JSON.stringify(item)}\n{"id":`) + + await expect(readLatestItemsFromJsonl(path)).resolves.toMatchObject({ + items: [expect.objectContaining({ id: 'user_1' })], + rawCount: 1, + malformedCount: 0, + incompleteTrailingRecord: true + }) + + await appendFile(path, '}\n{broken-json\n') + await expect(readLatestItemsFromJsonl(path)).resolves.toMatchObject({ + malformedCount: 2, + incompleteTrailingRecord: false + }) + }) + it('does not retain a Session item array that exceeds its byte admission limit', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-session-cache-budget-')) roots.push(root) diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts index 0d62d35af..3d8531313 100644 --- a/kun/src/adapters/file/file-session-store.ts +++ b/kun/src/adapters/file/file-session-store.ts @@ -46,6 +46,7 @@ const DEFAULT_ITEMS_CACHE_MAX_BYTES = 16 * 1024 * 1024 const DEFAULT_ITEM_HISTORY_COMPACTION_MIN_BYTES = 4 * 1024 * 1024 const HIGHEST_SEQ_CACHE_MAX_THREADS = 256 const ITEM_HISTORY_REVISION_MAX_THREADS = 512 +const EVENT_HISTORY_REVISION_MAX_THREADS = 512 // A valid model tool argument may contain 1 MiB of JSON, whose escaping can // nearly double the persisted item event. Unresolved `__raw` strings are // summarized before persistence, while replay remains bounded for valid calls. @@ -71,6 +72,8 @@ export class FileSessionStore implements SessionStore { /** Opaque revisions used to fence stale read-compute-rewrite snapshots. */ private readonly itemHistoryRevisions = new Map() private nextItemHistoryRevision = 0 + private readonly eventHistoryRevisions = new Map() + private nextEventHistoryRevision = 0 private readonly highestSeqCache = new Map() private readonly writeQueues = new Map>() private readonly compactionScheduler: SessionCompactionScheduler @@ -136,6 +139,7 @@ export class FileSessionStore implements SessionStore { await this.ensureDir(this.threadDir(threadId)) const path = this.eventsPath(threadId) await appendFile(path, `${JSON.stringify(event)}\n`, { encoding: 'utf-8', mode: 0o600 }) + this.bumpEventHistoryRevision(threadId) const info = await stat(path) this.cacheHighestSeq(threadId, event.seq, info, { preserveHigher: true }) }) @@ -232,21 +236,18 @@ export class FileSessionStore implements SessionStore { } // Scan unlocked so appendItem/updateItem are not queued behind a 350MB parse. const revisionBefore = this.itemHistoryRevision(threadId) - const parsed = await readLatestItemsFromJsonl(path, { rejectMalformed: true }) + const parsed = await readLatestItemsFromJsonl(path) const contents = parsed.items.map((item) => JSON.stringify(item)).join('\n') const output = contents ? `${contents}\n` : '' const afterBytes = Buffer.byteLength(output, 'utf-8') - if (afterBytes >= info.size) { - this.cacheItems(threadId, parsed.items) - return { - compacted: false, - beforeBytes: info.size, - afterBytes: info.size, - itemCount: parsed.items.length - } - } return this.withThreadWrite(threadId, async () => { - if (this.itemHistoryRevision(threadId) !== revisionBefore) { + const currentInfo = await stat(path).catch(() => null) + if ( + this.itemHistoryRevision(threadId) !== revisionBefore || + !currentInfo || + currentInfo.size !== info.size || + currentInfo.mtimeMs !== info.mtimeMs + ) { // A concurrent append invalidated the snapshot; coalesce another pass. this.scheduleItemHistoryCompaction(threadId) return { @@ -256,7 +257,20 @@ export class FileSessionStore implements SessionStore { itemCount: parsed.items.length } } - await this.atomicWrite(path, output) + const malformedCount = parsed.malformedCount + (parsed.incompleteTrailingRecord ? 1 : 0) + if (malformedCount > 0) { + throw new Error(`item history contains ${malformedCount} malformed record(s)`) + } + if (afterBytes >= currentInfo.size) { + this.cacheItems(threadId, parsed.items) + return { + compacted: false, + beforeBytes: currentInfo.size, + afterBytes: currentInfo.size, + itemCount: parsed.items.length + } + } + await atomicWriteFile(path, output, { allowDirectWriteFallback: false }) this.bumpItemsVersion(threadId) this.cacheItems(threadId, parsed.items) this.bumpItemHistoryRevision(threadId) @@ -452,6 +466,7 @@ export class FileSessionStore implements SessionStore { this.itemsCacheBytes.clear() this.itemsCacheVersion.clear() this.itemHistoryRevisions.clear() + this.eventHistoryRevisions.clear() this.highestSeqCache.clear() } @@ -459,6 +474,7 @@ export class FileSessionStore implements SessionStore { this.removeCachedItems(threadId) this.itemsCacheVersion.delete(threadId) this.itemHistoryRevisions.delete(threadId) + this.eventHistoryRevisions.delete(threadId) this.highestSeqCache.delete(threadId) } @@ -498,6 +514,26 @@ export class FileSessionStore implements SessionStore { return this.nextItemHistoryRevision } + private eventHistoryRevision(threadId: string): number { + const revision = this.eventHistoryRevisions.get(threadId) + if (revision === undefined) return this.bumpEventHistoryRevision(threadId) + this.eventHistoryRevisions.delete(threadId) + this.eventHistoryRevisions.set(threadId, revision) + return revision + } + + private bumpEventHistoryRevision(threadId: string): number { + this.nextEventHistoryRevision += 1 + this.eventHistoryRevisions.delete(threadId) + this.eventHistoryRevisions.set(threadId, this.nextEventHistoryRevision) + while (this.eventHistoryRevisions.size > EVENT_HISTORY_REVISION_MAX_THREADS) { + const oldest = this.eventHistoryRevisions.keys().next().value + if (oldest === undefined) break + this.eventHistoryRevisions.delete(oldest) + } + return this.nextEventHistoryRevision + } + private cacheItems(threadId: string, items: TurnItem[]): void { this.removeCachedItems(threadId) const bytes = serializedBytes(items) @@ -618,11 +654,29 @@ export class FileSessionStore implements SessionStore { const path = this.eventsPath(threadId) const info = await stat(path).catch(() => null) if (!info || info.size <= this.usageEventCompaction.maxBytes) return + const revisionBefore = this.eventHistoryRevision(threadId) + let conflicted = false const compacted = await compactUsageEventsJsonlFile(path, { nowIso: this.usageEventCompaction.nowIso(), retentionDays: this.usageEventCompaction.retentionDays, - maxRecordBytes: DEFAULT_EVENT_REPLAY_MAX_RECORD_BYTES + maxRecordBytes: DEFAULT_EVENT_REPLAY_MAX_RECORD_BYTES, + commitReplacement: (replace) => this.withThreadWrite(threadId, async () => { + const currentInfo = await stat(path).catch(() => null) + if ( + this.eventHistoryRevision(threadId) !== revisionBefore || + !currentInfo || + currentInfo.size !== info.size || + currentInfo.mtimeMs !== info.mtimeMs + ) { + conflicted = true + return false + } + await replace() + this.bumpEventHistoryRevision(threadId) + return true + }) }) + if (conflicted) this.scheduleUsageEventCompaction(threadId) if (!compacted) return // Size/mtime changed; drop the stale high-water cache entry so the next // highestSeq() rescans against the rewritten file. diff --git a/kun/src/adapters/session-event-query.test.ts b/kun/src/adapters/session-event-query.test.ts index 120f1c37f..b64dfbb54 100644 --- a/kun/src/adapters/session-event-query.test.ts +++ b/kun/src/adapters/session-event-query.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' +import { appendFile, mkdtemp, readdir, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -144,13 +144,50 @@ describe('compactUsageEventsJsonlFile', () => { await expect(compactUsageEventsJsonlFile(path, { nowIso: '2026-06-03T00:00:00.000Z', retentionDays: 30, - maxRecordBytes: 1024 * 1024 + maxRecordBytes: 1024 * 1024, + commitReplacement: async (replace) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + expect((await readdir(root)).some((name) => name.endsWith('.tmp'))).toBe(true) + await replace() + return true + } })).resolves.toBe(true) const rewritten = (await readFile(path, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) // Keep the heartbeat, the latest pre-cutoff usage anchor, and the latest usage. expect(rewritten.map((event) => event.seq)).toEqual([1, 3, 4]) }) + + it('discards its temporary rewrite when a concurrent append invalidates the snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-usage-compact-conflict-')) + roots.push(root) + const path = join(root, 'events.jsonl') + const lines = [ + { kind: 'usage', seq: 1, timestamp: '2024-01-01T00:00:00.000Z', threadId: 'thr' }, + { kind: 'usage', seq: 2, timestamp: '2024-01-02T00:00:00.000Z', threadId: 'thr' }, + { kind: 'heartbeat', seq: 3, timestamp: '2026-01-01T00:00:00.000Z', threadId: 'thr' } + ] + await writeFile(path, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8') + + await expect(compactUsageEventsJsonlFile(path, { + nowIso: '2026-06-03T00:00:00.000Z', + retentionDays: 30, + maxRecordBytes: 1024 * 1024, + commitReplacement: async () => { + await appendFile(path, `${JSON.stringify({ + kind: 'heartbeat', + seq: 4, + timestamp: '2026-01-01T00:00:01.000Z', + threadId: 'thr' + })}\n`) + return false + } + })).resolves.toBe(false) + + const preserved = (await readFile(path, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) + expect(preserved.map((event) => event.seq)).toEqual([1, 2, 3, 4]) + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) }) describe('sessionEventExists early-exit', () => { From 6d037328d2800c9078a72c6289c5f49d9a7fb132 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:46:40 +0800 Subject: [PATCH 08/13] docs(release): add v0.3.5 release notes --- release/release-v0.3.5.md | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 release/release-v0.3.5.md diff --git a/release/release-v0.3.5.md b/release/release-v0.3.5.md new file mode 100644 index 000000000..82e85f4b5 --- /dev/null +++ b/release/release-v0.3.5.md @@ -0,0 +1,46 @@ +# Kun v0.3.5 + +v0.3.5 是一个以数据安全、Windows 兼容和更新体验为重点的稳定性版本。它修复长会话压缩与写入竞争、进程 PID 被系统复用后的陈旧锁误判、SQLite 会话列表回退扫描,以及 Windows 自动更新后桌面快捷方式消失等问题。 + +### 长会话持久化与压缩(#1185) + +- `events.jsonl` 和 `messages.jsonl` 的后台压缩现在与同一会话的追加写入协调;压缩期间只要文件发生变化,就会丢弃旧快照并重新调度,不会用过期结果覆盖新记录。 +- Windows 上的原子替换增加受控重试,并禁止压缩失败后直接覆盖正在使用的历史文件。 +- 无换行的最后一段只按“可能仍在写入”处理;文件中间的真实损坏仍会明确报错,不会被静默忽略。 +- 修复异步替换尚未完成就提前删除临时文件的时序问题,显式刷新压缩任务后可以可靠读到最终结果。 + +### Runtime 锁与 PID 复用(#1186) + +- Runtime owner、写入声明和迁移锁在 PID 之外记录进程启动身份,避免 Windows 或 Unix 复用旧 PID 后把无关的新进程误认成锁持有者。 +- 旧格式锁继续兼容:可取得启动时间时会核对锁记录与进程先后关系;无法可靠检查时仍保持安全失败,不会冒险抢占可能存活的写入者。 +- 锁文件格式版本保持兼容,不需要迁移现有会话或数据目录。 + +### 大量会话时的列表性能(#1187) + +- 补齐 Hybrid Thread Store 到 SQLite 索引的计数调用,正常分页不再因缺少 `indexCount` 而抛错并退回全量 JSONL 目录扫描。 +- 会话内容和索引格式没有变化;大量历史会话用户会明显减少重复扫描和误导性的索引重建日志。 + +### Windows 文件权限兼容(#1188) + +- 附件、记忆、产物、子代理、后台 Shell 输出、请求追踪、浏览器审计、导入和 Runtime 数据迁移等路径不再因 Windows 不支持 POSIX `chmod` 语义而失败。 +- macOS 和 Linux 继续执行原有权限加固,并保留真实权限错误,不会因为兼容 Windows 而降低其他平台的安全检查。 + +### Windows 自动更新快捷方式 + +- 修复自动更新在清理旧安装范围或旧目录时删除桌面快捷方式,而新安装因 `--updated` 模式跳过重建的问题。 +- 新版本在安装内容验证成功后按当前安装设置重新创建桌面快捷方式,并继续遵守“不创建桌面快捷方式”参数和 Windows 应用标识。 + +### Git / worktree 选择窗口(#1189) + +- 分支和 worktree 选择窗口改为渲染到页面顶层,不再被初始聊天界面的滚动容器裁剪。 +- 弹窗会根据上下可用空间自动定位,并限制在当前视口内;窗口缩放、内部滚动、外部点击和 Escape 关闭行为保持正常。 + +### 影响与升级 + +- 建议 v0.3.4 用户直接升级到 v0.3.5;本次升级不需要迁移或删除会话、设置、工作区和 Provider 配置。 +- 不要为了处理压缩错误手工删除 `events.jsonl` 或 `messages.jsonl`。升级后让 Kun 自动重试;如果仍有报错,请保留原文件和同一时间的日志用于排查。 +- 当前桌面快捷方式已经丢失的 Windows 用户,可先从开始菜单启动 Kun 或重新运行安装包;升级到 v0.3.5 时安装器会按配置重新创建快捷方式。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.3.4...v0.3.5 From fa0dea8ee2cca4cd14f1b63f510b980a9bd860fd Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 17:20:53 +0800 Subject: [PATCH 09/13] fix(i18n): localize advanced gateway settings --- .../settings-section-model-routes.test.ts | 45 +++++ .../settings-section-model-routes.tsx | 5 +- .../settings-section-providers-view.tsx | 1 + src/renderer/src/locales/hi/settings.ts | 2 + .../src/locales/hi/settings/model-routes.json | 183 ++++++++++++++++++ src/renderer/src/locales/ja/settings.ts | 2 + .../src/locales/ja/settings/model-routes.json | 183 ++++++++++++++++++ src/renderer/src/locales/ko/settings.ts | 2 + .../src/locales/ko/settings/model-routes.json | 183 ++++++++++++++++++ .../src/locales/locale-resources.test.ts | 49 +++++ src/renderer/src/locales/ru/settings.ts | 2 + .../src/locales/ru/settings/model-routes.json | 183 ++++++++++++++++++ src/renderer/src/locales/th/settings.ts | 2 + .../src/locales/th/settings/model-routes.json | 183 ++++++++++++++++++ 14 files changed, 1024 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/locales/hi/settings/model-routes.json create mode 100644 src/renderer/src/locales/ja/settings/model-routes.json create mode 100644 src/renderer/src/locales/ko/settings/model-routes.json create mode 100644 src/renderer/src/locales/ru/settings/model-routes.json create mode 100644 src/renderer/src/locales/th/settings/model-routes.json diff --git a/src/renderer/src/components/settings-section-model-routes.test.ts b/src/renderer/src/components/settings-section-model-routes.test.ts index 61e3182d7..e50d46d03 100644 --- a/src/renderer/src/components/settings-section-model-routes.test.ts +++ b/src/renderer/src/components/settings-section-model-routes.test.ts @@ -8,6 +8,7 @@ import { modelProviderPresetAccountProfile, type ModelProviderSettingsV1 } from '@shared/app-settings' +import { APP_LOCALES, type AppLocale } from '@shared/app-locales' import i18n from '../i18n' import { ModelRoutesSettings } from './settings-section-model-routes' @@ -33,6 +34,16 @@ function settings(): ModelProviderSettingsV1 { } } +const localRelayProviderLabels: Record = { + en: 'Local relay provider', + zh: '本地中转供应商', + ru: 'Местный поставщик ретрансляции', + hi: 'स्थानीय रिले प्रदाता', + th: 'ผู้ให้บริการรีเลย์ท้องถิ่น', + ja: 'ローカルリレープロバイダー', + ko: '지역 중계 제공업체' +} + describe('ModelRoutesSettings', () => { beforeEach(async () => { await i18n.changeLanguage('en') @@ -147,6 +158,40 @@ describe('ModelRoutesSettings', () => { await act(async () => { renderer.unmount() }) }) + it('reacts to every selectable locale without remounting or fallback copy', async () => { + let renderer!: ReactTestRenderer + await act(async () => { + renderer = createRenderer(createElement(ModelRoutesSettings, { + settings: settings(), + onChange: () => undefined, + active: false + })) + }) + + for (const locale of APP_LOCALES) { + await act(async () => { + await i18n.changeLanguage(locale) + }) + expect(textContent(renderer.root)).toContain(localRelayProviderLabels[locale]) + } + + await act(async () => { renderer.unmount() }) + }) + + it('uses the parent provider translation consistently when nested i18n state lags', async () => { + await i18n.changeLanguage('zh') + const translation = i18n.getFixedT('en', 'settings') + const html = renderToStaticMarkup(createElement(ModelRoutesSettings, { + settings: settings(), + onChange: () => undefined, + translation + })) + + expect(html).toContain('Local relay provider') + expect(html).toContain('Gateway & API') + expect(html).not.toMatch(/[\p{Script=Han}]/u) + }) + it('opens a detailed local API dialog with endpoint examples', async () => { vi.stubGlobal('window', { kunGui: { diff --git a/src/renderer/src/components/settings-section-model-routes.tsx b/src/renderer/src/components/settings-section-model-routes.tsx index 4e76e9929..64749354b 100644 --- a/src/renderer/src/components/settings-section-model-routes.tsx +++ b/src/renderer/src/components/settings-section-model-routes.tsx @@ -109,6 +109,7 @@ function EmptyRoutePoolState({ onAdd, t }: { onAdd: () => void; t: TFunction }): export function ModelRoutesSettings({ settings, onChange, + translation, saveStatus = 'idle', saveError, onRetrySave, @@ -117,6 +118,7 @@ export function ModelRoutesSettings({ }: { settings: ModelProviderSettingsV1 onChange: (next: ModelProviderSettingsV1) => void + translation?: TFunction saveStatus?: 'idle' | 'saving' | 'saved' | 'error' saveError?: string | null onRetrySave?: () => void @@ -124,7 +126,8 @@ export function ModelRoutesSettings({ /** The configured local Kun endpoint; this is also the public gateway origin. */ publicBaseUrl?: string }): ReactElement { - const { t, i18n } = useTranslation('settings') + const { t: localTranslation, i18n } = useTranslation('settings') + const t = translation ?? localTranslation const [selectedId, setSelectedId] = useState(settings.routePools[0]?.id ?? '') const [status, setStatus] = useState(null) const [statusError, setStatusError] = useState('') diff --git a/src/renderer/src/components/settings-section-providers-view.tsx b/src/renderer/src/components/settings-section-providers-view.tsx index c4f967d16..40e5e7a33 100644 --- a/src/renderer/src/components/settings-section-providers-view.tsx +++ b/src/renderer/src/components/settings-section-providers-view.tsx @@ -310,6 +310,7 @@ export function ProvidersSettingsView({ view }: { view: Record }): update({ provider: { routePools: next.routePools, localGateway: next.localGateway } })} + translation={t} saveStatus={saveStatus} saveError={saveError} onRetrySave={retrySave} diff --git a/src/renderer/src/locales/hi/settings.ts b/src/renderer/src/locales/hi/settings.ts index 6ce53f439..0d0ea11af 100644 --- a/src/renderer/src/locales/hi/settings.ts +++ b/src/renderer/src/locales/hi/settings.ts @@ -1,4 +1,5 @@ import navigationProviders from './settings/navigation-providers.json' +import modelRoutes from './settings/model-routes.json' import providerMediaMcp from './settings/provider-media-mcp.json' import mcpMigration from './settings/mcp-migration.json' import migrationSystem from './settings/migration-system.json' @@ -6,6 +7,7 @@ import codePersonas from './settings/code-personas.json' const settings = { ...navigationProviders, + ...modelRoutes, ...providerMediaMcp, ...mcpMigration, ...migrationSystem, diff --git a/src/renderer/src/locales/hi/settings/model-routes.json b/src/renderer/src/locales/hi/settings/model-routes.json new file mode 100644 index 000000000..61be7a250 --- /dev/null +++ b/src/renderer/src/locales/hi/settings/model-routes.json @@ -0,0 +1,183 @@ +{ + "modelRoutes": { + "strategyPriority": "प्राथमिकता विफलता", + "strategyRoundRobin": "राउंड रोबिन", + "strategyWeightedRoundRobin": "भारित राउंड रोबिन", + "strategyLeastLatency": "सबसे कम विलंबता", + "strategyAdaptive": "स्थिरता-प्रथम अनुकूली", + "emptyTitle": "अपना पहला रूटेड मॉडल जोड़ें", + "gatewayMultipleModelsDesc": "एक स्थानीय रिले प्रदाता में कई सार्वजनिक मॉडल हो सकते हैं।", + "addModel": "मॉडल जोड़ें", + "defaultRouteName": "रूटेड मॉडल {{index}}", + "testCreateFailed": "रूट परीक्षण बनाने में असमर्थ", + "testButtonCreating": "परीक्षण बनाना", + "testButtonInProgress": "परीक्षण प्रगति पर है", + "testButtonFixSave": "पहले सेव विफलता को ठीक करें", + "testButtonWaitSave": "स्थानीय सहेजे जाने की प्रतीक्षा की जा रही है", + "testButtonEnableFirst": "परीक्षण करने के लिए सक्षम करें", + "testButtonFixInvalidTargets": "परीक्षण के लिए अमान्य लक्ष्य ठीक करें", + "runtimeUnavailable": "Kun Runtime अनुपलब्ध है", + "testButtonWaitSync": "कॉन्फ़िगरेशन समन्वयन की प्रतीक्षा की जा रही है", + "testButtonRun": "संपूर्ण मार्ग का परीक्षण करें", + "localSaveSaving": "स्थानीय स्तर पर बचत हो रही है", + "localSaveFailed": "स्थानीय बचत विफल रही", + "localSaveComplete": "स्थानीय स्तर पर सहेजा गया", + "runtimeSynced": "Kun Runtime समन्वयित", + "runtimeSyncFailed": "Kun Runtime सिंक विफल रहा", + "runtimeNotRunning": "Kun Runtime नहीं चल रहा है", + "runtimeNotConnected": "Kun Runtime कनेक्ट नहीं है", + "runtimeSyncing": "Kun Runtime से सिंक किया जा रहा है", + "runtimeWaitingForSync": "Kun Runtime सिंक की प्रतीक्षा की जा रही है", + "tabsAria": "मॉडल मार्ग सेटिंग्स", + "tabGateway": "गेटवे और API", + "tabModels": "मॉडल और लक्ष्य", + "tabResilience": "लचीलापन", + "tabMonitoring": "सत्यापन एवं निगरानी", + "localRelayProvider": "स्थानीय रिले प्रदाता", + "enabledModelCount": "{{enabled}} / {{total}} मॉडल सक्षम", + "providerNameAria": "रिले प्रदाता का नाम", + "providerDesc": "एक प्रदाता कई सार्वजनिक मॉडल पेश करता है, प्रत्येक स्वतंत्र मार्ग लक्ष्य और लोड रणनीति के साथ।", + "enableLocalApi": "स्थानीय API सक्षम करें", + "localOnlyNoAuth": "केवल स्थानीय पहुंच · कोई प्रमाणीकरण नहीं", + "retrySave": "सहेजने का पुनः प्रयास करें", + "runtimeUnavailableHint": "स्थानीय सेटिंग्स अप्रभावित हैं और कुन शुरू होने पर स्वचालित रूप से सिंक हो जाएंगी।", + "localApi": "स्थानीय API", + "localApiEnabledLocalOnly": "सक्षम · केवल स्थानीय पहुंच", + "disabled": "अक्षम", + "apiCompatibilityDesc": "OpenAI Chat Completions और प्रतिक्रियाओं के साथ संगत। सार्वजनिक मॉडल ID नीचे दिए गए रूटेड मॉडल से आते हैं।", + "copyLocalApiAddress": "स्थानीय API पता कॉपी करें", + "copied": "कॉपी किया गया", + "copy": "प्रतिलिपि", + "copyCurl": "cURL कॉपी करें", + "copyCurlUnavailable": "किसी उदाहरण की प्रतिलिपि बनाने से पहले सक्षम लक्ष्य के साथ कम से कम एक मार्ग सक्षम करें।", + "apiDocs": "API गाइड", + "routedModels": "रूट किए गए मॉडल", + "choosePool": "इसके लक्ष्य पूल को कॉन्फ़िगर करने के लिए एक मॉडल का चयन करें।", + "availableTargets": "{{available}}/{{total}} उपलब्ध है", + "invalidTargets": "{{count}} को मरम्मत की आवश्यकता है", + "noModels": "अभी तक कोई रूटेड मॉडल नहीं है", + "routedModel": "रूटेड मॉडल", + "routeModelNameAria": "रूट किए गए मॉडल का नाम", + "hotUpdateHint": "सहेजा गया कॉन्फ़िगरेशन सक्रिय Kun Runtime को हॉट-अपडेट करता है।", + "enable": "सक्षम", + "enablePoolAria": "रूट पूल सक्षम करें", + "publicModelId": "सार्वजनिक मॉडल ID", + "publicModelIdRequired": "एक सार्वजनिक मॉडल ID दर्ज करें।", + "publicModelIdDuplicate": "सार्वजनिक मॉडल ID {{modelId}} पहले से ही किसी अन्य मार्ग द्वारा उपयोग किया जाता है।", + "loadStrategy": "रणनीति लोड करें", + "routeTargets": "मार्ग लक्ष्य", + "routeTargetsHint": "उन लक्ष्यों को सक्षम करें जो अनुरोध प्राप्त कर सकते हैं। प्राथमिकता निर्धारित करने के लिए तीरों को खींचें या उपयोग करें।", + "addTarget": "लक्ष्य जोड़ें", + "addTargetUnavailable": "रूट लक्ष्य जोड़ने से पहले कम से कम एक मॉडल वाला प्रदाता जोड़ें।", + "reorderTarget": "लक्ष्य को पुनः व्यवस्थित करने के लिए खींचें", + "moveTargetUp": "लक्ष्य को ऊपर ले जाएँ", + "moveTargetDown": "लक्ष्य को नीचे ले जाएँ", + "targetEnabled": "सक्रिय", + "targetProvider": "प्रदाता", + "targetModel": "नमूना", + "targetWeight": "वज़न", + "targetHealth": "स्वास्थ्य", + "weightInactive": "वज़न का उपयोग केवल भारित राउंड रॉबिन द्वारा किया जाता है।", + "deleteTarget": "लक्ष्य हटाएँ", + "providerDeleted": "प्रदाता हटा दिया गया: {{providerId}}", + "originalModel": "मूल मॉडल: {{modelId}}", + "modelDeleted": "मॉडल हटाया गया: {{modelId}}", + "weight": "वज़न", + "notProbed": "जांच नहीं की गई", + "successCount": "{{successes}}/{{total}} सफल", + "providerMissingWarning": "प्रदाता {{providerId}} अब मौजूद नहीं है। सन्दर्भ सुरक्षित रखा गया; एक प्रतिस्थापन प्रदाता चुनें या इस लक्ष्य को हटा दें।", + "modelMissingWarning": "मॉडल {{modelId}} अब {{providerId}} से उपलब्ध नहीं है। सन्दर्भ सुरक्षित रखा गया; कोई प्रतिस्थापन मॉडल चुनें या इस लक्ष्य को हटा दें।", + "deleteModel": "मॉडल हटाएँ", + "confirmDeleteModel": "रूट किए गए मॉडल {{modelId}} और उसके सभी लक्ष्य हटाएं?", + "failoverRules": "विफलता नियम", + "networkError": "नेटवर्क त्रुटि", + "requestTimeout": "ब्रेक का अनुरोध", + "credentialError": "401 / 403 क्रेडेंशियल त्रुटि", + "failoverStatuses": "विफलता HTTP स्थिति कोड", + "failoverStatusesInvalid": "400 से 599 तक अल्पविराम या स्थान से अलग किए गए HTTP स्थिति कोड का उपयोग करें।", + "afterStreamNoRetry": "streaming आउटपुट शुरू होने के बाद, अनुरोध वहीं रुक जाता है और दोबारा प्रयास नहीं करेगा या tools को निष्पादित नहीं करेगा।", + "healthCircuit": "स्वास्थ्य एवं सर्किट ब्रेकिंग", + "consecutiveFailures": "लगातार असफलताएँ", + "cooldownSeconds": "कूलडाउन सेकंड", + "halfOpenProbes": "आधी-अधूरी जांच", + "routeValidation": "मार्ग सत्यापन", + "routeValidationDesc": "Kun Runtime अतुल्यकालिक रूप से परीक्षण चलाता है। आपके जाने के बाद भी वे जारी रहते हैं और आपके लौटने पर प्रगति और परिणाम बहाल करते हैं।", + "blockedSaveFailed": "स्थानीय बचत विफल रही. पहले सहेजने का पुनः प्रयास करें; रूट परीक्षणों के लिए सहेजे न गए कॉन्फ़िगरेशन का उपयोग नहीं किया जाता है।", + "blockedSaving": "कॉन्फ़िगरेशन स्थानीय रूप से सहेजा जा रहा है. सहेजने और Kun Runtime सिंक पूर्ण होने के बाद परीक्षण उपलब्ध है।", + "blockedInvalidTargets": "इस रूट में {{count}} अमान्य संदर्भ हैं और कोई निष्पादन योग्य लक्ष्य नहीं है। पहले प्रदाता या मॉडल को बदलें।", + "blockedNoTargets": "इस मार्ग में कोई वैध लक्ष्य सक्षम नहीं है. पहले कोई लक्ष्य जोड़ें या सक्षम करें.", + "blockedSyncFailed": "स्थानीय कॉन्फ़िगरेशन सहेजा गया था, लेकिन Kun Runtime सिंक विफल रहा। रनटाइम लॉग की जाँच करें और सहेजने का पुनः प्रयास करें।", + "blockedSyncFailedWithMessage": "स्थानीय कॉन्फ़िगरेशन सहेजा गया था, लेकिन Kun Runtime सिंक विफल रहा। {{message}}", + "blockedRuntimeUnavailable": "स्थानीय कॉन्फ़िगरेशन सहेजा गया था, लेकिन Kun Runtime अनुपलब्ध है और स्टार्टअप के बाद स्वचालित रूप से सिंक हो जाएगा।", + "blockedRuntimeUnavailableWithMessage": "स्थानीय कॉन्फ़िगरेशन सहेजा गया था, लेकिन Kun Runtime अनुपलब्ध है और स्टार्टअप के बाद स्वचालित रूप से सिंक हो जाएगा। {{message}}", + "blockedWaitingForSync": "स्थानीय कॉन्फ़िगरेशन सहेजा गया था और समान रूट पूल और स्थानीय API स्थिति को लागू करने के लिए Kun Runtime की प्रतीक्षा कर रहा है।", + "blockedRuntimeNotReady": "Kun Runtime अभी इस संपूर्ण मार्ग का परीक्षण करने के लिए तैयार नहीं है।", + "testStatus": { + "queued": "चलने की प्रतीक्षा की जा रही है", + "running": "परीक्षण प्रगति पर है", + "succeeded": "रूट परीक्षण सफल रहा", + "failed": "रूट परीक्षण विफल रहा" + }, + "attemptStatus": { + "running": "परीक्षण", + "succeeded": "सफल हुए", + "failed": "विफल, लक्ष्य बदला" + }, + "attemptedTargets": "{{attempted}} / {{total}} लक्ष्य का प्रयास किया गया", + "testingTarget": "परीक्षण: {{target}}", + "finalTargetValue": "अंतिम लक्ष्य: {{target}}", + "modelResponse": "मॉडल प्रतिक्रिया: {{response}}", + "noTests": "कोई रूट परीक्षण रिकॉर्ड नहीं", + "currentTargetProgress": "वर्तमान लक्ष्य प्रगति", + "order": "आदेश", + "target": "लक्ष्य", + "status": "स्थिति", + "latencyError": "विलंबता/त्रुटि", + "recentTests": "हाल के परीक्षण रिकॉर्ड", + "time": "समय", + "result": "परिणाम", + "attempts": "प्रयास", + "finalTarget": "अंतिम लक्ष्य", + "recentEvents": "हाल की मार्ग घटनाएँ", + "latency": "विलंब", + "noEvents": "कोई रूट इवेंट नहीं", + "apiDialogTitle": "स्थानीय API गाइड", + "apiDialogDesc": "केवल स्थानीय प्रक्रियाओं के लिए OpenAI-संगत समापन बिंदु; किसी प्राधिकरण शीर्षलेख की आवश्यकता नहीं है.", + "closeApiDocs": "API गाइड बंद करें", + "endpoints": "अंतिमबिंदुओं", + "modelList": "मॉडल सूची", + "chatCompletions": "Chat Completions", + "responses": "जवाब", + "baseUrlLabel": "Base URL", + "prerequisites": "आवश्यक शर्तें", + "prerequisitesDesc": "स्थानीय API और कम से कम एक रूटेड मॉडल सक्षम करें। अनुरोध के मॉडल फ़ील्ड में इसके सार्वजनिक मॉडल ID का उपयोग करें।", + "openAiCompatible": "ओपनएआई संगत", + "keyFields": "प्रमुख क्षेत्र", + "responsesAndLimits": "प्रतिक्रियाएँ और सीमाएँ", + "curlExample": "cURL उदाहरण", + "copyExample": "उदाहरण कॉपी करें", + "apiSecurityWarning": "यह रिले सार्वजनिक API सेवा नहीं है। यह केवल स्थानीय लूपबैक पते से जुड़ता है और वर्तमान में इसका कोई प्रमाणीकरण नहीं है। पोर्ट फ़ॉरवर्डिंग या असुरक्षित रिवर्स प्रॉक्सी के माध्यम से इसे LAN या इंटरनेट के संपर्क में न लाएँ।", + "guideModelsDesc": "स्थानीय रिले में सभी सक्षम रूटेड मॉडलों को सूचीबद्ध करता है। लौटाया गया प्रत्येक data[].id जनरेशन अनुरोधों द्वारा उपयोग किया जाने वाला सार्वजनिक मॉडल ID है।", + "guideModelsNoBody": "किसी अनुरोध निकाय की आवश्यकता नहीं है.", + "guideModelsEnabledOnly": "केवल सक्षम रूट किए गए मॉडल ही लौटाए जाते हैं; अक्षम या अमान्य पूल छोड़े गए हैं।", + "guideModelsResponse": "सफल प्रतिक्रिया OpenAI शैली का object: \"list\" और data सरणी लौटाती है।", + "guideModelsDisabled": "स्थानीय API अक्षम होने पर 404 (gateway_disabled) लौटाता है।", + "guideResponsesDesc": "एकल अनुरोध मान के रूप में input भेजने वाले ग्राहकों के लिए OpenAI Responses प्रारूप में सामग्री उत्पन्न करता है।", + "guideFieldModel": "model: आवश्यक है। {{modelId}} जैसे सार्वजनिक मॉडल ID का उपयोग करें।", + "guideResponsesInput": "input: आवश्यक। एक स्ट्रिंग या संदेश सरणी स्वीकार करता है।", + "guideResponsesStream": "stream: वैकल्पिक। सच्चा रिटर्न Server-Sent Events।", + "guideResponsesOptional": "max_output_tokens, tools, और reasoning_effort वैकल्पिक हैं।", + "guideResponsesNonStreaming": "गैर-streaming प्रतिक्रिया object: \"response\" का उपयोग करती है और टेक्स्ट output में होता है।", + "guideResponsesStreaming": "एक streaming प्रतिक्रिया क्रम में response.created, response.output_text.delta, और response.completed उत्सर्जित करती है।", + "guideChatDesc": "अधिकांश OpenAI-संगत SDK द्वारा उपयोग किए जाने वाले OpenAI Chat Completions प्रारूप में वार्तालाप प्रतिक्रिया उत्पन्न करता है।", + "guideChatMessages": "messages: आवश्यक है और खाली नहीं हो सकता। system, developer, user, assistant और tool संदेश समर्थित हैं।", + "guideChatStream": "stream: वैकल्पिक। सच्चा रिटर्न SSE; गलत रिटर्न पूर्ण JSON।", + "guideChatTools": "tools: वैकल्पिक है। OpenAI function tool परिभाषाओं का उपयोग होता है; छवियाँ केवल Base64 data URL के रूप में स्वीकार होती हैं।", + "guideChatNonStreaming": "गैर-streaming टेक्स्ट choices[0].message.content में है।", + "guideChatStreaming": "स्ट्रीमिंग आउटपुट data: [DONE] के साथ समाप्त होता है।", + "guideChatModelMissing": "एक अज्ञात मॉडल 404 (model_not_found) लौटाता है।", + "exampleChatPrompt": "नमस्ते कुन!", + "exampleResponsesPrompt": "एक वाक्य में कुन स्थानीय प्रवेश द्वार का परिचय दें।", + "statusRequestFailed": "Kun Runtime स्थिति अनुरोध विफल ({{status}})" + } +} diff --git a/src/renderer/src/locales/ja/settings.ts b/src/renderer/src/locales/ja/settings.ts index 6ce53f439..0d0ea11af 100644 --- a/src/renderer/src/locales/ja/settings.ts +++ b/src/renderer/src/locales/ja/settings.ts @@ -1,4 +1,5 @@ import navigationProviders from './settings/navigation-providers.json' +import modelRoutes from './settings/model-routes.json' import providerMediaMcp from './settings/provider-media-mcp.json' import mcpMigration from './settings/mcp-migration.json' import migrationSystem from './settings/migration-system.json' @@ -6,6 +7,7 @@ import codePersonas from './settings/code-personas.json' const settings = { ...navigationProviders, + ...modelRoutes, ...providerMediaMcp, ...mcpMigration, ...migrationSystem, diff --git a/src/renderer/src/locales/ja/settings/model-routes.json b/src/renderer/src/locales/ja/settings/model-routes.json new file mode 100644 index 000000000..91446165f --- /dev/null +++ b/src/renderer/src/locales/ja/settings/model-routes.json @@ -0,0 +1,183 @@ +{ + "modelRoutes": { + "strategyPriority": "優先フェイルオーバー", + "strategyRoundRobin": "ラウンドロビン", + "strategyWeightedRoundRobin": "加重ラウンドロビン", + "strategyLeastLatency": "最小のレイテンシ", + "strategyAdaptive": "安定性優先の適応型", + "emptyTitle": "最初のルーティングされたモデルを追加する", + "gatewayMultipleModelsDesc": "1 つのローカル リレー プロバイダーに複数のパブリック モデルを含めることができます。", + "addModel": "モデルの追加", + "defaultRouteName": "配線済みモデル {{index}}", + "testCreateFailed": "ルートテストを作成できません", + "testButtonCreating": "テストの作成", + "testButtonInProgress": "テスト中", + "testButtonFixSave": "まず保存失敗を修正してください", + "testButtonWaitSave": "ローカル保存を待っています", + "testButtonEnableFirst": "テストを有効にする", + "testButtonFixInvalidTargets": "テストする無効なターゲットを修正する", + "runtimeUnavailable": "Kun Runtime は使用できません", + "testButtonWaitSync": "構成の同期を待機しています", + "testButtonRun": "完全なルートをテストする", + "localSaveSaving": "ローカルに保存する", + "localSaveFailed": "ローカル保存に失敗しました", + "localSaveComplete": "ローカルに保存されました", + "runtimeSynced": "Kun Runtime が同期されました", + "runtimeSyncFailed": "Kun Runtime 同期に失敗しました", + "runtimeNotRunning": "Kun Runtime が実行されていません", + "runtimeNotConnected": "Kun Runtime が接続されていません", + "runtimeSyncing": "Kun Runtime に同期中", + "runtimeWaitingForSync": "Kun Runtime 同期を待機しています", + "tabsAria": "モデルルートの設定", + "tabGateway": "ゲートウェイ & API", + "tabModels": "モデルとターゲット", + "tabResilience": "回復力", + "tabMonitoring": "検証とモニタリング", + "localRelayProvider": "ローカルリレープロバイダー", + "enabledModelCount": "{{enabled}} / {{total}} モデルが有効になりました", + "providerNameAria": "リレープロバイダー名", + "providerDesc": "1 つのプロバイダーが複数のパブリック モデルを提供し、それぞれが独立したルート ターゲットと負荷戦略を備えています。", + "enableLocalApi": "ローカル API を有効にする", + "localOnlyNoAuth": "ローカルアクセスのみ・認証なし", + "retrySave": "保存を再試行", + "runtimeUnavailableHint": "ローカル設定は影響を受けず、Kun の起動時に自動的に同期されます。", + "localApi": "ローカル API", + "localApiEnabledLocalOnly": "有効 · ローカルアクセスのみ", + "disabled": "無効", + "apiCompatibilityDesc": "OpenAI Chat Completionsおよびレスポンスと互換性があります。パブリック モデル ID は、以下の配線済みモデルに由来しています。", + "copyLocalApiAddress": "ローカルのAPIアドレスをコピーします", + "copied": "コピーされました", + "copy": "コピー", + "copyCurl": "cURLをコピー", + "copyCurlUnavailable": "例をコピーする前に、有効なターゲットを持つ少なくとも 1 つのルートを有効にします。", + "apiDocs": "API ガイド", + "routedModels": "ルーティングモデル", + "choosePool": "ターゲット プールを構成するモデルを選択します。", + "availableTargets": "{{available}}/{{total}} あり", + "invalidTargets": "{{count}}は修理が必要です", + "noModels": "配線済みモデルはまだありません", + "routedModel": "ルーティングモデル", + "routeModelNameAria": "ルーティングモデル名", + "hotUpdateHint": "保存された構成により、アクティブな Kun Runtime がホットアップデートされます。", + "enable": "有効にする", + "enablePoolAria": "ルートプールを有効にする", + "publicModelId": "公開モデル ID", + "publicModelIdRequired": "パブリック モデル ID を入力します。", + "publicModelIdDuplicate": "パブリック モデル ID {{modelId}} は、別のルートですでに使用されています。", + "loadStrategy": "ロード戦略", + "routeTargets": "ルートターゲット", + "routeTargetsHint": "リクエストを受信できるターゲットを有効にします。ドラッグするか矢印を使用して優先度を設定します。", + "addTarget": "ターゲットの追加", + "addTargetUnavailable": "ルート ターゲットを追加する前に、少なくとも 1 つのモデルを持つプロバイダーを追加します。", + "reorderTarget": "ドラッグしてターゲットを並べ替えます", + "moveTargetUp": "ターゲットを上に移動", + "moveTargetDown": "ターゲットを下に移動", + "targetEnabled": "有効", + "targetProvider": "プロバイダー", + "targetModel": "モデル", + "targetWeight": "重さ", + "targetHealth": "健康", + "weightInactive": "重みは重み付きラウンドロビンでのみ使用されます。", + "deleteTarget": "対象の削除", + "providerDeleted": "プロバイダーが削除されました: {{providerId}}", + "originalModel": "元のモデル: {{modelId}}", + "modelDeleted": "削除されたモデル: {{modelId}}", + "weight": "重さ", + "notProbed": "調査されていない", + "successCount": "{{successes}}/{{total}} 成功しました", + "providerMissingWarning": "プロバイダー {{providerId}} はもう存在しません。参照は保存されました。代替プロバイダーを選択するか、このターゲットを削除してください。", + "modelMissingWarning": "モデル {{modelId}} は、{{providerId}} からは入手できなくなりました。参照は保存されました。代替モデルを選択するか、このターゲットを削除してください。", + "deleteModel": "モデルの削除", + "confirmDeleteModel": "配線済みモデル {{modelId}} とそのすべてのターゲットを削除しますか?", + "failoverRules": "フェイルオーバールール", + "networkError": "ネットワークエラー", + "requestTimeout": "リクエストタイムアウト", + "credentialError": "401 / 403 資格情報エラー", + "failoverStatuses": "フェイルオーバー HTTP ステータス コード", + "failoverStatusesInvalid": "400 から 599 までの、コンマまたはスペースで区切られた HTTP ステータス コードを使用します。", + "afterStreamNoRetry": "streaming 出力が開始されると、要求はその場で停止し、再試行または tools の再実行は行われません。", + "healthCircuit": "健康とサーキットブレーカー", + "consecutiveFailures": "連続失敗", + "cooldownSeconds": "クールダウン秒数", + "halfOpenProbes": "ハーフオープンプローブ", + "routeValidation": "ルートの検証", + "routeValidationDesc": "Kun Runtime はテストを非同期で実行します。退出した後も継続し、戻ったときに進行状況と結果が復元されます。", + "blockedSaveFailed": "ローカル保存に失敗しました。まず保存を再試行してください。保存されていない設定はルート テストには使用されません。", + "blockedSaving": "構成はローカルに保存されています。保存して Kun Runtime 同期が完了すると、テストが可能になります。", + "blockedInvalidTargets": "このルートには {{count}} 無効な参照があり、実行可能なターゲットがありません。まずプロバイダーまたはモデルを置き換えます。", + "blockedNoTargets": "このルートには有効な有効なターゲットがありません。まずターゲットを追加または有効にします。", + "blockedSyncFailed": "ローカル構成は保存されましたが、Kun Runtime 同期は失敗しました。実行時ログを確認し、保存を再試行してください。", + "blockedSyncFailedWithMessage": "ローカル構成は保存されましたが、Kun Runtime 同期は失敗しました。 {{message}}", + "blockedRuntimeUnavailable": "ローカル構成は保存されましたが、Kun Runtime は使用できないため、起動後に自動的に同期されます。", + "blockedRuntimeUnavailableWithMessage": "ローカル構成は保存されましたが、Kun Runtime は使用できないため、起動後に自動的に同期されます。 {{message}}", + "blockedWaitingForSync": "ローカル設定が保存され、Kun Runtime が同じルート プールとローカル API 状態を適用するのを待機しています。", + "blockedRuntimeNotReady": "Kun Runtime はまだこの完全なルートをテストする準備ができていません。", + "testStatus": { + "queued": "実行を待っています", + "running": "テスト中", + "succeeded": "ルートテストに成功しました", + "failed": "ルートテストに失敗しました" + }, + "attemptStatus": { + "running": "テスト", + "succeeded": "成功しました", + "failed": "失敗、ターゲット切り替え" + }, + "attemptedTargets": "{{attempted}} / {{total}} ターゲットを試行しました", + "testingTarget": "テスト: {{target}}", + "finalTargetValue": "最終ターゲット: {{target}}", + "modelResponse": "モデル応答: {{response}}", + "noTests": "ルートテストの記録がありません", + "currentTargetProgress": "現在の目標の進捗状況", + "order": "注文", + "target": "ターゲット", + "status": "状態", + "latencyError": "レイテンシ/エラー", + "recentTests": "最近の検査記録", + "time": "時間", + "result": "結果", + "attempts": "試み", + "finalTarget": "最終目標", + "recentEvents": "最近のルートイベント", + "latency": "レイテンシー", + "noEvents": "ルートイベントはありません", + "apiDialogTitle": "地元の API ガイド", + "apiDialogDesc": "ローカルプロセスのみの OpenAI 互換エンドポイント。 Authorization ヘッダーは必要ありません。", + "closeApiDocs": "API ガイドを閉じる", + "endpoints": "エンドポイント", + "modelList": "機種一覧", + "chatCompletions": "Chat Completions", + "responses": "応答", + "baseUrlLabel": "Base URL", + "prerequisites": "前提条件", + "prerequisitesDesc": "ローカル API と少なくとも 1 つのルーティング モデルを有効にします。リクエストのモデル フィールドでパブリック モデル ID を使用します。", + "openAiCompatible": "OpenAI対応", + "keyFields": "主要なフィールド", + "responsesAndLimits": "対応と制限", + "curlExample": "cURL の例", + "copyExample": "コピー例", + "apiSecurityWarning": "このリレーはパブリック API サービスではありません。これはローカル ループバック アドレスにのみバインドされ、現在認証はありません。ポート転送や保護されていないリバース プロキシを介して LAN やインターネットに公開しないでください。", + "guideModelsDesc": "ローカルリレー内の有効なルーテッドモデルをすべてリストします。返される各 data[].id は、生成リクエストで使用されるパブリック モデル ID です。", + "guideModelsNoBody": "リクエスト本文は必要ありません。", + "guideModelsEnabledOnly": "有効なルーティングされたモデルのみが返されます。無効なプールまたは無効なプールは省略されます。", + "guideModelsResponse": "成功時は OpenAI 形式の object: \"list\" と data 配列を返します。", + "guideModelsDisabled": "ローカルの API が無効な場合は、404 (gateway_disabled) を返します。", + "guideResponsesDesc": "input を単一の要求値として送信するクライアントに対して、OpenAI Responses 形式のコンテンツを生成します。", + "guideFieldModel": "model: 必須。{{modelId}} などのパブリックモデル ID を使用します。", + "guideResponsesInput": "input: 必須。文字列またはメッセージ配列を受け入れます。", + "guideResponsesStream": "stream: オプション。 true は Server-Sent Events を返します。", + "guideResponsesOptional": "max_output_tokens、tools、および reasoning_effort はオプションです。", + "guideResponsesNonStreaming": "非 streaming 応答は object: \"response\" で、テキストは output に含まれます。", + "guideResponsesStreaming": "streaming 応答は、response.created、response.output_text.delta、response.completed を順番に発行します。", + "guideChatDesc": "ほとんどの OpenAI 互換 SDK で使用される OpenAI Chat Completions 形式で会話応答を生成します。", + "guideChatMessages": "messages: 必須かつ空にできません。system、developer、user、assistant、tool メッセージをサポートします。", + "guideChatStream": "stream: オプション。 true は SSE を返します。 false は完全な JSON を返します。", + "guideChatTools": "tools: 任意。OpenAI の function tool 定義を使用します。画像は Base64 data URL のみを受け入れます。", + "guideChatNonStreaming": "stream 以外のテキストは choices[0].message.content にあります。", + "guideChatStreaming": "ストリーミング出力は data: [DONE] で終了します。", + "guideChatModelMissing": "不明なモデルは 404 (model_not_found) を返します。", + "exampleChatPrompt": "こんにちは、クン!", + "exampleResponsesPrompt": "Kun ローカル ゲートウェイを一文で紹介します。", + "statusRequestFailed": "Kun Runtime ステータス要求が失敗しました ({{status}})" + } +} diff --git a/src/renderer/src/locales/ko/settings.ts b/src/renderer/src/locales/ko/settings.ts index 6ce53f439..0d0ea11af 100644 --- a/src/renderer/src/locales/ko/settings.ts +++ b/src/renderer/src/locales/ko/settings.ts @@ -1,4 +1,5 @@ import navigationProviders from './settings/navigation-providers.json' +import modelRoutes from './settings/model-routes.json' import providerMediaMcp from './settings/provider-media-mcp.json' import mcpMigration from './settings/mcp-migration.json' import migrationSystem from './settings/migration-system.json' @@ -6,6 +7,7 @@ import codePersonas from './settings/code-personas.json' const settings = { ...navigationProviders, + ...modelRoutes, ...providerMediaMcp, ...mcpMigration, ...migrationSystem, diff --git a/src/renderer/src/locales/ko/settings/model-routes.json b/src/renderer/src/locales/ko/settings/model-routes.json new file mode 100644 index 000000000..8b26480d4 --- /dev/null +++ b/src/renderer/src/locales/ko/settings/model-routes.json @@ -0,0 +1,183 @@ +{ + "modelRoutes": { + "strategyPriority": "우선순위 장애 조치", + "strategyRoundRobin": "라운드 로빈", + "strategyWeightedRoundRobin": "가중 라운드 로빈", + "strategyLeastLatency": "최저 대기 시간", + "strategyAdaptive": "안정성 우선 적응형", + "emptyTitle": "첫 번째 라우팅 모델 추가", + "gatewayMultipleModelsDesc": "하나의 로컬 릴레이 공급자는 여러 공개 모델을 포함할 수 있습니다.", + "addModel": "모델 추가", + "defaultRouteName": "라우팅된 모델 {{index}}", + "testCreateFailed": "경로 테스트를 생성할 수 없습니다.", + "testButtonCreating": "테스트 만들기", + "testButtonInProgress": "테스트 진행 중", + "testButtonFixSave": "먼저 저장 실패를 수정하세요.", + "testButtonWaitSave": "로컬 저장을 기다리는 중", + "testButtonEnableFirst": "테스트 가능", + "testButtonFixInvalidTargets": "테스트할 잘못된 대상 수정", + "runtimeUnavailable": "Kun Runtime를 사용할 수 없습니다.", + "testButtonWaitSync": "구성 동기화를 기다리는 중", + "testButtonRun": "전체 경로 테스트", + "localSaveSaving": "로컬에 저장", + "localSaveFailed": "로컬 저장 실패", + "localSaveComplete": "로컬에 저장됨", + "runtimeSynced": "Kun Runtime 동기화됨", + "runtimeSyncFailed": "Kun Runtime 동기화 실패", + "runtimeNotRunning": "Kun Runtime가 실행되지 않음", + "runtimeNotConnected": "Kun Runtime가 연결되지 않음", + "runtimeSyncing": "Kun Runtime로 동기화하는 중", + "runtimeWaitingForSync": "Kun Runtime 동기화를 기다리는 중", + "tabsAria": "모델 루트 설정", + "tabGateway": "게이트웨이 및 API", + "tabModels": "모델 및 타겟", + "tabResilience": "회복력", + "tabMonitoring": "검증 및 모니터링", + "localRelayProvider": "지역 중계 제공업체", + "enabledModelCount": "{{enabled}} / {{total}} 모델 활성화됨", + "providerNameAria": "릴레이 공급자 이름", + "providerDesc": "하나의 공급자는 각각 독립적인 경로 대상과 로드 전략을 갖춘 여러 공개 모델을 제공합니다.", + "enableLocalApi": "로컬 API 활성화", + "localOnlyNoAuth": "로컬 액세스만 가능 · 인증 없음", + "retrySave": "저장 다시 시도", + "runtimeUnavailableHint": "로컬 설정은 영향을 받지 않으며 Kun이 시작될 때 자동으로 동기화됩니다.", + "localApi": "로컬 API", + "localApiEnabledLocalOnly": "활성화됨 · 로컬 액세스만 가능", + "disabled": "장애가 있는", + "apiCompatibilityDesc": "OpenAI Chat Completions 및 응답과 호환됩니다. 공개 모델 ID는 아래 라우팅 모델에서 나옵니다.", + "copyLocalApiAddress": "로컬 API 주소 복사", + "copied": "복사됨", + "copy": "복사", + "copyCurl": "복사 cURL", + "copyCurlUnavailable": "예제를 복사하기 전에 활성화된 대상이 있는 경로를 하나 이상 활성화하세요.", + "apiDocs": "API 가이드", + "routedModels": "라우팅된 모델", + "choosePool": "대상 풀을 구성할 모델을 선택하세요.", + "availableTargets": "{{available}}/{{total}} 사용 가능", + "invalidTargets": "{{count}} 수리 필요", + "noModels": "아직 라우팅된 모델이 없습니다.", + "routedModel": "라우팅된 모델", + "routeModelNameAria": "라우팅된 모델 이름", + "hotUpdateHint": "저장된 구성은 활성 Kun Runtime를 핫 업데이트합니다.", + "enable": "할 수 있게 하다", + "enablePoolAria": "경로 풀 활성화", + "publicModelId": "공개 모델 ID", + "publicModelIdRequired": "공개 모델 ID를 입력하세요.", + "publicModelIdDuplicate": "공개 모델 ID {{modelId}}는 이미 다른 경로에서 사용되었습니다.", + "loadStrategy": "로드 전략", + "routeTargets": "경로 대상", + "routeTargetsHint": "요청을 받을 수 있는 대상을 활성화합니다. 우선순위를 설정하려면 드래그하거나 화살표를 사용하세요.", + "addTarget": "대상 추가", + "addTargetUnavailable": "경로 대상을 추가하기 전에 하나 이상의 모델이 있는 공급자를 추가하세요.", + "reorderTarget": "드래그하여 타겟 재정렬", + "moveTargetUp": "대상을 위로 이동", + "moveTargetDown": "대상을 아래로 이동", + "targetEnabled": "활성화됨", + "targetProvider": "공급자", + "targetModel": "모델", + "targetWeight": "무게", + "targetHealth": "건강", + "weightInactive": "가중치는 가중치가 부여된 라운드 로빈에서만 사용됩니다.", + "deleteTarget": "대상 삭제", + "providerDeleted": "공급자가 삭제됨: {{providerId}}", + "originalModel": "원래 모델: {{modelId}}", + "modelDeleted": "모델 삭제됨: {{modelId}}", + "weight": "무게", + "notProbed": "프로브되지 않음", + "successCount": "{{successes}}/{{total}} 성공", + "providerMissingWarning": "공급자 {{providerId}}는 더 이상 존재하지 않습니다. 참조는 보존되었습니다. 대체 공급자를 선택하거나 이 대상을 삭제하세요.", + "modelMissingWarning": "모델 {{modelId}}는 더 이상 {{providerId}}에서 제공되지 않습니다. 참조는 보존되었습니다. 대체 모델을 선택하거나 이 대상을 삭제하세요.", + "deleteModel": "모델 삭제", + "confirmDeleteModel": "라우팅된 모델 {{modelId}} 및 해당 대상을 모두 삭제하시겠습니까?", + "failoverRules": "장애 조치 규칙", + "networkError": "네트워크 오류", + "requestTimeout": "요청 시간 초과", + "credentialError": "401 / 403 자격 증명 오류", + "failoverStatuses": "장애 조치 HTTP 상태 코드", + "failoverStatusesInvalid": "400부터 599까지 쉼표 또는 공백으로 구분된 HTTP 상태 코드를 사용하세요.", + "afterStreamNoRetry": "streaming 출력이 시작된 후 요청은 제자리에서 중지되고 tools를 다시 시도하거나 실행하지 않습니다.", + "healthCircuit": "건강 및 회로 차단", + "consecutiveFailures": "연속적인 실패", + "cooldownSeconds": "쿨다운 초", + "halfOpenProbes": "반 개방형 프로브", + "routeValidation": "경로 검증", + "routeValidationDesc": "Kun Runtime는 테스트를 비동기식으로 실행합니다. 귀하가 떠난 후에도 계속되고 귀하가 돌아올 때 진행 상황과 결과를 복원합니다.", + "blockedSaveFailed": "로컬 저장에 실패했습니다. 먼저 저장을 다시 시도하세요. 저장되지 않은 구성은 경로 테스트에 사용되지 않습니다.", + "blockedSaving": "구성이 로컬에 저장되고 있습니다. 저장 후 Kun Runtime 동기화가 완료되면 테스트가 가능합니다.", + "blockedInvalidTargets": "이 경로에는 {{count}} 잘못된 참조가 있고 실행 가능한 대상이 없습니다. 먼저 공급자나 모델을 교체하세요.", + "blockedNoTargets": "이 경로에는 활성화된 유효한 대상이 없습니다. 먼저 대상을 추가하거나 활성화하세요.", + "blockedSyncFailed": "로컬 구성이 저장되었지만 Kun Runtime 동기화에 실패했습니다. 런타임 로그를 확인하고 다시 저장해 보세요.", + "blockedSyncFailedWithMessage": "로컬 구성이 저장되었지만 Kun Runtime 동기화에 실패했습니다. {{message}}", + "blockedRuntimeUnavailable": "로컬 구성이 저장되었지만 Kun Runtime를 사용할 수 없으며 시작 후 자동으로 동기화됩니다.", + "blockedRuntimeUnavailableWithMessage": "로컬 구성이 저장되었지만 Kun Runtime를 사용할 수 없으며 시작 후 자동으로 동기화됩니다. {{message}}", + "blockedWaitingForSync": "로컬 구성이 저장되었으며 Kun Runtime가 동일한 경로 풀과 로컬 API 상태를 적용할 때까지 기다리고 있습니다.", + "blockedRuntimeNotReady": "Kun Runtime는 아직 이 전체 경로를 테스트할 준비가 되지 않았습니다.", + "testStatus": { + "queued": "실행 대기 중", + "running": "테스트 진행 중", + "succeeded": "경로 테스트 성공", + "failed": "경로 테스트 실패" + }, + "attemptStatus": { + "running": "테스트", + "succeeded": "성공함", + "failed": "실패, 대상 변경" + }, + "attemptedTargets": "{{attempted}} / {{total}} 대상을 시도했습니다.", + "testingTarget": "테스트: {{target}}", + "finalTargetValue": "최종 목표: {{target}}", + "modelResponse": "모델 응답: {{response}}", + "noTests": "경로 테스트 기록이 없습니다.", + "currentTargetProgress": "현재 목표 진행 상황", + "order": "주문하다", + "target": "목표", + "status": "상태", + "latencyError": "지연 시간/오류", + "recentTests": "최근 테스트 기록", + "time": "시간", + "result": "결과", + "attempts": "시도", + "finalTarget": "최종 목표", + "recentEvents": "최근 경로 이벤트", + "latency": "숨어 있음", + "noEvents": "경로 이벤트 없음", + "apiDialogTitle": "현지 API 가이드", + "apiDialogDesc": "로컬 프로세스 전용 OpenAI 호환 엔드포인트. Authorization 헤더가 필요하지 않습니다.", + "closeApiDocs": "API 가이드 닫기", + "endpoints": "엔드포인트", + "modelList": "모델 목록", + "chatCompletions": "Chat Completions", + "responses": "응답", + "baseUrlLabel": "Base URL", + "prerequisites": "전제 조건", + "prerequisitesDesc": "로컬 API 및 하나 이상의 라우팅된 모델을 활성화합니다. 요청의 모델 필드에 공개 모델 ID를 사용합니다.", + "openAiCompatible": "OpenAI 호환", + "keyFields": "주요 분야", + "responsesAndLimits": "대응 및 한계", + "curlExample": "cURL 예", + "copyExample": "복사 예", + "apiSecurityWarning": "이 릴레이는 공개 API 서비스가 아닙니다. 로컬 루프백 주소에만 바인딩되며 현재 인증이 없습니다. 포트 전달이나 보호되지 않는 역방향 프록시를 통해 LAN이나 인터넷에 노출하지 마십시오.", + "guideModelsDesc": "로컬 릴레이에서 활성화된 모든 라우팅 모델을 나열합니다. 반환된 각 data[].id는 생성 요청에 사용되는 공개 모델 ID입니다.", + "guideModelsNoBody": "요청 본문이 필요하지 않습니다.", + "guideModelsEnabledOnly": "활성화된 라우팅 모델만 반환됩니다. 비활성화되거나 유효하지 않은 풀은 생략됩니다.", + "guideModelsResponse": "성공 시 OpenAI 형식의 object: \"list\"와 data 배열을 반환합니다.", + "guideModelsDisabled": "로컬 API가 비활성화된 경우 404(gateway_disabled)를 반환합니다.", + "guideResponsesDesc": "input를 단일 요청 값으로 보내는 클라이언트를 위해 OpenAI Responses 형식으로 콘텐츠를 생성합니다.", + "guideFieldModel": "model: 필수입니다. {{modelId}}와 같은 공개 모델 ID를 사용하세요.", + "guideResponsesInput": "input: 필수입니다. 문자열 또는 메시지 배열을 허용합니다.", + "guideResponsesStream": "stream: 선택 사항입니다. true는 Server-Sent Events를 반환합니다.", + "guideResponsesOptional": "max_output_tokens, tools 및 reasoning_effort는 선택 사항입니다.", + "guideResponsesNonStreaming": "비 streaming 응답은 object: \"response\"를 사용하며 텍스트는 output에 있습니다.", + "guideResponsesStreaming": "streaming 응답은 response.created, response.output_text.delta 및 response.completed를 순서대로 내보냅니다.", + "guideChatDesc": "대부분의 OpenAI 호환 SDK에서 사용되는 OpenAI Chat Completions 형식으로 대화 응답을 생성합니다.", + "guideChatMessages": "messages: 필수이며 비워 둘 수 없습니다. system, developer, user, assistant 및 tool 메시지를 지원합니다.", + "guideChatStream": "stream: 선택 사항입니다. true는 SSE를 반환합니다. false는 완전한 JSON을 반환합니다.", + "guideChatTools": "tools: 선택 사항입니다. OpenAI function tool 정의를 사용하며 이미지는 Base64 data URL만 허용합니다.", + "guideChatNonStreaming": "streaming이 아닌 텍스트는 choices[0].message.content에 있습니다.", + "guideChatStreaming": "스트리밍 출력은 data: [DONE]로 끝납니다.", + "guideChatModelMissing": "알 수 없는 모델이 404(model_not_found)를 반환합니다.", + "exampleChatPrompt": "안녕, 쿤!", + "exampleResponsesPrompt": "Kun 로컬 게이트웨이를 한 문장으로 소개해보세요.", + "statusRequestFailed": "Kun Runtime 상태 요청 실패({{status}})" + } +} diff --git a/src/renderer/src/locales/locale-resources.test.ts b/src/renderer/src/locales/locale-resources.test.ts index 8a0a9b616..ebf1b47fd 100644 --- a/src/renderer/src/locales/locale-resources.test.ts +++ b/src/renderer/src/locales/locale-resources.test.ts @@ -22,6 +22,16 @@ import zhSettings from './zh/settings' type LocaleTree = Record +const authoredSettings: Record = { + en: enSettings, + zh: zhSettings, + ru: ruSettings, + hi: hiSettings, + th: thSettings, + ja: jaSettings, + ko: koSettings +} + const resources: Record = { en: { common: enCommon, settings: enSettings }, zh: { common: zhCommon, settings: zhSettings }, @@ -99,6 +109,45 @@ describe('active locale resources', () => { } ) + it.each(APP_LOCALES)( + 'authors a complete model-routes resource for %s without fallback copy', + (locale) => { + const source = flattenStrings(enSettings.modelRoutes) + const translated = flattenStrings(authoredSettings[locale].modelRoutes as LocaleTree) + + expect([...translated.keys()]).toEqual([...source.keys()]) + for (const [key, sourceValue] of source) { + const translatedValue = translated.get(key) + expect(translatedValue, `settings:modelRoutes.${key}`).toBeTruthy() + expect(interpolationTokens(translatedValue ?? ''), `settings:modelRoutes.${key}`) + .toEqual(interpolationTokens(sourceValue)) + expect(translatedValue).not.toContain('ZZSAFE') + } + if (locale !== 'en') { + expect(translated.get('localRelayProvider')).not.toBe(source.get('localRelayProvider')) + } + } + ) + + it.each(APP_LOCALES)('preserves model-route protocol literals in %s guidance', (locale) => { + const modelRoutes = authoredSettings[locale].modelRoutes as Record + const expectedLiterals: Record = { + guideModelsResponse: ['object: "list"', 'data'], + guideFieldModel: ['model', '{{modelId}}'], + guideResponsesNonStreaming: ['object: "response"', 'output'], + guideChatMessages: ['messages', 'system', 'developer', 'user', 'assistant', 'tool'], + guideChatTools: ['tools', 'function', 'Base64', 'data URL'], + guideChatNonStreaming: ['choices[0].message.content'], + guideChatStreaming: ['data: [DONE]'] + } + + for (const [key, literals] of Object.entries(expectedLiterals)) { + for (const literal of literals) { + expect(modelRoutes[key], `settings:modelRoutes.${key}`).toContain(literal) + } + } + }) + it.each(APP_LOCALES)('can switch i18next to %s without falling back to another locale', async (locale) => { await i18n.changeLanguage(locale) expect(i18n.resolvedLanguage).toBe(locale) diff --git a/src/renderer/src/locales/ru/settings.ts b/src/renderer/src/locales/ru/settings.ts index 6ce53f439..0d0ea11af 100644 --- a/src/renderer/src/locales/ru/settings.ts +++ b/src/renderer/src/locales/ru/settings.ts @@ -1,4 +1,5 @@ import navigationProviders from './settings/navigation-providers.json' +import modelRoutes from './settings/model-routes.json' import providerMediaMcp from './settings/provider-media-mcp.json' import mcpMigration from './settings/mcp-migration.json' import migrationSystem from './settings/migration-system.json' @@ -6,6 +7,7 @@ import codePersonas from './settings/code-personas.json' const settings = { ...navigationProviders, + ...modelRoutes, ...providerMediaMcp, ...mcpMigration, ...migrationSystem, diff --git a/src/renderer/src/locales/ru/settings/model-routes.json b/src/renderer/src/locales/ru/settings/model-routes.json new file mode 100644 index 000000000..ae0173d87 --- /dev/null +++ b/src/renderer/src/locales/ru/settings/model-routes.json @@ -0,0 +1,183 @@ +{ + "modelRoutes": { + "strategyPriority": "Приоритетное аварийное переключение", + "strategyRoundRobin": "Круговая система", + "strategyWeightedRoundRobin": "Взвешенный круговой турнир", + "strategyLeastLatency": "Самая низкая задержка", + "strategyAdaptive": "Адаптивная система, ориентированная на стабильность", + "emptyTitle": "Добавьте свою первую маршрутизированную модель", + "gatewayMultipleModelsDesc": "Один локальный поставщик ретрансляции может содержать несколько общедоступных моделей.", + "addModel": "Добавить модель", + "defaultRouteName": "Маршрутизированная модель {{index}}", + "testCreateFailed": "Не удалось создать тест маршрута.", + "testButtonCreating": "Создание теста", + "testButtonInProgress": "Тест продолжается", + "testButtonFixSave": "Сначала исправьте ошибку сохранения", + "testButtonWaitSave": "Ждем локального сохранения", + "testButtonEnableFirst": "Включить для тестирования", + "testButtonFixInvalidTargets": "Исправьте недопустимые цели для тестирования", + "runtimeUnavailable": "Kun Runtime недоступен", + "testButtonWaitSync": "Ожидание синхронизации конфигурации", + "testButtonRun": "Тестовый полный маршрут", + "localSaveSaving": "Сохранение локально", + "localSaveFailed": "Локальное сохранение не удалось", + "localSaveComplete": "Сохранено локально", + "runtimeSynced": "Kun Runtime синхронизировано", + "runtimeSyncFailed": "Kun Runtime не удалось синхронизировать", + "runtimeNotRunning": "Kun Runtime не работает", + "runtimeNotConnected": "Kun Runtime не подключено", + "runtimeSyncing": "Синхронизация с Kun Runtime", + "runtimeWaitingForSync": "Ожидание синхронизации Kun Runtime", + "tabsAria": "Настройки маршрута модели", + "tabGateway": "Шлюз и API", + "tabModels": "Модели и цели", + "tabResilience": "Устойчивость", + "tabMonitoring": "Валидация и мониторинг", + "localRelayProvider": "Местный поставщик ретрансляции", + "enabledModelCount": "Модели {{enabled}} / {{total}} включены", + "providerNameAria": "Имя поставщика реле", + "providerDesc": "Один провайдер обслуживает несколько общедоступных моделей, каждая из которых имеет независимые цели маршрутизации и стратегию загрузки.", + "enableLocalApi": "Включить локальный API", + "localOnlyNoAuth": "Только локальный доступ · Без аутентификации", + "retrySave": "Повторить попытку сохранения", + "runtimeUnavailableHint": "Локальные настройки не будут затронуты и будут синхронизироваться автоматически при запуске Kun.", + "localApi": "Местный API", + "localApiEnabledLocalOnly": "Включено · Только локальный доступ", + "disabled": "Неполноценный", + "apiCompatibilityDesc": "Совместим с OpenAI Chat Completions и ответами. Публичная модель ID взята из маршрутизируемых ниже моделей.", + "copyLocalApiAddress": "Скопируйте локальный адрес API", + "copied": "Скопировано", + "copy": "Копировать", + "copyCurl": "Копировать cURL", + "copyCurlUnavailable": "Прежде чем копировать пример, включите хотя бы один маршрут с включенной целью.", + "apiDocs": "API руководство", + "routedModels": "Маршрутизированные модели", + "choosePool": "Выберите модель для настройки целевого пула.", + "availableTargets": "{{available}}/{{total}} доступны", + "invalidTargets": "{{count}} требуется ремонт", + "noModels": "Маршрутизируемых моделей пока нет", + "routedModel": "Маршрутизированная модель", + "routeModelNameAria": "Название маршрутизированной модели", + "hotUpdateHint": "Сохраненная конфигурация выполняет горячее обновление активного Kun Runtime.", + "enable": "Давать возможность", + "enablePoolAria": "Включить пул маршрутов", + "publicModelId": "Публичная модель ID", + "publicModelIdRequired": "Введите общедоступную модель ID.", + "publicModelIdDuplicate": "Публичная модель ID {{modelId}} уже используется другим маршрутом.", + "loadStrategy": "Стратегия загрузки", + "routeTargets": "Цели маршрута", + "routeTargetsHint": "Включите цели, которые могут получать запросы. Перетащите или используйте стрелки, чтобы установить приоритет.", + "addTarget": "Добавить цель", + "addTargetUnavailable": "Прежде чем добавлять цель маршрута, добавьте поставщика хотя бы с одной моделью.", + "reorderTarget": "Перетащите, чтобы изменить порядок цели", + "moveTargetUp": "Переместить цель вверх", + "moveTargetDown": "Переместить цель вниз", + "targetEnabled": "Включено", + "targetProvider": "Поставщик", + "targetModel": "Модель", + "targetWeight": "Масса", + "targetHealth": "Здоровье", + "weightInactive": "Вес используется только при взвешенном круговом турнире.", + "deleteTarget": "Удалить цель", + "providerDeleted": "Поставщик удален: {{providerId}}.", + "originalModel": "Оригинальная модель: {{modelId}}", + "modelDeleted": "Модель удалена: {{modelId}}.", + "weight": "Масса", + "notProbed": "Не исследовано", + "successCount": "{{successes}}/{{total}} успешно выполнено", + "providerMissingWarning": "Провайдер {{providerId}} больше не существует. Ссылка сохранилась; выберите поставщика на замену или удалите эту цель.", + "modelMissingWarning": "Модель {{modelId}} больше не доступна на сайте {{providerId}}. Ссылка сохранилась; выберите модель на замену или удалите эту цель.", + "deleteModel": "Удалить модель", + "confirmDeleteModel": "Удалить маршрутизируемую модель {{modelId}} и все ее цели?", + "failoverRules": "Правила аварийного переключения", + "networkError": "Ошибка сети", + "requestTimeout": "Запросить тайм-аут", + "credentialError": "Ошибка учетных данных 401/403", + "failoverStatuses": "Коды состояния аварийного переключения HTTP", + "failoverStatusesInvalid": "Используйте коды состояния HTTP, разделенные запятыми или пробелами, от 400 до 599.", + "afterStreamNoRetry": "После начала вывода streaming запрос останавливается на месте и не повторяется и не выполняет tools снова.", + "healthCircuit": "Здоровье и разрыв цепи", + "consecutiveFailures": "Последовательные неудачи", + "cooldownSeconds": "Секунды восстановления", + "halfOpenProbes": "Полуоткрытые зонды", + "routeValidation": "Проверка маршрута", + "routeValidationDesc": "Kun Runtime запускает тесты асинхронно. Они продолжаются после вашего ухода и восстанавливают прогресс и результаты по вашему возвращению.", + "blockedSaveFailed": "Локальное сохранение не удалось. Сначала повторите сохранение; несохраненная конфигурация не используется для тестов маршрута.", + "blockedSaving": "Конфигурация сохраняется локально. Тестирование доступно после сохранения и завершения синхронизации Kun Runtime.", + "blockedInvalidTargets": "Этот маршрут имеет недопустимые ссылки {{count}} и не имеет исполняемой цели. Сначала замените поставщика или модель.", + "blockedNoTargets": "У этого маршрута нет включенных допустимых целей. Сначала добавьте или включите цель.", + "blockedSyncFailed": "Локальная конфигурация была сохранена, но синхронизация Kun Runtime не удалась. Проверьте журналы среды выполнения и повторите попытку сохранения.", + "blockedSyncFailedWithMessage": "Локальная конфигурация была сохранена, но синхронизация Kun Runtime не удалась. {{message}}", + "blockedRuntimeUnavailable": "Локальная конфигурация сохранена, но Kun Runtime недоступен и автоматически синхронизируется после запуска.", + "blockedRuntimeUnavailableWithMessage": "Локальная конфигурация сохранена, но Kun Runtime недоступен и автоматически синхронизируется после запуска. {{message}}", + "blockedWaitingForSync": "Локальная конфигурация сохранена и ожидает, пока Kun Runtime применит те же пулы маршрутов и локальное состояние API.", + "blockedRuntimeNotReady": "Kun Runtime пока не готов протестировать этот полный маршрут.", + "testStatus": { + "queued": "Ожидание запуска", + "running": "Тест продолжается", + "succeeded": "Проверка маршрута прошла успешно", + "failed": "Проверка маршрута не удалась" + }, + "attemptStatus": { + "running": "Тестирование", + "succeeded": "Удалось", + "failed": "Не удалось, смена цели" + }, + "attemptedTargets": "Попытка достижения целей {{attempted}}/{{total}}", + "testingTarget": "Тестирование: {{target}}", + "finalTargetValue": "Конечная цель: {{target}}", + "modelResponse": "Ответ модели: {{response}}", + "noTests": "Нет записей о маршрутных испытаниях", + "currentTargetProgress": "Текущий целевой прогресс", + "order": "Заказ", + "target": "Цель", + "status": "Статус", + "latencyError": "Задержка/ошибка", + "recentTests": "Записи последних испытаний", + "time": "Время", + "result": "Результат", + "attempts": "Попытки", + "finalTarget": "Конечная цель", + "recentEvents": "Последние события маршрута", + "latency": "Задержка", + "noEvents": "Нет событий на маршруте", + "apiDialogTitle": "Местный гид по API", + "apiDialogDesc": "OpenAI-совместимые конечные точки только для локальных процессов; заголовок авторизации не требуется.", + "closeApiDocs": "Закрыть руководство по API", + "endpoints": "Конечные точки", + "modelList": "Список моделей", + "chatCompletions": "Chat Completions", + "responses": "Ответы", + "baseUrlLabel": "Base URL", + "prerequisites": "Предварительные условия", + "prerequisitesDesc": "Включите локальную API и хотя бы одну маршрутизируемую модель. Используйте его общедоступную модель ID в поле модели запроса.", + "openAiCompatible": "Совместимость с OpenAI", + "keyFields": "Ключевые поля", + "responsesAndLimits": "Ответы и ограничения", + "curlExample": "Пример cURL", + "copyExample": "Скопировать пример", + "apiSecurityWarning": "Это реле не является общедоступной службой API. Он привязывается только к локальному адресу обратной связи и в настоящее время не имеет аутентификации. Не подключайте его к локальной сети или Интернету через переадресацию портов или незащищенный обратный прокси-сервер.", + "guideModelsDesc": "Перечисляет все включенные маршрутизируемые модели в локальном реле. Каждый возвращенный data[].id — это общедоступная модель ID, используемая запросами генерации.", + "guideModelsNoBody": "Тело запроса не требуется.", + "guideModelsEnabledOnly": "Возвращаются только включенные маршрутизируемые модели; отключенные или недействительные пулы опускаются.", + "guideModelsResponse": "Успешный ответ содержит object: \"list\" в формате OpenAI и массив data.", + "guideModelsDisabled": "Возвращает 404 (gateway_disabled), если локальный API отключен.", + "guideResponsesDesc": "Создает содержимое в формате OpenAI Responses для клиентов, которые отправляют input как одно значение запроса.", + "guideFieldModel": "model: обязательное поле. Используйте публичный ID модели, например {{modelId}}.", + "guideResponsesInput": "input: Обязательно. Принимает строку или массив сообщений.", + "guideResponsesStream": "stream: Необязательно. true возвращает Server-Sent Events.", + "guideResponsesOptional": "max_output_tokens, tools и reasoning_effort являются необязательными.", + "guideResponsesNonStreaming": "Непотоковый ответ содержит object: \"response\", а текст находится в output.", + "guideResponsesStreaming": "Ответ streaming выдает response.created, response.output_text.delta и response.completed по порядку.", + "guideChatDesc": "Генерирует ответ на диалог в формате OpenAI Chat Completions, используемом большинством OpenAI-совместимых SDK.", + "guideChatMessages": "messages: обязательное непустое поле. Поддерживаются сообщения system, developer, user, assistant и tool.", + "guideChatStream": "stream: Необязательно. true возвращает SSE; false возвращает полный JSON.", + "guideChatTools": "tools: необязательное поле. Используются определения OpenAI function tool; изображения принимаются только как Base64 data URL.", + "guideChatNonStreaming": "Текст, не относящийся к streaming, находится в формате choices[0].message.content.", + "guideChatStreaming": "Потоковый вывод заканчивается на data: [DONE].", + "guideChatModelMissing": "Неизвестная модель возвращает 404 (model_not_found).", + "exampleChatPrompt": "Привет, Кун!", + "exampleResponsesPrompt": "Представьте локальный шлюз Kun в одном предложении.", + "statusRequestFailed": "Запрос статуса Kun Runtime не выполнен ({{status}})" + } +} diff --git a/src/renderer/src/locales/th/settings.ts b/src/renderer/src/locales/th/settings.ts index 6ce53f439..0d0ea11af 100644 --- a/src/renderer/src/locales/th/settings.ts +++ b/src/renderer/src/locales/th/settings.ts @@ -1,4 +1,5 @@ import navigationProviders from './settings/navigation-providers.json' +import modelRoutes from './settings/model-routes.json' import providerMediaMcp from './settings/provider-media-mcp.json' import mcpMigration from './settings/mcp-migration.json' import migrationSystem from './settings/migration-system.json' @@ -6,6 +7,7 @@ import codePersonas from './settings/code-personas.json' const settings = { ...navigationProviders, + ...modelRoutes, ...providerMediaMcp, ...mcpMigration, ...migrationSystem, diff --git a/src/renderer/src/locales/th/settings/model-routes.json b/src/renderer/src/locales/th/settings/model-routes.json new file mode 100644 index 000000000..4d6738fb8 --- /dev/null +++ b/src/renderer/src/locales/th/settings/model-routes.json @@ -0,0 +1,183 @@ +{ + "modelRoutes": { + "strategyPriority": "ลำดับความสำคัญล้มเหลว", + "strategyRoundRobin": "โรบินตัวกลม", + "strategyWeightedRoundRobin": "โรบินกลมถ่วงน้ำหนัก", + "strategyLeastLatency": "เวลาแฝงต่ำสุด", + "strategyAdaptive": "การปรับตัวที่เน้นความเสถียรเป็นอันดับแรก", + "emptyTitle": "เพิ่มโมเดลที่กำหนดเส้นทางแรกของคุณ", + "gatewayMultipleModelsDesc": "ผู้ให้บริการรีเลย์ในพื้นที่รายหนึ่งสามารถมีโมเดลสาธารณะได้หลายโมเดล", + "addModel": "เพิ่มรุ่น", + "defaultRouteName": "รุ่นกำหนดเส้นทาง {{index}}", + "testCreateFailed": "ไม่สามารถสร้างการทดสอบเส้นทางได้", + "testButtonCreating": "กำลังสร้างแบบทดสอบ", + "testButtonInProgress": "อยู่ระหว่างการทดสอบ", + "testButtonFixSave": "แก้ไขความล้มเหลวในการบันทึกก่อน", + "testButtonWaitSave": "กำลังรอการบันทึกในเครื่อง", + "testButtonEnableFirst": "เปิดใช้งานเพื่อทดสอบ", + "testButtonFixInvalidTargets": "แก้ไขเป้าหมายที่ไม่ถูกต้องเพื่อทดสอบ", + "runtimeUnavailable": "Kun Runtime ไม่พร้อมใช้งาน", + "testButtonWaitSync": "กำลังรอการซิงค์การกำหนดค่า", + "testButtonRun": "ทดสอบเส้นทางที่สมบูรณ์", + "localSaveSaving": "ประหยัดในเครื่อง", + "localSaveFailed": "บันทึกในเครื่องล้มเหลว", + "localSaveComplete": "บันทึกไว้ในเครื่อง", + "runtimeSynced": "Kun Runtime ซิงค์แล้ว", + "runtimeSyncFailed": "การซิงค์ Kun Runtime ล้มเหลว", + "runtimeNotRunning": "Kun Runtime ไม่ทำงาน", + "runtimeNotConnected": "Kun Runtime ไม่ได้เชื่อมต่อ", + "runtimeSyncing": "กำลังซิงค์กับ Kun Runtime", + "runtimeWaitingForSync": "กำลังรอการซิงค์ Kun Runtime", + "tabsAria": "การตั้งค่าเส้นทางจำลอง", + "tabGateway": "เกตเวย์ & API", + "tabModels": "โมเดลและเป้าหมาย", + "tabResilience": "ความยืดหยุ่น", + "tabMonitoring": "การตรวจสอบความถูกต้องและการเฝ้าติดตาม", + "localRelayProvider": "ผู้ให้บริการรีเลย์ท้องถิ่น", + "enabledModelCount": "เปิดใช้งานรุ่น {{enabled}} / {{total}} แล้ว", + "providerNameAria": "ชื่อผู้ให้บริการรีเลย์", + "providerDesc": "ผู้ให้บริการรายหนึ่งให้บริการโมเดลสาธารณะหลายโมเดล โดยแต่ละโมเดลมีเป้าหมายเส้นทางและกลยุทธ์ในการโหลดที่เป็นอิสระ", + "enableLocalApi": "เปิดใช้งาน API ในเครื่อง", + "localOnlyNoAuth": "เข้าถึงได้ในพื้นที่เท่านั้น · ไม่มีการตรวจสอบสิทธิ์", + "retrySave": "ลองบันทึกอีกครั้ง", + "runtimeUnavailableHint": "การตั้งค่าในเครื่องจะไม่ได้รับผลกระทบ และจะซิงค์โดยอัตโนมัติเมื่อ Kun เริ่มทำงาน", + "localApi": "API ท้องถิ่น", + "localApiEnabledLocalOnly": "เปิดใช้งาน · การเข้าถึงเฉพาะที่เท่านั้น", + "disabled": "พิการ", + "apiCompatibilityDesc": "เข้ากันได้กับ OpenAI Chat Completions และการตอบกลับ โมเดลสาธารณะ ID มาจากโมเดลที่กำหนดเส้นทางด้านล่าง", + "copyLocalApiAddress": "คัดลอกที่อยู่ API ในเครื่อง", + "copied": "คัดลอกแล้ว", + "copy": "สำเนา", + "copyCurl": "คัดลอก cURL", + "copyCurlUnavailable": "เปิดใช้งานอย่างน้อยหนึ่งเส้นทางโดยมีเป้าหมายที่เปิดใช้งานก่อนที่จะคัดลอกตัวอย่าง", + "apiDocs": "คู่มือ API", + "routedModels": "โมเดลที่กำหนดเส้นทาง", + "choosePool": "เลือกโมเดลเพื่อกำหนดค่าพูลเป้าหมาย", + "availableTargets": "{{available}}/{{total}} พร้อมใช้งาน", + "invalidTargets": "{{count}} จำเป็นต้องซ่อมแซม", + "noModels": "ยังไม่มีโมเดลที่กำหนดเส้นทาง", + "routedModel": "โมเดลที่กำหนดเส้นทาง", + "routeModelNameAria": "ชื่อรุ่นที่กำหนดเส้นทาง", + "hotUpdateHint": "การกำหนดค่าที่บันทึกไว้จะอัพเดต Kun Runtime ที่ใช้งานอยู่", + "enable": "เปิดใช้งาน", + "enablePoolAria": "เปิดใช้งานกลุ่มเส้นทาง", + "publicModelId": "รุ่นสาธารณะ ID", + "publicModelIdRequired": "กรอกโมเดลสาธารณะ ID", + "publicModelIdDuplicate": "โมเดลสาธารณะ ID {{modelId}} ถูกใช้แล้วในเส้นทางอื่น", + "loadStrategy": "กลยุทธ์การโหลด", + "routeTargets": "เป้าหมายเส้นทาง", + "routeTargetsHint": "เปิดใช้งานเป้าหมายที่อาจได้รับคำขอ ลากหรือใช้ลูกศรเพื่อกำหนดลำดับความสำคัญ", + "addTarget": "เพิ่มเป้าหมาย", + "addTargetUnavailable": "เพิ่มผู้ให้บริการที่มีอย่างน้อยหนึ่งรุ่นก่อนที่จะเพิ่มเป้าหมายเส้นทาง", + "reorderTarget": "ลากเพื่อเรียงลำดับเป้าหมายใหม่", + "moveTargetUp": "ย้ายเป้าหมายขึ้น", + "moveTargetDown": "ย้ายเป้าหมายลง", + "targetEnabled": "เปิดใช้งานแล้ว", + "targetProvider": "ผู้ให้บริการ", + "targetModel": "แบบอย่าง", + "targetWeight": "น้ำหนัก", + "targetHealth": "สุขภาพ", + "weightInactive": "ตุ้มน้ำหนักจะใช้โดยโรบินกลมถ่วงน้ำหนักเท่านั้น", + "deleteTarget": "ลบเป้าหมาย", + "providerDeleted": "ผู้ให้บริการถูกลบ: {{providerId}}", + "originalModel": "รุ่นเดิม: {{modelId}}", + "modelDeleted": "โมเดลที่ถูกลบ: {{modelId}}", + "weight": "น้ำหนัก", + "notProbed": "ไม่ถูกสอบสวน", + "successCount": "{{successes}}/{{total}} สำเร็จ", + "providerMissingWarning": "ผู้ให้บริการ {{providerId}} ไม่มีอยู่แล้ว ข้อมูลอ้างอิงถูกเก็บรักษาไว้ เลือกผู้ให้บริการทดแทนหรือลบเป้าหมายนี้", + "modelMissingWarning": "รุ่น {{modelId}} ไม่มีจำหน่ายจาก {{providerId}} อีกต่อไป ข้อมูลอ้างอิงถูกเก็บรักษาไว้ เลือกรุ่นทดแทนหรือลบเป้าหมายนี้", + "deleteModel": "ลบโมเดล", + "confirmDeleteModel": "ลบโมเดลที่กำหนดเส้นทาง {{modelId}} และเป้าหมายทั้งหมดหรือไม่", + "failoverRules": "กฎการเฟลโอเวอร์", + "networkError": "ข้อผิดพลาดของเครือข่าย", + "requestTimeout": "ขอหมดเวลา", + "credentialError": "ข้อผิดพลาดข้อมูลประจำตัว 401 / 403", + "failoverStatuses": "รหัสสถานะ HTTP เมื่อเกิดข้อผิดพลาด", + "failoverStatusesInvalid": "ใช้รหัสสถานะ HTTP โดยคั่นด้วยเครื่องหมายจุลภาคหรือเว้นวรรคจาก 400 ถึง 599", + "afterStreamNoRetry": "หลังจากที่เอาต์พุต streaming เริ่มต้นขึ้น คำขอจะหยุดลงและจะไม่ลองอีกครั้งหรือดำเนินการ tools อีกครั้ง", + "healthCircuit": "สุขภาพและการทำลายวงจร", + "consecutiveFailures": "ความล้มเหลวติดต่อกัน", + "cooldownSeconds": "คูลดาวน์วินาที", + "halfOpenProbes": "โพรบแบบเปิดครึ่ง", + "routeValidation": "การตรวจสอบเส้นทาง", + "routeValidationDesc": "Kun Runtime รันการทดสอบแบบอะซิงโครนัส สิ่งเหล่านี้จะดำเนินต่อไปหลังจากที่คุณจากไป และฟื้นฟูความคืบหน้าและผลลัพธ์เมื่อคุณกลับมา", + "blockedSaveFailed": "บันทึกในเครื่องล้มเหลว ลองบันทึกอีกครั้งก่อน การกำหนดค่าที่ไม่ได้บันทึกไม่ได้ใช้สำหรับการทดสอบเส้นทาง", + "blockedSaving": "กำลังบันทึกการกำหนดค่าไว้ในเครื่อง การทดสอบจะพร้อมใช้งานหลังจากบันทึกและการซิงค์ Kun Runtime เสร็จสมบูรณ์", + "blockedInvalidTargets": "เส้นทางนี้มีการอ้างอิง {{count}} ที่ไม่ถูกต้อง และไม่มีเป้าหมายที่ปฏิบัติการได้ เปลี่ยนผู้ให้บริการหรือรุ่นก่อน", + "blockedNoTargets": "เส้นทางนี้ไม่เปิดใช้งานเป้าหมายที่ถูกต้อง เพิ่มหรือเปิดใช้งานเป้าหมายก่อน", + "blockedSyncFailed": "บันทึกการกำหนดค่าในเครื่องแล้ว แต่การซิงค์ Kun Runtime ล้มเหลว ตรวจสอบบันทึกรันไทม์แล้วลองบันทึกอีกครั้ง", + "blockedSyncFailedWithMessage": "บันทึกการกำหนดค่าในเครื่องแล้ว แต่การซิงค์ Kun Runtime ล้มเหลว {{message}}", + "blockedRuntimeUnavailable": "บันทึกการกำหนดค่าในเครื่องแล้ว แต่ Kun Runtime ไม่พร้อมใช้งานและจะซิงค์โดยอัตโนมัติหลังจากเริ่มต้นระบบ", + "blockedRuntimeUnavailableWithMessage": "บันทึกการกำหนดค่าในเครื่องแล้ว แต่ Kun Runtime ไม่พร้อมใช้งานและจะซิงค์โดยอัตโนมัติหลังจากเริ่มต้นระบบ {{message}}", + "blockedWaitingForSync": "บันทึกการกำหนดค่าในเครื่องแล้ว และกำลังรอให้ Kun Runtime ใช้พูลเส้นทางเดียวกันและสถานะ API ในเครื่อง", + "blockedRuntimeNotReady": "Kun Runtime ยังไม่พร้อมที่จะทดสอบเส้นทางที่สมบูรณ์นี้", + "testStatus": { + "queued": "รอวิ่งครับ", + "running": "อยู่ระหว่างการทดสอบ", + "succeeded": "การทดสอบเส้นทางสำเร็จแล้ว", + "failed": "การทดสอบเส้นทางล้มเหลว" + }, + "attemptStatus": { + "running": "การทดสอบ", + "succeeded": "ประสบความสำเร็จ", + "failed": "ล้มเหลว เปลี่ยนเป้าหมาย" + }, + "attemptedTargets": "พยายามกำหนดเป้าหมาย {{attempted}} / {{total}}", + "testingTarget": "การทดสอบ: {{target}}", + "finalTargetValue": "เป้าหมายสุดท้าย: {{target}}", + "modelResponse": "การตอบสนองของโมเดล: {{response}}", + "noTests": "ไม่มีบันทึกการทดสอบเส้นทาง", + "currentTargetProgress": "ความคืบหน้าเป้าหมายปัจจุบัน", + "order": "คำสั่ง", + "target": "เป้า", + "status": "สถานะ", + "latencyError": "เวลาแฝง / ข้อผิดพลาด", + "recentTests": "บันทึกการทดสอบล่าสุด", + "time": "เวลา", + "result": "ผลลัพธ์", + "attempts": "ความพยายาม", + "finalTarget": "เป้าหมายสุดท้าย", + "recentEvents": "เหตุการณ์เส้นทางล่าสุด", + "latency": "เวลาแฝง", + "noEvents": "ไม่มีเหตุการณ์เส้นทาง", + "apiDialogTitle": "คู่มือ API ท้องถิ่น", + "apiDialogDesc": "จุดสิ้นสุดที่เข้ากันได้กับ OpenAI สำหรับกระบวนการในเครื่องเท่านั้น ไม่จำเป็นต้องมีส่วนหัวการอนุญาต", + "closeApiDocs": "ปิดคำแนะนำ API", + "endpoints": "จุดสิ้นสุด", + "modelList": "รายการรุ่น", + "chatCompletions": "Chat Completions", + "responses": "คำตอบ", + "baseUrlLabel": "Base URL", + "prerequisites": "ข้อกำหนดเบื้องต้น", + "prerequisitesDesc": "เปิดใช้งาน API ในเครื่องและโมเดลที่กำหนดเส้นทางอย่างน้อยหนึ่งโมเดล ใช้โมเดลสาธารณะ ID ในฟิลด์โมเดลของคำขอ", + "openAiCompatible": "รองรับ OpenAI", + "keyFields": "ฟิลด์สำคัญ", + "responsesAndLimits": "การตอบสนองและขีดจำกัด", + "curlExample": "ตัวอย่าง cURL", + "copyExample": "คัดลอกตัวอย่าง", + "apiSecurityWarning": "รีเลย์นี้ไม่ใช่บริการ API สาธารณะ โดยจะผูกกับที่อยู่ลูปแบ็คในเครื่องเท่านั้น และขณะนี้ไม่มีการตรวจสอบสิทธิ์ อย่าเปิดเผยข้อมูลดังกล่าวกับ LAN หรืออินเทอร์เน็ตผ่านการส่งต่อพอร์ตหรือพร็อกซีย้อนกลับที่ไม่มีการป้องกัน", + "guideModelsDesc": "แสดงรายการโมเดลที่กำหนดเส้นทางที่เปิดใช้งานทั้งหมดในรีเลย์ภายในเครื่อง data[].id ที่ส่งคืนแต่ละรายการคือโมเดลสาธารณะ ID ที่ใช้โดยคำขอสร้าง", + "guideModelsNoBody": "ไม่จำเป็นต้องมีเนื้อหาคำขอ", + "guideModelsEnabledOnly": "ส่งคืนเฉพาะโมเดลกำหนดเส้นทางที่เปิดใช้งานเท่านั้น พูลที่ปิดใช้งานหรือไม่ถูกต้องจะถูกละเว้น", + "guideModelsResponse": "การตอบกลับสำเร็จใช้ object: \"list\" ตามรูปแบบ OpenAI และอาร์เรย์ data", + "guideModelsDisabled": "ส่งกลับ 404 (gateway_disabled) เมื่อ API ในเครื่องถูกปิดใช้งาน", + "guideResponsesDesc": "สร้างเนื้อหาในรูปแบบ OpenAI Responses สำหรับไคลเอ็นต์ที่ส่ง input เป็นค่าคำขอเดียว", + "guideFieldModel": "model: จำเป็น ใช้ ID โมเดลสาธารณะ เช่น {{modelId}}", + "guideResponsesInput": "input: จำเป็น ยอมรับสตริงหรืออาร์เรย์ข้อความ", + "guideResponsesStream": "stream: ไม่จำเป็น คืนค่าจริง Server-Sent Events", + "guideResponsesOptional": "max_output_tokens, tools และ reasoning_effort เป็นทางเลือก", + "guideResponsesNonStreaming": "การตอบกลับแบบไม่ streaming ใช้ object: \"response\" โดยข้อความอยู่ใน output", + "guideResponsesStreaming": "การตอบสนองของ streaming จะส่งเสียง response.created, response.output_text.delta และ response.completed ตามลำดับ", + "guideChatDesc": "สร้างการตอบกลับการสนทนาในรูปแบบ OpenAI Chat Completions ซึ่งใช้โดย SDK ที่เข้ากันได้กับ OpenAI ส่วนใหญ่", + "guideChatMessages": "messages: จำเป็นและห้ามว่าง รองรับข้อความ system, developer, user, assistant และ tool", + "guideChatStream": "stream: ไม่จำเป็น ส่งคืนจริง SSE; ส่งคืนเท็จ JSON เสร็จสมบูรณ์", + "guideChatTools": "tools: ไม่จำเป็น ใช้คำจำกัดความ OpenAI function tool และรับรูปภาพเป็น Base64 data URL เท่านั้น", + "guideChatNonStreaming": "ข้อความที่ไม่ใช่ streaming อยู่ใน choices[0].message.content", + "guideChatStreaming": "เอาต์พุตการสตรีมจะลงท้ายด้วย data: [DONE]", + "guideChatModelMissing": "โมเดลที่ไม่รู้จักส่งคืน 404 (model_not_found)", + "exampleChatPrompt": "สวัสดีคุน!", + "exampleResponsesPrompt": "แนะนำเกตเวย์ท้องถิ่น Kun ในประโยคเดียว", + "statusRequestFailed": "การร้องขอสถานะ Kun Runtime ล้มเหลว ({{status}})" + } +} From 37b3bed75c0f3b597172a85571cb2d94204aec8e Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 15 Aug 2026 21:46:28 +0800 Subject: [PATCH 10/13] fix(chat): preserve subagent process navigation --- .../chat-store-navigation-refresh.test.ts | 43 +++++++++++++++++++ ...chat-store-navigation-workspace-actions.ts | 9 ++-- .../chat-store-thread-refresh-selection.ts | 37 ++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/store/chat-store-thread-refresh-selection.ts diff --git a/src/renderer/src/store/chat-store-navigation-refresh.test.ts b/src/renderer/src/store/chat-store-navigation-refresh.test.ts index c885b060a..c4054d115 100644 --- a/src/renderer/src/store/chat-store-navigation-refresh.test.ts +++ b/src/renderer/src/store/chat-store-navigation-refresh.test.ts @@ -436,6 +436,49 @@ describe('chat-store navigation workspace selection', () => { expect(harness.state.threads.some((item) => item.id === ordinarySide.id)).toBe(false) }) + it.each([ + ['while its detail is loading', 'primary', 'thr_subagent'], + ['after its side relation loads', 'side', null] + ] as const)('preserves an active subagent process %s across inventory refresh', async ( + _label, + relation, + threadLoadingId + ) => { + const source = thread({ id: 'thr_source', title: 'Source', workspace: '/project' }) + const child = { + ...thread({ id: 'thr_subagent', title: 'Subagent', workspace: '/project' }), + relation: 'side' as const, + parentThreadId: source.id + } + registryMock.getProvider.mockReturnValue({ + listThreads: vi.fn(async () => [source, child]), + getThreadDetail: vi.fn(async () => ({ blocks: [] })) + }) + vi.stubGlobal('window', { + localStorage: new MemoryStorage(), + kunGui: { getSettings: vi.fn(async () => ({ write: { workspaces: [] } })) } + }) + const harness = buildHarness() + harness.state.activeThreadId = child.id + harness.state.activeThreadRelation = relation + harness.state.activeThreadParentId = source.id + harness.state.threadLoadingId = threadLoadingId + harness.state.blocks = [{ kind: 'assistant', id: 'child_output', text: 'Child transcript' }] + harness.state.threads = [source] + harness.state.watchTurnCompletion = { [child.id]: true } + harness.state.unreadThreadIds = { [child.id]: true } + + await harness.actions.refreshThreads() + + expect(harness.state.activeThreadId).toBe(child.id) + expect(harness.state.blocks).toEqual([ + { kind: 'assistant', id: 'child_output', text: 'Child transcript' } + ]) + expect(harness.state.threads.map((item) => item.id)).toEqual([source.id]) + expect(harness.state.watchTurnCompletion).toEqual({ [child.id]: true }) + expect(harness.state.unreadThreadIds).toEqual({ [child.id]: true }) + }) + it('openDesign keeps the Code timeline and routes into its shared workbench', () => { const harness = buildHarness() harness.state.activeThreadId = 'thr_code' diff --git a/src/renderer/src/store/chat-store-navigation-workspace-actions.ts b/src/renderer/src/store/chat-store-navigation-workspace-actions.ts index ef375306b..62bd090b7 100644 --- a/src/renderer/src/store/chat-store-navigation-workspace-actions.ts +++ b/src/renderer/src/store/chat-store-navigation-workspace-actions.ts @@ -137,6 +137,7 @@ import { markUnreadCompletion, retainUnreadCompletions } from './unread-completions' +import { threadRefreshSelection } from './chat-store-thread-refresh-selection' type SseAbortRef = { current: AbortController | null } @@ -589,8 +590,7 @@ export function createNavigationWorkspaceActions( isWriteAssistantThread(activeThread, writeRegistry) || isClawThread(activeThread, get().clawChannels) || isInternalDeepSeekGuiWorkspace(activeThread.workspace)) - const shouldClearSelection = - activeThreadId != null && !displayThreads.some((thread) => thread.id === activeThreadId) + const { shouldClearSelection } = threadRefreshSelection(get(), displayThreads) if (shouldClearSelection) { sseAbortRef.current?.abort() sseAbortRef.current = null @@ -604,10 +604,7 @@ export function createNavigationWorkspaceActions( rememberedCodeThreadId && !threads.some((thread) => thread.id === rememberedCodeThreadId && thread.archived !== true) ) - const validIds = new Set([ - ...displayThreads.map((thread) => thread.id), - ...Object.keys(get().sideConversations ?? {}) - ]) + const { validIds } = threadRefreshSelection(get(), displayThreads) const reconciledCompletedWatchIds = new Set( [...reconciledStateById.entries()] .filter(([id, state]) => { diff --git a/src/renderer/src/store/chat-store-thread-refresh-selection.ts b/src/renderer/src/store/chat-store-thread-refresh-selection.ts new file mode 100644 index 000000000..12ec700d0 --- /dev/null +++ b/src/renderer/src/store/chat-store-thread-refresh-selection.ts @@ -0,0 +1,37 @@ +import type { NormalizedThread } from '../agent/types' +import type { ChatState } from './chat-store-types' + +type ThreadRefreshState = Pick< + ChatState, + 'activeThreadId' | 'activeThreadRelation' | 'threadLoadingId' | 'sideConversations' +> + +export type ThreadRefreshSelection = { + shouldClearSelection: boolean + validIds: Set +} + +/** + * Primary inventory refreshes intentionally omit side threads. Preserve an + * explicit process navigation while it is hydrating and after its side + * relation is known, without inserting that hidden thread into the sidebar. + */ +export function threadRefreshSelection( + state: ThreadRefreshState, + displayThreads: ReadonlyArray> +): ThreadRefreshSelection { + const activeThreadId = state.activeThreadId + const preserveActiveThread = activeThreadId != null && ( + state.threadLoadingId === activeThreadId || state.activeThreadRelation === 'side' + ) + const validIds = new Set([ + ...displayThreads.map((thread) => thread.id), + ...Object.keys(state.sideConversations ?? {}) + ]) + if (preserveActiveThread) validIds.add(activeThreadId) + return { + shouldClearSelection: + activeThreadId != null && !preserveActiveThread && !validIds.has(activeThreadId), + validIds + } +} From f71f5bb77b8edc35f1c441895d64bcbbd2121371 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 18:15:58 +0800 Subject: [PATCH 11/13] fix(palette): bound conversation content search --- .../file/file-session-store.search.test.ts | 10 + kun/src/adapters/file/file-session-store.ts | 93 +-------- .../adapters/file/file-session-text-search.ts | 101 +++++++++ .../adapters/hybrid/hybrid-session-store.ts | 3 +- kun/src/manager/remote-data-stores.ts | 6 +- .../shared-data-store-implementation.ts | 8 +- kun/src/manager/shared-data-store.test.ts | 3 + kun/src/ports/session-store.ts | 8 +- .../server/routes/register-thread-routes.ts | 2 +- .../routes/thread-content-search.test.ts | 193 ++++++++++++++++++ .../server/routes/thread-content-search.ts | 138 +++++++++++++ kun/src/server/routes/threads.test.ts | 175 +--------------- kun/src/server/routes/threads.ts | 108 ---------- src/renderer/src/components/Workbench.tsx | 185 +++-------------- .../WorkbenchCommandPaletteRuntime.tsx | 140 +++++++++++++ ...chat-store-navigation-workspace-actions.ts | 4 - 16 files changed, 639 insertions(+), 538 deletions(-) create mode 100644 kun/src/adapters/file/file-session-text-search.ts create mode 100644 kun/src/server/routes/thread-content-search.test.ts create mode 100644 kun/src/server/routes/thread-content-search.ts create mode 100644 src/renderer/src/components/workbench/WorkbenchCommandPaletteRuntime.tsx diff --git a/kun/src/adapters/file/file-session-store.search.test.ts b/kun/src/adapters/file/file-session-store.search.test.ts index e4fbe4764..c7655627a 100644 --- a/kun/src/adapters/file/file-session-store.search.test.ts +++ b/kun/src/adapters/file/file-session-store.search.test.ts @@ -121,4 +121,14 @@ describe('FileSessionStore.searchItemText', () => { await expect(store.searchItemText('../escape', 'anything')).resolves.toBeNull() await expect(store.searchItemText('thread_ok', '')).resolves.toBeNull() }) + + it('does not start or return a scan after its deadline', async () => { + const { store } = await newStore() + const threadId = 'thread_expired' + await store.appendItem(threadId, makeUserItem({ + id: 'i1', turnId: 't1', threadId, text: 'checkout after deadline' + })) + await expect(store.searchItemText(threadId, 'checkout', { deadlineAtMs: Date.now() - 1 })) + .resolves.toBeNull() + }) }) diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts index 11824715e..8b4cedc57 100644 --- a/kun/src/adapters/file/file-session-store.ts +++ b/kun/src/adapters/file/file-session-store.ts @@ -8,6 +8,7 @@ import type { ItemHistoryCompactionResult, ItemHistoryCommit, ItemHistorySnapshot, + ItemTextSearchOptions, SessionStore } from '../../ports/session-store.js' import type { RuntimeEvent } from '../../contracts/events.js' @@ -28,6 +29,7 @@ import { buildPublicItemHistoryPage } from '../../services/item-history-page.js' import { SessionCompactionScheduler } from './session-compaction-scheduler.js' +import { searchItemTextFile } from './file-session-text-search.js' export { readLatestItemsFromJsonl } from './file-session-jsonl.js' @@ -359,71 +361,16 @@ export class FileSessionStore implements SessionStore { async searchItemText( threadId: string, query: string, - options: { maxBytes?: number } = {} + options: ItemTextSearchOptions = {} ): Promise { if (!isSafeThreadId(threadId)) return null - const needle = query.toLowerCase() - if (!needle) return null - - const cached = this.itemsCache.get(threadId) - if (cached) return firstMatchingItemText(cached, needle) - const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_ITEM_TEXT_SEARCH_MAX_BYTES)) - const path = this.messagesPath(threadId) - const info = await stat(path).catch(() => null) - if (!info || info.size === 0) return null - // Reading the tail keeps the work bounded on huge logs while covering the - // most recent conversation, which is what a palette query is looking for. - const start = Math.max(0, info.size - maxBytes) - - return new Promise((resolvePromise) => { - const stream = createReadStream(path, { encoding: 'utf-8', start }) - let remainder = '' - // A non-zero start almost certainly lands mid-record; drop that partial - // first line rather than reporting a truncated snippet. - let skipPartialLine = start > 0 - let settled = false - - const finish = (value: string | null): void => { - if (settled) return - settled = true - stream.destroy() - resolvePromise(value) - } - - const acceptLine = (line: string): string | null => { - if (skipPartialLine) { - skipPartialLine = false - return null - } - // Cheap pre-filter: only parse records whose raw JSON could match. - if (!line || !line.toLowerCase().includes(needle)) return null - let item: TurnItem - try { - item = JSON.parse(line) as TurnItem - } catch { - return null - } - const text = searchableItemText(item) - // The raw hit may have been a field name or id rather than content. - return text && text.toLowerCase().includes(needle) ? text : null - } - - stream.on('data', (chunk: string | Buffer) => { - remainder += typeof chunk === 'string' ? chunk : chunk.toString('utf-8') - let newline = remainder.indexOf('\n') - while (newline >= 0) { - const match = acceptLine(remainder.slice(0, newline).trim()) - remainder = remainder.slice(newline + 1) - if (match !== null) { - finish(match) - return - } - newline = remainder.indexOf('\n') - } - }) - stream.on('error', () => finish(null)) - stream.on('close', () => finish(acceptLine(remainder.trim()))) + return searchItemTextFile({ + path: this.messagesPath(threadId), + query, + maxBytes, + cachedItems: this.itemsCache.get(threadId), + options }) } @@ -726,25 +673,3 @@ export class FileSessionStore implements SessionStore { } } } - -/** - * Item kinds whose text a content search may read. Tool calls, results, and - * internal bookkeeping stay out so a search never surfaces raw tool payloads. - */ -function searchableItemText(item: TurnItem): string | null { - switch (item.kind) { - case 'user_message': - case 'assistant_text': - return item.text - default: - return null - } -} - -function firstMatchingItemText(items: readonly TurnItem[], lowerCaseNeedle: string): string | null { - for (const item of items) { - const text = searchableItemText(item) - if (text && text.toLowerCase().includes(lowerCaseNeedle)) return text - } - return null -} diff --git a/kun/src/adapters/file/file-session-text-search.ts b/kun/src/adapters/file/file-session-text-search.ts new file mode 100644 index 000000000..a1df35809 --- /dev/null +++ b/kun/src/adapters/file/file-session-text-search.ts @@ -0,0 +1,101 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import type { TurnItem } from '../../contracts/items.js' +import type { ItemTextSearchOptions } from '../../ports/session-store.js' + +export async function searchItemTextFile(input: { + path: string + query: string + maxBytes: number + cachedItems?: readonly TurnItem[] + options?: ItemTextSearchOptions +}): Promise { + const needle = input.query.toLowerCase() + if (!needle) return null + const deadlineAtMs = input.options?.deadlineAtMs + const expired = (): boolean => deadlineAtMs !== undefined && Date.now() >= deadlineAtMs + if (expired()) return null + + if (input.cachedItems) { + return firstMatchingItemText(input.cachedItems, needle, deadlineAtMs) + } + + const info = await stat(input.path).catch(() => null) + if (!info || info.size === 0 || expired()) return null + const start = Math.max(0, info.size - input.maxBytes) + + return new Promise((resolvePromise) => { + const stream = createReadStream(input.path, { encoding: 'utf-8', start }) + let deadlineTimer: ReturnType | undefined + let remainder = '' + let skipPartialLine = start > 0 + let settled = false + + const finish = (value: string | null): void => { + if (settled) return + settled = true + if (deadlineTimer) clearTimeout(deadlineTimer) + stream.destroy() + resolvePromise(value) + } + + if (deadlineAtMs !== undefined) { + deadlineTimer = setTimeout(() => finish(null), Math.max(0, deadlineAtMs - Date.now())) + } + + const acceptLine = (line: string): string | null => { + if (skipPartialLine) { + skipPartialLine = false + return null + } + if (!line || !line.toLowerCase().includes(needle)) return null + let item: TurnItem + try { + item = JSON.parse(line) as TurnItem + } catch { + return null + } + const text = searchableItemText(item) + return text && text.toLowerCase().includes(needle) ? text : null + } + + stream.on('data', (chunk: string | Buffer) => { + remainder += typeof chunk === 'string' ? chunk : chunk.toString('utf-8') + let newline = remainder.indexOf('\n') + while (newline >= 0) { + const match = acceptLine(remainder.slice(0, newline).trim()) + remainder = remainder.slice(newline + 1) + if (match !== null) { + finish(match) + return + } + newline = remainder.indexOf('\n') + } + }) + stream.on('error', () => finish(null)) + stream.on('close', () => finish(acceptLine(remainder.trim()))) + }) +} + +function searchableItemText(item: TurnItem): string | null { + switch (item.kind) { + case 'user_message': + case 'assistant_text': + return item.text + default: + return null + } +} + +function firstMatchingItemText( + items: readonly TurnItem[], + lowerCaseNeedle: string, + deadlineAtMs?: number +): string | null { + for (const item of items) { + if (deadlineAtMs !== undefined && Date.now() >= deadlineAtMs) return null + const text = searchableItemText(item) + if (text && text.toLowerCase().includes(lowerCaseNeedle)) return text + } + return null +} diff --git a/kun/src/adapters/hybrid/hybrid-session-store.ts b/kun/src/adapters/hybrid/hybrid-session-store.ts index e511cb5de..efea2c528 100644 --- a/kun/src/adapters/hybrid/hybrid-session-store.ts +++ b/kun/src/adapters/hybrid/hybrid-session-store.ts @@ -7,6 +7,7 @@ import type { ItemHistoryPage, ItemHistoryPageOptions, ItemHistorySnapshot, + ItemTextSearchOptions, SessionLatestUsageSnapshot, SessionStore, SessionUsageRecord @@ -109,7 +110,7 @@ export class HybridSessionStore implements SessionStore { async searchItemText( threadId: string, query: string, - options?: { maxBytes?: number } + options?: ItemTextSearchOptions ): Promise { return this.delegate.searchItemText?.(threadId, query, options) ?? null } diff --git a/kun/src/manager/remote-data-stores.ts b/kun/src/manager/remote-data-stores.ts index a2db3b159..155f7e190 100644 --- a/kun/src/manager/remote-data-stores.ts +++ b/kun/src/manager/remote-data-stores.ts @@ -53,6 +53,7 @@ import type { ItemHistoryPage, ItemHistoryPageOptions, ItemHistorySnapshot, + ItemTextSearchOptions, SessionLatestUsageSnapshot, SessionStore, SessionUsageRecord @@ -307,12 +308,13 @@ export class ManagerRemoteSessionStore implements SessionStore { async searchItemText( threadId: string, query: string, - options?: { maxBytes?: number } + options?: ItemTextSearchOptions ): Promise { return z.string().nullable().parse(await this.call('searchItemText', { threadId, query, - ...(options?.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }) + ...(options?.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }), + ...(options?.deadlineAtMs === undefined ? {} : { deadlineAtMs: options.deadlineAtMs }) })) } diff --git a/kun/src/manager/shared-data-store-implementation.ts b/kun/src/manager/shared-data-store-implementation.ts index 7fb21fbde..57d8c0a38 100644 --- a/kun/src/manager/shared-data-store-implementation.ts +++ b/kun/src/manager/shared-data-store-implementation.ts @@ -481,7 +481,8 @@ export class ManagerSharedDataStore extends ManagerSharedDataStoreCore { const body = z.object({ threadId: ThreadIdSchema, query: z.string(), - maxBytes: z.number().int().positive().optional() + maxBytes: z.number().int().positive().optional(), + deadlineAtMs: z.number().int().nonnegative().optional() }).strict().parse(value) // The owning store keeps the lock-free guarantee; a manager-backed // runtime without it reports no match rather than falling back to the @@ -490,7 +491,10 @@ export class ManagerSharedDataStore extends ManagerSharedDataStoreCore { return this.sessionStore.searchItemText( body.threadId, body.query, - body.maxBytes === undefined ? undefined : { maxBytes: body.maxBytes } + { + ...(body.maxBytes === undefined ? {} : { maxBytes: body.maxBytes }), + ...(body.deadlineAtMs === undefined ? {} : { deadlineAtMs: body.deadlineAtMs }) + } ) } case 'loadItemPage': { diff --git a/kun/src/manager/shared-data-store.test.ts b/kun/src/manager/shared-data-store.test.ts index 6974ca8a7..af0aa06db 100644 --- a/kun/src/manager/shared-data-store.test.ts +++ b/kun/src/manager/shared-data-store.test.ts @@ -49,6 +49,9 @@ describe('manager shared data store', () => { await expect(store.executeSession('searchItemText', { threadId: thread.id, query: 'absent' })).resolves.toBeNull() + await expect(store.executeSession('searchItemText', { + threadId: thread.id, query: 'checkout', deadlineAtMs: Date.now() - 1 + })).resolves.toBeNull() await store.close() }) diff --git a/kun/src/ports/session-store.ts b/kun/src/ports/session-store.ts index 2e654bf02..2c73751e3 100644 --- a/kun/src/ports/session-store.ts +++ b/kun/src/ports/session-store.ts @@ -67,6 +67,12 @@ export type ItemHistoryPage = { itemBytes: number } +export type ItemTextSearchOptions = { + maxBytes?: number + /** Epoch deadline after which the scan must stop and report no match. */ + deadlineAtMs?: number +} + /** * Port for persisted per-thread activity. * @@ -156,7 +162,7 @@ export interface SessionStore { searchItemText?( threadId: string, query: string, - options?: { maxBytes?: number } + options?: ItemTextSearchOptions ): Promise loadSession(threadId: string): Promise upsertSession(session: AgentSession): Promise diff --git a/kun/src/server/routes/register-thread-routes.ts b/kun/src/server/routes/register-thread-routes.ts index 6005564af..5960174f6 100644 --- a/kun/src/server/routes/register-thread-routes.ts +++ b/kun/src/server/routes/register-thread-routes.ts @@ -1,6 +1,5 @@ import type { Router } from '../router.js' import { - contentSearchThreads, createThread, clearThreadGoal, clearThreadTodos, @@ -16,6 +15,7 @@ import { setThreadTodos, updateThread } from './threads.js' +import { contentSearchThreads } from './thread-content-search.js' import { summarizeThread } from './threads-summarize.js' import { compactTurn, diff --git a/kun/src/server/routes/thread-content-search.test.ts b/kun/src/server/routes/thread-content-search.test.ts new file mode 100644 index 000000000..dc338b176 --- /dev/null +++ b/kun/src/server/routes/thread-content-search.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TurnItem } from '../../contracts/items.js' +import { createThreadRecord } from '../../domain/thread.js' +import { makeUserItem } from '../../domain/item.js' +import type { ThreadService } from '../../services/thread-service.js' +import { + contentSearchThreads, + snippetAroundMatch, + THREAD_CONTENT_SEARCH_BUDGET_MS +} from './thread-content-search.js' + +describe('contentSearchThreads', () => { + it('returns one snippet per matching conversation, most recently updated first', async () => { + const newer = createThreadRecord({ + id: 'thr_newer', title: 'Payment gateway', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const older = createThreadRecord({ + id: 'thr_older', title: 'Docs rewrite', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const none = createThreadRecord({ + id: 'thr_none', title: 'Nothing', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const archived = createThreadRecord({ + id: 'thr_archived', title: 'Archived hit', workspace: '/tmp', model: 'deepseek-chat', status: 'archived' + }) + newer.updatedAt = '2026-08-15T03:00:00.000Z' + older.updatedAt = '2026-08-15T02:00:00.000Z' + none.updatedAt = '2026-08-15T01:00:00.000Z' + archived.updatedAt = '2026-08-15T04:00:00.000Z' + const service = { list: async () => [none, archived, older, newer] } as unknown as ThreadService + const sessionStore = { + searchItemText: async (threadId: string): Promise => { + if (threadId === 'thr_newer') return 'Let us redesign the checkout flow end to end.' + if (threadId === 'thr_older') return 'checkout must be faster' + if (threadId === 'thr_archived') return 'checkout checkout checkout' + return null + } + } + const response = await contentSearchThreads( + service, + sessionStore, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + expect(response.status).toBe(200) + const body = JSON.parse(response.body) as { + matches: Array<{ threadId: string; title: string; workspace: string; snippet: string }> + } + expect(body.matches.map((match) => match.threadId)).toEqual(['thr_newer', 'thr_older']) + expect(body.matches[0]).toMatchObject({ title: 'Payment gateway', workspace: '/tmp' }) + expect(body.matches[0].snippet.toLowerCase()).toContain('checkout') + }) + + it('never drives the blocking loadItems path', async () => { + const thread = createThreadRecord({ + id: 'thr_only', title: 'Only', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const service = { list: async () => [thread] } as unknown as ThreadService + const loadItems = vi.fn(async (): Promise => [ + makeUserItem({ id: 'i0', turnId: 't0', threadId: 'thr_only', text: 'checkout' }) + ]) + const response = await contentSearchThreads( + service, + { loadItems } as unknown as Parameters[1], + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + expect(response.status).toBe(200) + expect(JSON.parse(response.body)).toEqual({ matches: [] }) + expect(loadItems).not.toHaveBeenCalled() + }) + + it('searches every project and reports which one each match came from', async () => { + const here = createThreadRecord({ + id: 'thr_here', title: 'This project', workspace: '/repo/app', model: 'deepseek-chat', status: 'idle' + }) + const elsewhere = createThreadRecord({ + id: 'thr_elsewhere', title: 'Other project', workspace: '/repo/other', model: 'deepseek-chat', status: 'idle' + }) + here.updatedAt = '2026-08-15T02:00:00.000Z' + elsewhere.updatedAt = '2026-08-15T03:00:00.000Z' + const service = { list: async () => [here, elsewhere] } as unknown as ThreadService + const response = await contentSearchThreads( + service, + { searchItemText: async (): Promise => 'checkout here' }, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + const body = JSON.parse(response.body) as { matches: Array<{ threadId: string; workspace: string }> } + expect(body.matches.map((match) => match.threadId)).toEqual(['thr_elsewhere', 'thr_here']) + expect(body.matches.map((match) => match.workspace)).toEqual(['/repo/other', '/repo/app']) + }) + + it('stops scanning once the time budget is spent', async () => { + const threads = Array.from({ length: 10 }, (_, index) => { + const record = createThreadRecord({ + id: 'thr_' + index, title: 'T' + index, workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + record.updatedAt = '2026-08-15T0' + index + ':00:00.000Z' + return record + }) + const service = { list: async () => threads } as unknown as ThreadService + let currentTime = 0 + const searchItemText = vi.fn(async (): Promise => { + currentTime = THREAD_CONTENT_SEARCH_BUDGET_MS + 1 + return 'checkout' + }) + const response = await contentSearchThreads( + service, + { searchItemText }, + new Request('http://kun.local/v1/threads/content-search?q=checkout'), + () => currentTime + ) + expect((JSON.parse(response.body) as { matches: unknown[] }).matches).toHaveLength(1) + expect(searchItemText).toHaveBeenCalledTimes(1) + }) + + it('returns when an individual store scan exceeds the wall-clock budget', async () => { + vi.useFakeTimers() + try { + const thread = createThreadRecord({ + id: 'thr_hung', title: 'Hung', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const service = { list: async () => [thread] } as unknown as ThreadService + const searchItemText = vi.fn(() => new Promise(() => undefined)) + const responsePromise = contentSearchThreads( + service, + { searchItemText }, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + await vi.advanceTimersByTimeAsync(THREAD_CONTENT_SEARCH_BUDGET_MS) + expect(JSON.parse((await responsePromise).body)).toEqual({ matches: [] }) + expect(searchItemText).toHaveBeenCalledWith( + 'thr_hung', + 'checkout', + { deadlineAtMs: expect.any(Number) } + ) + } finally { + vi.useRealTimers() + } + }) + + it('rejects empty and oversized queries with 400', async () => { + const service = { list: async () => [] } as unknown as ThreadService + const sessionStore = { searchItemText: async () => null } + const empty = await contentSearchThreads( + service, sessionStore, new Request('http://kun.local/v1/threads/content-search') + ) + expect(empty.status).toBe(400) + const oversized = await contentSearchThreads( + service, + sessionStore, + new Request('http://kun.local/v1/threads/content-search?q=' + 'x'.repeat(257)) + ) + expect(oversized.status).toBe(400) + }) + + it('tolerates threads whose items cannot be scanned', async () => { + const broken = createThreadRecord({ + id: 'thr_broken', title: 'Broken', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + const fine = createThreadRecord({ + id: 'thr_fine', title: 'Fine', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' + }) + broken.updatedAt = '2026-08-15T03:00:00.000Z' + fine.updatedAt = '2026-08-15T02:00:00.000Z' + const service = { list: async () => [broken, fine] } as unknown as ThreadService + const response = await contentSearchThreads( + service, + { + searchItemText: async (threadId: string): Promise => { + if (threadId === 'thr_broken') throw new Error('corrupt') + return 'checkout once more' + } + }, + new Request('http://kun.local/v1/threads/content-search?q=checkout') + ) + const body = JSON.parse(response.body) as { matches: Array<{ threadId: string }> } + expect(body.matches.map((match) => match.threadId)).toEqual(['thr_fine']) + }) +}) + +describe('snippetAroundMatch', () => { + it('windows the snippet around the first match and elides the edges', () => { + const text = 'a'.repeat(300) + ' checkout ' + 'b'.repeat(300) + const snippet = snippetAroundMatch(text, 'checkout') + expect(snippet).toContain('checkout') + expect(snippet.startsWith('…')).toBe(true) + expect(snippet.endsWith('…')).toBe(true) + expect(snippet.length).toBeLessThan(180) + }) + + it('returns the head of the text when nothing matches', () => { + expect(snippetAroundMatch('plain text without match', 'zzz')).toBe('plain text without match') + }) +}) diff --git a/kun/src/server/routes/thread-content-search.ts b/kun/src/server/routes/thread-content-search.ts new file mode 100644 index 000000000..22b696243 --- /dev/null +++ b/kun/src/server/routes/thread-content-search.ts @@ -0,0 +1,138 @@ +import { z } from 'zod' +import type { SessionStore } from '../../ports/session-store.js' +import type { ThreadService } from '../../services/thread-service.js' +import { jsonResponse, type JsonResponse } from '../response.js' + +const THREAD_CONTENT_SEARCH_MAX_THREADS = 40 +const THREAD_CONTENT_SEARCH_DEFAULT_MATCHES = 12 +const THREAD_CONTENT_SEARCH_MAX_QUERY_CHARS = 256 +const THREAD_CONTENT_SEARCH_CANDIDATE_POOL = 500 +/** Wall-clock ceiling for one scan; partial results beat a stalled palette. */ +export const THREAD_CONTENT_SEARCH_BUDGET_MS = 400 + +const ContentSearchQuery = z.object({ + q: z.string().min(1).max(THREAD_CONTENT_SEARCH_MAX_QUERY_CHARS), + limit: z.preprocess((value) => { + if (typeof value !== 'string' || value.trim() === '') return undefined + return Number(value) + }, z.number().int().positive().max(20).optional()) +}) + +export type ThreadContentMatch = { + threadId: string + title: string + workspace: string + snippet: string + updatedAt: string +} + +export type ThreadContentSearchResponse = { matches: ThreadContentMatch[] } +export type ThreadContentSearchStore = Pick + +const CONTENT_SEARCH_DEADLINE = Symbol('content-search-deadline') + +async function settleBeforeDeadline( + operation: () => Promise, + deadline: number, + now: () => number +): Promise { + const remainingMs = deadline - now() + if (remainingMs <= 0) return CONTENT_SEARCH_DEADLINE + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => resolvePromise(CONTENT_SEARCH_DEADLINE), remainingMs) + let running: Promise + try { + running = operation() + } catch (error) { + clearTimeout(timer) + rejectPromise(error) + return + } + void running.then( + (value) => { + clearTimeout(timer) + resolvePromise(value) + }, + (error: unknown) => { + clearTimeout(timer) + rejectPromise(error) + } + ) + }) +} + +export function snippetAroundMatch(text: string, query: string): string { + const index = text.toLowerCase().indexOf(query.toLowerCase()) + if (index < 0) return text.slice(0, 160) + const start = Math.max(0, index - 60) + const end = Math.min(text.length, index + query.length + 100) + return ((start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')) + .replace(/\s+/g, ' ') + .trim() +} + +export async function contentSearchThreads( + service: ThreadService, + sessionStore: ThreadContentSearchStore, + request: Request, + now: () => number = () => Date.now() +): Promise { + const url = new URL(request.url) + const parsed = ContentSearchQuery.safeParse(Object.fromEntries(url.searchParams.entries())) + if (!parsed.success) { + return jsonResponse({ + code: 'validation_error', + message: 'invalid content search query', + details: parsed.error.issues + }, 400) + } + const search = sessionStore.searchItemText + if (!search) return jsonResponse({ matches: [] } satisfies ThreadContentSearchResponse) + + const query = parsed.data.q + const matchLimit = parsed.data.limit ?? THREAD_CONTENT_SEARCH_DEFAULT_MATCHES + const deadline = now() + THREAD_CONTENT_SEARCH_BUDGET_MS + const listed = await settleBeforeDeadline( + () => service.list({ limit: THREAD_CONTENT_SEARCH_CANDIDATE_POOL }), + deadline, + now + ) + if (listed === CONTENT_SEARCH_DEADLINE) { + return jsonResponse({ matches: [] } satisfies ThreadContentSearchResponse) + } + const candidates = listed + .filter((thread) => thread.status !== 'archived' && thread.status !== 'deleted') + .sort((left, right) => sortableTime(right.updatedAt) - sortableTime(left.updatedAt)) + .slice(0, THREAD_CONTENT_SEARCH_MAX_THREADS) + + const matches: ThreadContentMatch[] = [] + for (const thread of candidates) { + if (matches.length >= matchLimit || now() >= deadline) break + let text: string | null + try { + const result = await settleBeforeDeadline( + () => search.call(sessionStore, thread.id, query, { deadlineAtMs: deadline }), + deadline, + now + ) + if (result === CONTENT_SEARCH_DEADLINE) break + text = result + } catch { + continue + } + if (!text) continue + matches.push({ + threadId: thread.id, + title: thread.title.trim() || thread.id, + workspace: thread.workspace, + snippet: snippetAroundMatch(text, query), + updatedAt: thread.updatedAt + }) + } + return jsonResponse({ matches } satisfies ThreadContentSearchResponse) +} + +function sortableTime(value: string): number { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? 0 : parsed +} diff --git a/kun/src/server/routes/threads.test.ts b/kun/src/server/routes/threads.test.ts index 47bb6ca6d..9c3435d2e 100644 --- a/kun/src/server/routes/threads.test.ts +++ b/kun/src/server/routes/threads.test.ts @@ -1,20 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import { - contentSearchThreads, forkThread, getThread, getThreadState, getThreadTimeline, - snippetAroundMatch, - updateThread, - THREAD_CONTENT_SEARCH_BUDGET_MS + updateThread } from './threads.js' import { buildRouter } from './index.js' import type { ServerRuntime } from './server-runtime.js' import { createThreadRecord } from '../../domain/thread.js' import { createTurnRecord } from '../../domain/turn.js' import { makeGoalContextItem, makeUserItem } from '../../domain/item.js' -import type { TurnItem } from '../../contracts/items.js' import { createApprovalRequest } from '../../domain/approval.js' import { InMemoryApprovalGate } from '../../adapters/in-memory-approval-gate.js' import { InMemoryUserInputGate } from '../../adapters/in-memory-user-input-gate.js' @@ -597,173 +593,4 @@ describe('GET /v1/threads/:id active-owner forwarding (#1053)', () => { const rejected = await match.handler(unauthorized, { params: match.params }) expect(rejected.status).toBe(401) }) - -describe('contentSearchThreads', () => { - it('returns one snippet per matching conversation, most recently updated first', async () => { - const newer = createThreadRecord({ - id: 'thr_newer', title: 'Payment gateway', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - const older = createThreadRecord({ - id: 'thr_older', title: 'Docs rewrite', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - const none = createThreadRecord({ - id: 'thr_none', title: 'Nothing', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - const archived = createThreadRecord({ - id: 'thr_archived', title: 'Archived hit', workspace: '/tmp', model: 'deepseek-chat', status: 'archived' - }) - newer.updatedAt = '2026-08-15T03:00:00.000Z' - older.updatedAt = '2026-08-15T02:00:00.000Z' - none.updatedAt = '2026-08-15T01:00:00.000Z' - archived.updatedAt = '2026-08-15T04:00:00.000Z' - const service = { - list: async () => [none, archived, older, newer] - } as unknown as ThreadService - const sessionStore = { - searchItemText: async (threadId: string): Promise => { - if (threadId === 'thr_newer') return 'Let us redesign the checkout flow end to end.' - if (threadId === 'thr_older') return 'checkout must be faster' - if (threadId === 'thr_archived') return 'checkout checkout checkout' - return null - } - } - const response = await contentSearchThreads( - service, - sessionStore, - new Request('http://kun.local/v1/threads/content-search?q=checkout') - ) - expect(response.status).toBe(200) - const body = JSON.parse(response.body) as { matches: Array<{ threadId: string; title: string; workspace: string; snippet: string; updatedAt: string }> } - expect(body.matches.map((match) => match.threadId)).toEqual(['thr_newer', 'thr_older']) - expect(body.matches[0].title).toBe('Payment gateway') - expect(body.matches[0].workspace).toBe('/tmp') - expect(body.matches[0].snippet.toLowerCase()).toContain('checkout') - }) - - it('never drives the blocking loadItems path', async () => { - const thread = createThreadRecord({ - id: 'thr_only', title: 'Only', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - const service = { list: async () => [thread] } as unknown as ThreadService - const loadItems = vi.fn(async (): Promise => [ - makeUserItem({ id: 'i0', turnId: 't0', threadId: 'thr_only', text: 'checkout' }) - ]) - // A store exposing only the blocking path reports no matches rather than - // taking per-thread write queues and compacting logs on a keystroke. - const response = await contentSearchThreads( - service, - { loadItems } as unknown as Parameters[1], - new Request('http://kun.local/v1/threads/content-search?q=checkout') - ) - expect(response.status).toBe(200) - expect(JSON.parse(response.body)).toEqual({ matches: [] }) - expect(loadItems).not.toHaveBeenCalled() - }) - - it('searches every project and reports which one each match came from', async () => { - const here = createThreadRecord({ - id: 'thr_here', title: 'This project', workspace: '/repo/app', model: 'deepseek-chat', status: 'idle' - }) - const elsewhere = createThreadRecord({ - id: 'thr_elsewhere', title: 'Other project', workspace: '/repo/other', model: 'deepseek-chat', status: 'idle' - }) - here.updatedAt = '2026-08-15T02:00:00.000Z' - elsewhere.updatedAt = '2026-08-15T03:00:00.000Z' - const service = { list: async () => [here, elsewhere] } as unknown as ThreadService - const sessionStore = { searchItemText: async (): Promise => 'checkout here' } - const response = await contentSearchThreads( - service, - sessionStore, - new Request('http://kun.local/v1/threads/content-search?q=checkout') - ) - const body = JSON.parse(response.body) as { - matches: Array<{ threadId: string; workspace: string }> - } - // Recency alone orders them; the workspace rides along so the caller can - // show which project a match belongs to. - expect(body.matches.map((match) => match.threadId)).toEqual(['thr_elsewhere', 'thr_here']) - expect(body.matches.map((match) => match.workspace)).toEqual(['/repo/other', '/repo/app']) - }) - - it('stops scanning once the time budget is spent', async () => { - const threads = Array.from({ length: 10 }, (_, index) => { - const record = createThreadRecord({ - id: 'thr_' + index, title: 'T' + index, workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - record.updatedAt = '2026-08-15T0' + index + ':00:00.000Z' - return record - }) - const service = { list: async () => threads } as unknown as ThreadService - const searchItemText = vi.fn(async (): Promise => 'checkout') - // Reads: deadline stamp, first candidate check (in budget), second check - // (spent). Exactly one of the ten candidates is scanned. - const clock = [0, 0, THREAD_CONTENT_SEARCH_BUDGET_MS + 1] - const response = await contentSearchThreads( - service, - { searchItemText }, - new Request('http://kun.local/v1/threads/content-search?q=checkout'), - () => clock.shift() ?? THREAD_CONTENT_SEARCH_BUDGET_MS + 1 - ) - const body = JSON.parse(response.body) as { matches: Array<{ threadId: string }> } - expect(body.matches).toHaveLength(1) - expect(searchItemText).toHaveBeenCalledTimes(1) - }) - - it('rejects empty and oversized queries with 400', async () => { - const service = { list: async () => [] } as unknown as ThreadService - const sessionStore = { searchItemText: async () => null } - const empty = await contentSearchThreads( - service, sessionStore, new Request('http://kun.local/v1/threads/content-search') - ) - expect(empty.status).toBe(400) - const oversized = await contentSearchThreads( - service, - sessionStore, - new Request('http://kun.local/v1/threads/content-search?q=' + 'x'.repeat(257)) - ) - expect(oversized.status).toBe(400) - }) - - it('tolerates threads whose items cannot be scanned', async () => { - const broken = createThreadRecord({ - id: 'thr_broken', title: 'Broken', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - const fine = createThreadRecord({ - id: 'thr_fine', title: 'Fine', workspace: '/tmp', model: 'deepseek-chat', status: 'idle' - }) - broken.updatedAt = '2026-08-15T03:00:00.000Z' - fine.updatedAt = '2026-08-15T02:00:00.000Z' - const service = { - list: async () => [broken, fine] - } as unknown as ThreadService - const sessionStore = { - searchItemText: async (threadId: string): Promise => { - if (threadId === 'thr_broken') throw new Error('corrupt') - return 'checkout once more' - } - } - const response = await contentSearchThreads( - service, - sessionStore, - new Request('http://kun.local/v1/threads/content-search?q=checkout') - ) - const body = JSON.parse(response.body) as { matches: Array<{ threadId: string }> } - expect(body.matches.map((match) => match.threadId)).toEqual(['thr_fine']) - }) -}) - -describe('snippetAroundMatch', () => { - it('windows the snippet around the first match and elides the edges', () => { - const text = 'a'.repeat(300) + ' checkout ' + 'b'.repeat(300) - const snippet = snippetAroundMatch(text, 'checkout') - expect(snippet).toContain('checkout') - expect(snippet.startsWith('…')).toBe(true) - expect(snippet.endsWith('…')).toBe(true) - expect(snippet.length).toBeLessThan(180) - }) - - it('returns the head of the text when nothing matches', () => { - expect(snippetAroundMatch('plain text without match', 'zzz')).toBe('plain text without match') - }) -}) }) diff --git a/kun/src/server/routes/threads.ts b/kun/src/server/routes/threads.ts index a9bd80b80..c545c764c 100644 --- a/kun/src/server/routes/threads.ts +++ b/kun/src/server/routes/threads.ts @@ -579,112 +579,4 @@ function parseListThreadsOptions( } } -/** - * Deep-search route: scans the message content of recent conversations for - * a literal term and returns one snippet per matching thread. Content never - * leaves the local data dir. - * - * Deliberately not workspace-scoped: "where did I discuss this?" is usually - * asked without remembering which project it was in. Each match carries its - * workspace so callers can show which project it came from. The bounds below - * are therefore shared across every project, and a busy project can crowd out - * a quieter one. - * - * Every bound here exists because this runs on a palette keystroke. The scan - * uses the store's lock-free `searchItemText` capability rather than - * `loadItems`, which would take each thread's write queue and compact logs - * past the compaction threshold (#621). Stores without that capability report - * no matches instead of falling back to the blocking path. - */ -const THREAD_CONTENT_SEARCH_MAX_THREADS = 40 -const THREAD_CONTENT_SEARCH_DEFAULT_MATCHES = 12 -const THREAD_CONTENT_SEARCH_MAX_QUERY_CHARS = 256 -const THREAD_CONTENT_SEARCH_CANDIDATE_POOL = 500 -/** Wall-clock ceiling for one scan; partial results beat a stalled palette. */ -export const THREAD_CONTENT_SEARCH_BUDGET_MS = 400 - -const ContentSearchQuery = z.object({ - q: z.string().min(1).max(THREAD_CONTENT_SEARCH_MAX_QUERY_CHARS), - limit: z.preprocess((value) => { - if (typeof value !== 'string' || value.trim() === '') return undefined - return Number(value) - }, z.number().int().positive().max(20).optional()) -}) - -export type ThreadContentMatch = { - threadId: string - title: string - workspace: string - snippet: string - updatedAt: string -} - -export type ThreadContentSearchResponse = { matches: ThreadContentMatch[] } - -export type ThreadContentSearchStore = Pick - -export function snippetAroundMatch(text: string, query: string): string { - const index = text.toLowerCase().indexOf(query.toLowerCase()) - if (index < 0) return text.slice(0, 160) - const start = Math.max(0, index - 60) - const end = Math.min(text.length, index + query.length + 100) - return ((start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')) - .replace(/\s+/g, ' ') - .trim() -} - -export async function contentSearchThreads( - service: ThreadService, - sessionStore: ThreadContentSearchStore, - request: Request, - now: () => number = () => Date.now() -): Promise { - const url = new URL(request.url) - const parsed = ContentSearchQuery.safeParse(Object.fromEntries(url.searchParams.entries())) - if (!parsed.success) { - return validationError('invalid content search query', parsed.error.issues) - } - const search = sessionStore.searchItemText - if (!search) return jsonResponse({ matches: [] } satisfies ThreadContentSearchResponse) - - const query = parsed.data.q - const matchLimit = parsed.data.limit ?? THREAD_CONTENT_SEARCH_DEFAULT_MATCHES - const deadline = now() + THREAD_CONTENT_SEARCH_BUDGET_MS - - // `list` already excludes archived and deleted threads and orders by - // recency; the status filter and sort here keep the route correct on its - // own terms rather than depending on that as an invariant. - const threads = await service.list({ limit: THREAD_CONTENT_SEARCH_CANDIDATE_POOL }) - const candidates = threads - .filter((thread) => thread.status !== 'archived' && thread.status !== 'deleted') - .sort((left, right) => sortableTime(right.updatedAt) - sortableTime(left.updatedAt)) - .slice(0, THREAD_CONTENT_SEARCH_MAX_THREADS) - - const matches: ThreadContentMatch[] = [] - for (const thread of candidates) { - if (matches.length >= matchLimit || now() >= deadline) break - let text: string | null - try { - text = await search.call(sessionStore, thread.id, query) - } catch { - continue - } - if (!text) continue - matches.push({ - threadId: thread.id, - title: thread.title.trim() || thread.id, - workspace: thread.workspace, - snippet: snippetAroundMatch(text, query), - updatedAt: thread.updatedAt - }) - } - return jsonResponse({ matches } satisfies ThreadContentSearchResponse) -} - -/** Unparsable timestamps sort last instead of poisoning the comparator. */ -function sortableTime(value: string): number { - const parsed = Date.parse(value) - return Number.isNaN(parsed) ? 0 : parsed -} - void z diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 1f8c08aa1..de001ad2f 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -12,15 +12,9 @@ import { useWorkbenchNavigationController } from './workbench/useWorkbenchNaviga import { useWorkbenchDesignRuntime } from './workbench/useWorkbenchDesignRuntime' import { useWorkbenchExecutionSettings } from './workbench/useWorkbenchExecutionSettings' import { - runWorkbenchShortcutCommand, - useWorkbenchKeyboardShortcuts -} from './workbench/useWorkbenchKeyboardShortcuts' -import { getSlashQuery, COMPOSER_FOCUS_REQUEST_EVENT } from './chat/floating-composer-commands' -import { resolveKeyboardShortcutBindings } from '@shared/keyboard-shortcuts' -import { useKeyboardShortcutSettings } from '../lib/keyboard-shortcut-settings' -import { useCommandPaletteStore } from '../palette/palette-store' -import { useWorkbenchCommandPalette } from '../palette/useWorkbenchCommandPalette' -import { CommandPaletteOverlay } from '../palette/CommandPaletteOverlay' + openWorkbenchCommandPalette, + WorkbenchCommandPaletteRuntime +} from './workbench/WorkbenchCommandPaletteRuntime' import { useWorkbenchChatStoreState } from './workbench/useWorkbenchChatStoreState' import { useWorkbenchDerivedState } from './workbench/useWorkbenchDerivedState' import { useWorkbenchWriteAssistantRuntime } from './workbench/useWorkbenchWriteAssistantRuntime' @@ -112,7 +106,6 @@ const extensionSurfaceLayoutStorage = { export function Workbench(): ReactElement { const { t, i18n } = useTranslation('common') - const { t: tSettings } = useTranslation('settings') const { threads, threadSearch, showArchivedThreads, activeThreadId, activeThreadRelation, activeThreadParentId, selectThread, createThread, createConversation, blocks, @@ -402,38 +395,6 @@ export function Workbench(): ReactElement { if (!linkedSddDraft) return void openSddRequirementDraftFromHistory(linkedSddDraft) }, [linkedSddDraft, openSddRequirementDraftFromHistory]) - const slashMenuOpen = getSlashQuery(input) !== null - const keyboardShortcuts = useKeyboardShortcutSettings() - const shortcutPlatform = typeof window === 'undefined' ? undefined : window.kunGui?.platform - const keyboardShortcutBindings = useMemo( - () => resolveKeyboardShortcutBindings(keyboardShortcuts, shortcutPlatform), - [keyboardShortcuts, shortcutPlatform] - ) - const openPalette = useCommandPaletteStore((state) => state.openPalette) - const shortcutCommandContext = useMemo(() => ({ - composerMode, - setComposerMode, - handleGuiPlanCommand, - createThread, - chooseWorkspace, - toggleTerminal, - openSettings, - useWorktreePool, - setUseWorktreePool, - worktreeBranch, - navigationLocked: designDrawingCreationSubmitting - }), [ - chooseWorkspace, composerMode, createThread, designDrawingCreationSubmitting, - handleGuiPlanCommand, openSettings, setComposerMode, setUseWorktreePool, - toggleTerminal, useWorktreePool, worktreeBranch - ]) - - useWorkbenchKeyboardShortcuts({ - ...shortcutCommandContext, - slashMenuOpen, - openCommandPalette: openPalette, - keyboardShortcutBindings - }) const showDevPreviewCard = route === 'chat' && latestDevPreviewUrl !== null @@ -676,109 +637,6 @@ export function Workbench(): ReactElement { implementDesignInCode, selectCanvasShape, handleDesignHtmlElementAsContext, handleDesignRuntimeQualityFindings, handleDesignQualityRepairRequest }) - const paletteRuntimeReady = runtimeConnection === 'ready' - const commandPalette = useWorkbenchCommandPalette({ - handlers: { - route: (target) => { - switch (target) { - case 'chat': openCodeMode(); break - case 'write': openWriteMode(); break - case 'design': openDesignMode(); break - case 'settings': openSettings(); break - case 'plugins': openPluginsView(); break - case 'extensions': openExtensionsView(); break - case 'claw': openClaw(); break - case 'schedule': openScheduleView(); break - case 'workflow': openWorkflowView(); break - } - }, - settings: (section) => openSettings(section), - thread: (threadId) => { - void openThread(threadId) - }, - workspace: (root) => { - void selectWorkspaceRoot(root) - }, - 'shortcut-command': (commandId) => { - runWorkbenchShortcutCommand(commandId, shortcutCommandContext) - }, - 'slash-command': (_commandId, insertText) => { - const draft = input.trim() - // Never overwrite a pending draft. A trailing space marks the - // argument-taking commands (goal, research, btw), where the draft - // becomes the argument; the rest would be broken by trailing text. - const takesArgument = insertText.endsWith(' ') - if (draft && !takesArgument) { - setError(t('paletteComposerBusy')) - return - } - const focusComposer = (): void => { - window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT)) - } - setInput(draft && takesArgument ? insertText + draft : insertText) - if (route === 'chat') { - focusComposer() - } else { - void openCode() - window.setTimeout(focusComposer, 0) - } - }, - 'extension-view': (entryId) => { - const entry = extensionRightRailItems.find((candidate) => candidate.id === entryId) - if (!entry) return false - return selectRightRailExtension(entry) - }, - compose: (text) => { - const focusComposer = (): void => { - window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT)) - } - // Only offered with an empty composer, so this cannot clobber a draft. - setInput(text) - if (route === 'chat') { - focusComposer() - } else { - void openCode() - window.setTimeout(focusComposer, 0) - } - }, - 'select-model': (modelId, providerId) => { - setComposerModel(modelId, providerId) - }, - 'thread-action': (action, threadId) => { - if (action === 'archive') { - void archiveThread(threadId, true) - return - } - void pinThread(threadId, action === 'pin') - }, - unavailable: () => setError(t('paletteTargetUnavailable')) - }, - t, - tSettings, - route, - workspaceRoot: activeSkillWorkspace, - threads: codeThreads, - codeWorkspaceRoots, - runtimeReady: paletteRuntimeReady, - busy, - activeThreadId, - activeThreadArchived: threads.find((thread) => thread.id === activeThreadId)?.archived === true, - canOpenGoalPanel: paletteRuntimeReady && route !== 'claw', - canCreateNewThread: paletteRuntimeReady && route !== 'claw' && Boolean(activeSkillWorkspace), - hasPlanCommand: route !== 'claw', - hasBtwCommand: route !== 'claw', - hideBtwCommand: false, - hasReviewCommand: route !== 'claw', - skillCommands: runtimeSkills, - disabledSkillIds, - extensionRightRailItems, - shortcutBindings: keyboardShortcutBindings, - hasComposerDraft: input.trim().length > 0, - composerModel, - composerModelGroups, - activeThreadPinned: threads.find((thread) => thread.id === activeThreadId)?.pinned === true - }) - return <> - {commandPalette.open ? ( - - ) : null} + item.id === activeThreadId)?.archived === true, + canOpenGoalPanel: runtimeConnection === 'ready' && route !== 'claw', + canCreateNewThread: runtimeConnection === 'ready' && route !== 'claw' && Boolean(activeSkillWorkspace), + hasPlanCommand: route !== 'claw', hasBtwCommand: route !== 'claw', hideBtwCommand: false, + hasReviewCommand: route !== 'claw', skillCommands: runtimeSkills, disabledSkillIds, + extensionRightRailItems, composerModel, composerModelGroups, + activeThreadPinned: threads.find((item) => item.id === activeThreadId)?.pinned === true }} + shortcutContext={{ composerMode, setComposerMode, handleGuiPlanCommand, createThread, + chooseWorkspace, toggleTerminal, openSettings, useWorktreePool, setUseWorktreePool, + worktreeBranch, navigationLocked: designDrawingCreationSubmitting }} + actions={{ routes: { chat: openCodeMode, write: openWriteMode, design: openDesignMode, + settings: openSettings, plugins: openPluginsView, extensions: openExtensionsView, + claw: openClaw, schedule: openScheduleView, workflow: openWorkflowView }, + openSettings, openThread, selectWorkspaceRoot, selectExtension: selectRightRailExtension, + openCode, setInput, setError, setComposerModel, archiveThread, pinThread }} + input={input} + /> } diff --git a/src/renderer/src/components/workbench/WorkbenchCommandPaletteRuntime.tsx b/src/renderer/src/components/workbench/WorkbenchCommandPaletteRuntime.tsx new file mode 100644 index 000000000..12e038e3d --- /dev/null +++ b/src/renderer/src/components/workbench/WorkbenchCommandPaletteRuntime.tsx @@ -0,0 +1,140 @@ +import type { ReactElement } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { resolveKeyboardShortcutBindings } from '@shared/keyboard-shortcuts' +import type { AppRoute, SettingsRouteSection } from '../../store/chat-store-types' +import type { ExtensionRightRailViewEntry } from '../../extensions/contribution-registry' +import { useKeyboardShortcutSettings } from '../../lib/keyboard-shortcut-settings' +import { CommandPaletteOverlay } from '../../palette/CommandPaletteOverlay' +import type { PaletteSourcesInput } from '../../palette/palette-sources' +import { useCommandPaletteStore } from '../../palette/palette-store' +import { useWorkbenchCommandPalette } from '../../palette/useWorkbenchCommandPalette' +import { COMPOSER_FOCUS_REQUEST_EVENT, getSlashQuery } from '../chat/floating-composer-commands' +import { + runWorkbenchShortcutCommand, + useWorkbenchKeyboardShortcuts, + type WorkbenchShortcutCommandContext +} from './useWorkbenchKeyboardShortcuts' + +type MaybeAsync = void | Promise + +type PaletteSources = Omit< + PaletteSourcesInput, + 't' | 'tSettings' | 'shortcutBindings' | 'hasComposerDraft' +> + +export type WorkbenchCommandPaletteRuntimeProps = { + sources: PaletteSources + shortcutContext: WorkbenchShortcutCommandContext + actions: { + routes: Record MaybeAsync> + openSettings: (section?: SettingsRouteSection) => MaybeAsync + openThread: (threadId: string) => MaybeAsync + selectWorkspaceRoot: (root: string) => MaybeAsync + selectExtension: (entry: ExtensionRightRailViewEntry) => boolean + openCode: () => MaybeAsync + setInput: (text: string) => void + setError: (message: string | null) => void + setComposerModel: (modelId: string, providerId?: string) => void + archiveThread: (threadId: string, archived: boolean) => MaybeAsync + pinThread: (threadId: string, pinned: boolean) => MaybeAsync + } + input: string +} + +export function openWorkbenchCommandPalette(): void { + useCommandPaletteStore.getState().openPalette() +} + +function focusComposer(): void { + window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT)) +} + +export function WorkbenchCommandPaletteRuntime({ + sources, + shortcutContext, + actions, + input +}: WorkbenchCommandPaletteRuntimeProps): ReactElement { + const { t } = useTranslation('common') + const { t: tSettings } = useTranslation('settings') + const keyboardShortcuts = useKeyboardShortcutSettings() + const shortcutPlatform = typeof window === 'undefined' ? undefined : window.kunGui?.platform + const shortcutBindings = useMemo( + () => resolveKeyboardShortcutBindings(keyboardShortcuts, shortcutPlatform), + [keyboardShortcuts, shortcutPlatform] + ) + const openPalette = useCommandPaletteStore((state) => state.openPalette) + + useWorkbenchKeyboardShortcuts({ + ...shortcutContext, + slashMenuOpen: getSlashQuery(input) !== null, + openCommandPalette: openPalette, + keyboardShortcutBindings: shortcutBindings + }) + + const commandPalette = useWorkbenchCommandPalette({ + ...sources, + t, + tSettings, + shortcutBindings, + hasComposerDraft: input.trim().length > 0, + handlers: { + route: (target) => { void actions.routes[target]() }, + settings: (section) => { void actions.openSettings(section) }, + thread: (threadId) => { void actions.openThread(threadId) }, + workspace: (root) => { void actions.selectWorkspaceRoot(root) }, + 'shortcut-command': (commandId) => { + runWorkbenchShortcutCommand(commandId, shortcutContext) + }, + 'slash-command': (_commandId, insertText) => { + const draft = input.trim() + const takesArgument = insertText.endsWith(' ') + if (draft && !takesArgument) { + actions.setError(t('paletteComposerBusy')) + return + } + actions.setInput(draft && takesArgument ? insertText + draft : insertText) + if (sources.route === 'chat') focusComposer() + else { + void actions.openCode() + window.setTimeout(focusComposer, 0) + } + }, + 'extension-view': (entryId) => { + const entry = sources.extensionRightRailItems.find((candidate) => candidate.id === entryId) + return entry ? actions.selectExtension(entry) : false + }, + compose: (text) => { + actions.setInput(text) + if (sources.route === 'chat') focusComposer() + else { + void actions.openCode() + window.setTimeout(focusComposer, 0) + } + }, + 'select-model': actions.setComposerModel, + 'thread-action': (action, threadId) => { + if (action === 'archive') void actions.archiveThread(threadId, true) + else void actions.pinThread(threadId, action === 'pin') + }, + unavailable: () => actions.setError(t('paletteTargetUnavailable')) + } + }) + + return commandPalette.open ? ( + + ) : <> +} diff --git a/src/renderer/src/store/chat-store-navigation-workspace-actions.ts b/src/renderer/src/store/chat-store-navigation-workspace-actions.ts index 4dfd310a2..9678e2bae 100644 --- a/src/renderer/src/store/chat-store-navigation-workspace-actions.ts +++ b/src/renderer/src/store/chat-store-navigation-workspace-actions.ts @@ -147,11 +147,7 @@ type StoreActionContext = { sseAbortRef: SseAbortRef } -let bootPromise: Promise | null = null let refreshThreadsGeneration = 0 -let clawChannelActivityUnsubscribe: (() => void) | null = null -let runtimeStatusUnsubscribe: (() => void) | null = null -let trayActionUnsubscribe: (() => void) | null = null export function createNavigationWorkspaceActions( { set, get, sseAbortRef }: StoreActionContext From e2bc2b6d3770aef4419a857ab27f4a5b59bf434b Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 18:21:19 +0800 Subject: [PATCH 12/13] refactor(runtime): split session usage compaction --- kun/src/adapters/file/file-session-store.ts | 78 ++++++------------- .../file/file-session-usage-compaction.ts | 51 ++++++++++++ 2 files changed, 74 insertions(+), 55 deletions(-) create mode 100644 kun/src/adapters/file/file-session-usage-compaction.ts diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts index 845a0ffea..3f5526bdb 100644 --- a/kun/src/adapters/file/file-session-store.ts +++ b/kun/src/adapters/file/file-session-store.ts @@ -16,7 +16,6 @@ import type { TurnItem } from '../../contracts/items.js' import { assertSafeThreadId, isSafeThreadId } from '../../contracts/thread-id.js' import type { AgentSession } from '../../domain/session.js' import { - compactUsageEventsJsonlFile, parseReplayEventRecord, readItemPageFromJsonl, readLatestItemsFromJsonl, @@ -30,6 +29,10 @@ import { } from '../../services/item-history-page.js' import { SessionCompactionScheduler } from './session-compaction-scheduler.js' import { searchItemTextFile } from './file-session-text-search.js' +import { + compactUsageEventsIfLarge, + sessionDirectoryExists +} from './file-session-usage-compaction.js' export { readLatestItemsFromJsonl } from './file-session-jsonl.js' @@ -144,7 +147,7 @@ export class FileSessionStore implements SessionStore { async appendEvent(threadId: string, event: RuntimeEvent): Promise { assertSafeThreadId(threadId) await this.withThreadWrite(threadId, async () => { - await this.ensureDir(this.threadDir(threadId)) + await mkdir(this.threadDir(threadId), { recursive: true, mode: 0o700 }) const path = this.eventsPath(threadId) await appendFile(path, `${JSON.stringify(event)}\n`, { encoding: 'utf-8', mode: 0o600 }) this.bumpEventHistoryRevision(threadId) @@ -159,7 +162,7 @@ export class FileSessionStore implements SessionStore { async appendItem(threadId: string, item: TurnItem): Promise { assertSafeThreadId(threadId) await this.withThreadWrite(threadId, async () => { - await this.ensureDir(this.threadDir(threadId)) + await mkdir(this.threadDir(threadId), { recursive: true, mode: 0o700 }) const path = this.messagesPath(threadId) await appendFile(path, `${JSON.stringify(item)}\n`, { encoding: 'utf-8', mode: 0o600 }) this.bumpItemsVersion(threadId) @@ -171,9 +174,9 @@ export class FileSessionStore implements SessionStore { async rewriteItems(threadId: string, items: TurnItem[]): Promise { assertSafeThreadId(threadId) await this.withThreadWrite(threadId, async () => { - await this.ensureDir(this.threadDir(threadId)) + await mkdir(this.threadDir(threadId), { recursive: true, mode: 0o700 }) const contents = items.map((item) => JSON.stringify(item)).join('\n') - await this.atomicWrite(this.messagesPath(threadId), contents ? `${contents}\n` : '') + await atomicWriteFile(this.messagesPath(threadId), contents ? `${contents}\n` : '') this.bumpItemsVersion(threadId) this.cacheItems(threadId, [...items]) this.bumpItemHistoryRevision(threadId) @@ -199,9 +202,9 @@ export class FileSessionStore implements SessionStore { if (revision !== expectedRevision) { return { applied: false, reason: 'conflict', revision } } - await this.ensureDir(this.threadDir(threadId)) + await mkdir(this.threadDir(threadId), { recursive: true, mode: 0o700 }) const contents = items.map((item) => JSON.stringify(item)).join('\n') - await this.atomicWrite(this.messagesPath(threadId), contents ? `${contents}\n` : '') + await atomicWriteFile(this.messagesPath(threadId), contents ? `${contents}\n` : '') this.bumpItemsVersion(threadId) this.cacheItems(threadId, [...items]) return { applied: true, revision: this.bumpItemHistoryRevision(threadId) } @@ -215,7 +218,7 @@ export class FileSessionStore implements SessionStore { const current = items.find((item) => item.id === itemId) if (!current) return null const updated = { ...current, ...patch } as TurnItem - await this.ensureDir(this.threadDir(threadId)) + await mkdir(this.threadDir(threadId), { recursive: true, mode: 0o700 }) await appendFile(this.messagesPath(threadId), `${JSON.stringify(updated)}\n`, { encoding: 'utf-8', mode: 0o600 }) this.bumpItemsVersion(threadId) this.applyItemToCache(threadId, updated) @@ -455,7 +458,7 @@ export class FileSessionStore implements SessionStore { async loadSession(threadId: string): Promise { try { - const raw = await readFile(this.sessionPath(threadId), 'utf-8') + const raw = await readFile(join(this.threadDir(threadId), 'session.json'), 'utf-8') return JSON.parse(raw) as AgentSession } catch { return null @@ -465,8 +468,8 @@ export class FileSessionStore implements SessionStore { async upsertSession(session: AgentSession): Promise { assertSafeThreadId(session.threadId) await this.withThreadWrite(session.threadId, async () => { - await this.ensureDir(this.threadDir(session.threadId)) - await this.atomicWrite(this.sessionPath(session.threadId), JSON.stringify(session)) + await mkdir(this.threadDir(session.threadId), { recursive: true, mode: 0o700 }) + await atomicWriteFile(join(this.threadDir(session.threadId), 'session.json'), JSON.stringify(session)) }) } @@ -672,58 +675,23 @@ export class FileSessionStore implements SessionStore { return join(this.threadDir(threadId), 'messages.jsonl') } - private sessionPath(threadId: string): string { - return join(this.threadDir(threadId), 'session.json') - } - - private async ensureDir(path: string): Promise { - await mkdir(path, { recursive: true, mode: 0o700 }) - } - - private async atomicWrite(path: string, contents: string): Promise { - await atomicWriteFile(path, contents) - } - private async compactUsageEventsIfLarge(threadId: string): Promise { - const path = this.eventsPath(threadId) - const info = await stat(path).catch(() => null) - if (!info || info.size <= this.usageEventCompaction.maxBytes) return - const revisionBefore = this.eventHistoryRevision(threadId) - let conflicted = false - const compacted = await compactUsageEventsJsonlFile(path, { + await compactUsageEventsIfLarge({ + path: this.eventsPath(threadId), + maxBytes: this.usageEventCompaction.maxBytes, nowIso: this.usageEventCompaction.nowIso(), retentionDays: this.usageEventCompaction.retentionDays, maxRecordBytes: DEFAULT_EVENT_REPLAY_MAX_RECORD_BYTES, - commitReplacement: (replace) => this.withThreadWrite(threadId, async () => { - const currentInfo = await stat(path).catch(() => null) - if ( - this.eventHistoryRevision(threadId) !== revisionBefore || - !currentInfo || - currentInfo.size !== info.size || - currentInfo.mtimeMs !== info.mtimeMs - ) { - conflicted = true - return false - } - await replace() - this.bumpEventHistoryRevision(threadId) - return true - }) + readRevision: () => this.eventHistoryRevision(threadId), + bumpRevision: () => this.bumpEventHistoryRevision(threadId), + withWrite: (operation) => this.withThreadWrite(threadId, operation), + scheduleRetry: () => this.scheduleUsageEventCompaction(threadId), + invalidateCache: () => this.highestSeqCache.delete(threadId) }) - if (conflicted) this.scheduleUsageEventCompaction(threadId) - if (!compacted) return - // Size/mtime changed; drop the stale high-water cache entry so the next - // highestSeq() rescans against the rewritten file. - this.highestSeqCache.delete(threadId) } /** Used by the loop during shutdown to verify the file actually exists. */ async exists(threadId: string): Promise { - try { - await stat(this.threadDir(threadId)) - return true - } catch { - return false - } + return sessionDirectoryExists(this.threadDir(threadId)) } } diff --git a/kun/src/adapters/file/file-session-usage-compaction.ts b/kun/src/adapters/file/file-session-usage-compaction.ts new file mode 100644 index 000000000..9732eadf0 --- /dev/null +++ b/kun/src/adapters/file/file-session-usage-compaction.ts @@ -0,0 +1,51 @@ +import { stat } from 'node:fs/promises' +import { compactUsageEventsJsonlFile } from './file-session-jsonl.js' + +export async function compactUsageEventsIfLarge(options: { + path: string + maxBytes: number + nowIso: string + retentionDays: number + maxRecordBytes: number + readRevision: () => number + bumpRevision: () => void + withWrite: (operation: () => Promise) => Promise + scheduleRetry: () => void + invalidateCache: () => void +}): Promise { + const info = await stat(options.path).catch(() => null) + if (!info || info.size <= options.maxBytes) return + const revisionBefore = options.readRevision() + let conflicted = false + const compacted = await compactUsageEventsJsonlFile(options.path, { + nowIso: options.nowIso, + retentionDays: options.retentionDays, + maxRecordBytes: options.maxRecordBytes, + commitReplacement: (replace) => options.withWrite(async () => { + const currentInfo = await stat(options.path).catch(() => null) + if ( + options.readRevision() !== revisionBefore || + !currentInfo || + currentInfo.size !== info.size || + currentInfo.mtimeMs !== info.mtimeMs + ) { + conflicted = true + return false + } + await replace() + options.bumpRevision() + return true + }) + }) + if (conflicted) options.scheduleRetry() + if (compacted) options.invalidateCache() +} + +export async function sessionDirectoryExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} From 78604b807c57fee28bbe99f4fa592a55c65d3de9 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sun, 16 Aug 2026 16:46:40 +0800 Subject: [PATCH 13/13] docs(release): add v0.3.5 release notes --- release/release-v0.3.5.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/release/release-v0.3.5.md b/release/release-v0.3.5.md index 82e85f4b5..023c80a29 100644 --- a/release/release-v0.3.5.md +++ b/release/release-v0.3.5.md @@ -1,6 +1,17 @@ # Kun v0.3.5 -v0.3.5 是一个以数据安全、Windows 兼容和更新体验为重点的稳定性版本。它修复长会话压缩与写入竞争、进程 PID 被系统复用后的陈旧锁误判、SQLite 会话列表回退扫描,以及 Windows 自动更新后桌面快捷方式消失等问题。 +v0.3.5 加入全局命令面板,并集中改善数据安全、Windows 兼容、更新体验和多语言设置。它修复长会话压缩与写入竞争、进程 PID 被系统复用后的陈旧锁误判、SQLite 会话列表回退扫描,以及 Windows 自动更新后桌面快捷方式消失等问题。 + +### 全局命令面板(#1181) + +- 在 Workbench 中按 `Ctrl+K`(macOS 为 `Command+K`)即可快速搜索并打开设置、模型、会话、项目和扩展等常用入口。 +- 会话搜索支持匹配历史中的用户和助手正文;深层文件扫描有严格的时间预算,超时会安全返回已有结果,不会让旧查询持续占用 Runtime。 +- 命令面板只提供导航和安全操作,不会直接执行删除等破坏性动作。 + +### Provider 中转站多语言(#1197) + +- `Settings > Providers > Advanced local gateway` 页面现在会跟随应用语言,不再出现顶部为英文、内容为中文的混合界面。 +- 网关 API、模型与目标、容错策略、验证监控和网络代理等文案已覆盖 Kun 支持的全部语言。 ### 长会话持久化与压缩(#1185)