From 20ed49aa76a9f47060c2245184606444cb35461d Mon Sep 17 00:00:00 2001
From: musnows
Date: Sun, 14 Jun 2026 07:07:52 +0800
Subject: [PATCH 1/9] fix(chat): improve agent code block contrast
---
.../components/chat/StreamdownCode.test.ts | 1 +
.../src/components/chat/StreamdownCode.tsx | 2 +-
src/renderer/src/styles/base-shell.css | 5 ++++
src/renderer/src/styles/markdown-code.css | 25 ++++++++++++++-----
4 files changed, 26 insertions(+), 7 deletions(-)
diff --git a/src/renderer/src/components/chat/StreamdownCode.test.ts b/src/renderer/src/components/chat/StreamdownCode.test.ts
index 8cc1be264..16d4ae1be 100644
--- a/src/renderer/src/components/chat/StreamdownCode.test.ts
+++ b/src/renderer/src/components/chat/StreamdownCode.test.ts
@@ -14,6 +14,7 @@ describe('StreamdownCode plain text fences', () => {
)
expect(html).toContain('ds-plain-text-block')
+ expect(html).toContain('ds-plain-code-block')
expect(html).toContain('refactor(chat): simplify composer')
expect(html).toContain('- Keep only Stop')
expect(html).not.toContain('ds-code-block-header')
diff --git a/src/renderer/src/components/chat/StreamdownCode.tsx b/src/renderer/src/components/chat/StreamdownCode.tsx
index bfb4b54d9..e1a0dc80c 100644
--- a/src/renderer/src/components/chat/StreamdownCode.tsx
+++ b/src/renderer/src/components/chat/StreamdownCode.tsx
@@ -100,7 +100,7 @@ function PlainTextBlock({ code }: { code: string }): ReactNode {
if (!trimmedCode.trim()) return null
return (
-
+
{trimmedCode}
)
diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css
index 38264870d..53988db69 100644
--- a/src/renderer/src/styles/base-shell.css
+++ b/src/renderer/src/styles/base-shell.css
@@ -84,6 +84,7 @@
--ds-inline-code-bg: rgba(240, 245, 251, 0.92);
--ds-inline-code-hover-bg: rgba(228, 238, 250, 0.98);
--ds-pre-bg: rgba(245, 248, 253, 0.96);
+ --ds-code-block-bg: #edf3fb;
--ds-table-head-bg: rgba(242, 246, 252, 0.96);
--ds-scrollbar-thumb: rgba(84, 103, 140, 0.24);
--ds-scrollbar-thumb-hover: rgba(84, 103, 140, 0.34);
@@ -192,6 +193,7 @@
--ds-inline-code-bg: rgba(151, 192, 235, 0.09);
--ds-inline-code-hover-bg: rgba(111, 176, 232, 0.2);
--ds-pre-bg: #151c2e;
+ --ds-code-block-bg: #101827;
--ds-table-head-bg: rgba(27, 35, 56, 0.94);
--ds-scrollbar-thumb: rgba(151, 178, 215, 0.28);
--ds-scrollbar-thumb-hover: rgba(171, 198, 233, 0.38);
@@ -282,6 +284,7 @@
--ds-inline-code-bg: rgba(249, 242, 226, 0.92);
--ds-inline-code-hover-bg: rgba(247, 233, 203, 0.98);
--ds-pre-bg: rgba(252, 247, 235, 0.96);
+ --ds-code-block-bg: #f6ecd7;
--ds-table-head-bg: rgba(250, 244, 229, 0.96);
--ds-scrollbar-thumb: rgba(117, 96, 63, 0.24);
--ds-scrollbar-thumb-hover: rgba(117, 96, 63, 0.34);
@@ -372,6 +375,7 @@
--ds-inline-code-bg: rgba(238, 198, 128, 0.09);
--ds-inline-code-hover-bg: rgba(242, 177, 61, 0.2);
--ds-pre-bg: #1e1812;
+ --ds-code-block-bg: #15110d;
--ds-table-head-bg: rgba(39, 31, 23, 0.94);
--ds-scrollbar-thumb: rgba(214, 188, 142, 0.28);
--ds-scrollbar-thumb-hover: rgba(233, 208, 162, 0.38);
@@ -681,6 +685,7 @@ html {
--ds-inline-code-bg: rgba(151, 192, 235, 0.075);
--ds-inline-code-hover-bg: rgba(111, 176, 232, 0.18);
--ds-pre-bg: #151c2e;
+ --ds-code-block-bg: #101827;
--ds-table-head-bg: rgba(27, 35, 56, 0.94);
--ds-scrollbar-thumb: rgba(170, 170, 170, 0.26);
--ds-scrollbar-thumb-hover: rgba(200, 200, 200, 0.36);
diff --git a/src/renderer/src/styles/markdown-code.css b/src/renderer/src/styles/markdown-code.css
index f8dc3ae0e..cdf661709 100644
--- a/src/renderer/src/styles/markdown-code.css
+++ b/src/renderer/src/styles/markdown-code.css
@@ -143,11 +143,12 @@
max-width: 100%;
width: 100%;
min-width: 0;
- border: 1px solid var(--ds-border);
+ border: 1px solid color-mix(in srgb, var(--ds-border-strong) 72%, var(--ds-border));
border-radius: 14px;
- background: var(--ds-pre-bg);
+ background: var(--ds-code-block-bg, var(--ds-pre-bg));
padding: 0.6rem 0.72rem;
box-sizing: border-box;
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ds-bg-canvas) 62%, transparent);
}
.ds-markdown pre code {
@@ -221,9 +222,10 @@
width: 100%;
min-width: 0;
overflow: hidden;
- border: 1px solid var(--ds-border);
+ border: 1px solid color-mix(in srgb, var(--ds-border-strong) 72%, var(--ds-border));
border-radius: 14px;
- background: var(--ds-pre-bg);
+ background: var(--ds-code-block-bg, var(--ds-pre-bg));
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ds-bg-canvas) 62%, transparent);
box-sizing: border-box;
}
@@ -236,13 +238,24 @@
font: inherit;
}
+.ds-markdown .ds-plain-code-block {
+ border: 1px solid color-mix(in srgb, var(--ds-border-strong) 72%, var(--ds-border));
+ border-radius: 14px;
+ background: var(--ds-code-block-bg, var(--ds-pre-bg));
+ padding: 0.64rem 0.72rem;
+ font-family: 'SF Mono', 'JetBrains Mono', 'IBM Plex Mono', monospace;
+ font-size: 12px;
+ line-height: 1.55;
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ds-bg-canvas) 62%, transparent);
+}
+
.ds-markdown .ds-code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--ds-border-muted);
- background: color-mix(in srgb, var(--ds-code-bg) 88%, var(--ds-card-muted));
+ background: color-mix(in srgb, var(--ds-code-block-bg, var(--ds-code-bg)) 88%, var(--ds-card-muted));
padding: 0.42rem 0.6rem;
}
@@ -291,7 +304,7 @@
.ds-markdown .ds-code-block-body {
position: relative;
- background: var(--ds-pre-bg);
+ background: var(--ds-code-block-bg, var(--ds-pre-bg));
}
.ds-markdown .ds-code-block-body.is-collapsed {
From cf372a1dcb719c5e7848cc664e0bc9683201bc90 Mon Sep 17 00:00:00 2001
From: musnows
Date: Sat, 13 Jun 2026 20:34:02 +0800
Subject: [PATCH 2/9] fix(chat): isolate model selection per thread
---
.../src/store/chat-store-app-actions.test.ts | 116 ++++++++++++++++++
.../src/store/chat-store-app-actions.ts | 50 ++++++--
.../src/store/chat-store-helpers.test.ts | 44 +++++++
src/renderer/src/store/chat-store-helpers.ts | 109 +++++++++++++++-
.../src/store/chat-store-thread-actions.ts | 47 ++++++-
5 files changed, 351 insertions(+), 15 deletions(-)
diff --git a/src/renderer/src/store/chat-store-app-actions.test.ts b/src/renderer/src/store/chat-store-app-actions.test.ts
index e94157a44..3e9215159 100644
--- a/src/renderer/src/store/chat-store-app-actions.test.ts
+++ b/src/renderer/src/store/chat-store-app-actions.test.ts
@@ -11,6 +11,7 @@ import { createAppActions } from './chat-store-app-actions'
const COMPOSER_MODEL_STORAGE_KEY = 'kun.composerModel'
const COMPOSER_PROVIDER_STORAGE_KEY = 'kun.composerProviderId'
+const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1'
function createMemoryStorage(): Storage {
const items = new Map()
@@ -39,6 +40,8 @@ function buildHarness(fetchModelsResult: FetchModelsResult): {
state: ChatState
} {
let state = {
+ activeThreadId: null,
+ threads: [],
composerModel: '',
composerProviderId: '',
composerPickList: mergeComposerPickList(false, []),
@@ -138,6 +141,119 @@ describe('chat-store app actions composer model loading', () => {
})
})
+ it('keeps active-thread model changes out of the global Kun default', () => {
+ const { actions, state } = buildHarness({
+ ok: true,
+ modelIds: ['MiniMax-M2'],
+ defaultModelId: 'deepseek-v4-pro',
+ modelGroups: [{
+ providerId: 'minimax',
+ label: 'MiniMax',
+ modelIds: ['MiniMax-M2']
+ }]
+ })
+ state.activeThreadId = 'thread-a'
+ state.threads = [{
+ id: 'thread-a',
+ title: 'Thread A',
+ workspace: '/tmp/project',
+ model: 'deepseek-v4-pro',
+ status: 'idle',
+ mode: 'agent',
+ updatedAt: '2026-06-01T00:00:00.000Z'
+ }]
+ state.composerModelGroups = [{
+ providerId: 'minimax',
+ label: 'MiniMax',
+ modelIds: ['MiniMax-M2']
+ }]
+
+ actions.setComposerModel('MiniMax-M2', 'minimax')
+
+ expect(state.composerModel).toBe('MiniMax-M2')
+ expect(state.composerProviderId).toBe('minimax')
+ expect(localStorage.getItem(COMPOSER_MODEL_STORAGE_KEY)).toBeNull()
+ expect(localStorage.getItem(COMPOSER_PROVIDER_STORAGE_KEY)).toBeNull()
+ expect(JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}')).toEqual({
+ 'thread-a': { model: 'MiniMax-M2', providerId: 'minimax' }
+ })
+ expect(window.kunGui.saveSettingsSilent).not.toHaveBeenCalled()
+ })
+
+ it('restores a model selection from the active thread instead of the global picker', async () => {
+ localStorage.setItem(COMPOSER_MODEL_STORAGE_KEY, 'deepseek-v4-flash')
+ localStorage.setItem(
+ THREAD_COMPOSER_SELECTION_STORAGE_KEY,
+ JSON.stringify({ 'thread-a': { model: 'MiniMax-M2', providerId: 'minimax' } })
+ )
+ const { actions, state } = buildHarness({
+ ok: true,
+ modelIds: ['MiniMax-M2'],
+ defaultModelId: 'deepseek-v4-pro',
+ modelGroups: [{
+ providerId: 'minimax',
+ label: 'MiniMax',
+ modelIds: ['MiniMax-M2']
+ }]
+ })
+ state.activeThreadId = 'thread-a'
+ state.threads = [{
+ id: 'thread-a',
+ title: 'Thread A',
+ workspace: '/tmp/project',
+ model: 'deepseek-v4-pro',
+ status: 'idle',
+ mode: 'agent',
+ updatedAt: '2026-06-01T00:00:00.000Z'
+ }]
+
+ await actions.loadComposerModels()
+
+ expect(state.composerModel).toBe('MiniMax-M2')
+ expect(state.composerProviderId).toBe('minimax')
+ expect(localStorage.getItem(COMPOSER_MODEL_STORAGE_KEY)).toBe('deepseek-v4-flash')
+ })
+
+ it('does not restore a per-thread selection filtered out of the composer menu', async () => {
+ localStorage.setItem(
+ THREAD_COMPOSER_SELECTION_STORAGE_KEY,
+ JSON.stringify({ 'thread-a': { model: 'Kwai-Kolors/Kolors', providerId: 'minimax' } })
+ )
+ const { actions, state } = buildHarness({
+ ok: true,
+ modelIds: ['Kwai-Kolors/Kolors'],
+ defaultModelId: 'deepseek-v4-pro',
+ modelGroups: [{
+ providerId: 'minimax',
+ label: 'MiniMax',
+ modelIds: ['Kwai-Kolors/Kolors'],
+ modelProfiles: {
+ 'kwai-kolors/kolors': {
+ inputModalities: ['text'],
+ outputModalities: ['image'],
+ supportsToolCalling: false,
+ messageParts: ['text']
+ }
+ }
+ }]
+ })
+ state.activeThreadId = 'thread-a'
+ state.threads = [{
+ id: 'thread-a',
+ title: 'Thread A',
+ workspace: '/tmp/project',
+ model: 'deepseek-v4-pro',
+ status: 'idle',
+ mode: 'agent',
+ updatedAt: '2026-06-01T00:00:00.000Z'
+ }]
+
+ await actions.loadComposerModels()
+
+ expect(state.composerModel).toBe('deepseek-v4-pro')
+ expect(state.composerProviderId).toBe('')
+ })
+
it('does not overwrite a stored custom model when only fallback models are available', async () => {
localStorage.setItem(COMPOSER_MODEL_STORAGE_KEY, 'MiniMax-M2')
const { actions, state } = buildHarness({
diff --git a/src/renderer/src/store/chat-store-app-actions.ts b/src/renderer/src/store/chat-store-app-actions.ts
index 726f26565..5751a61fb 100644
--- a/src/renderer/src/store/chat-store-app-actions.ts
+++ b/src/renderer/src/store/chat-store-app-actions.ts
@@ -3,8 +3,12 @@ import type { AppSettingsV1 } from '@shared/app-settings'
import { rendererRuntimeClient } from '../agent/runtime-client'
import type { ChatState, ChatStoreGet, ChatStoreSet, InitialSetupMode, PluginHostRoute, SettingsRouteSection } from './chat-store-types'
import {
+ composerModelSelectable,
persistComposerProviderId,
providerIdForComposerModel,
+ providerIdMatchesComposerModel,
+ readThreadComposerSelection,
+ rememberThreadComposerSelection,
readStoredComposerProviderId
} from './chat-store-helpers'
@@ -63,12 +67,17 @@ export function createAppActions(options: CreateAppActionsOptions): Pick<
setError: (message) => set({ error: message }),
setComposerModel: (modelId, providerId) => {
- persistComposerModel(modelId)
const nextProviderId = providerId?.trim() || providerIdForComposerModel(get().composerModelGroups, modelId)
- persistComposerProviderId(nextProviderId)
+ const activeThreadId = get().activeThreadId
+ if (activeThreadId) {
+ rememberThreadComposerSelection(activeThreadId, modelId, nextProviderId)
+ } else {
+ persistComposerModel(modelId)
+ persistComposerProviderId(nextProviderId)
+ }
set({ composerModel: modelId, composerProviderId: nextProviderId })
const trimmed = modelId.trim()
- if (trimmed && trimmed.toLowerCase() !== 'auto' && typeof window.kunGui !== 'undefined') {
+ if (!activeThreadId && trimmed && trimmed.toLowerCase() !== 'auto' && typeof window.kunGui !== 'undefined') {
void window.kunGui.saveSettingsSilent({ agents: { kun: { model: trimmed } } })
}
},
@@ -80,26 +89,43 @@ export function createAppActions(options: CreateAppActionsOptions): Pick<
const res = await window.kunGui.fetchUpstreamModels()
const pick = mergeComposerPickList(res.ok, res.ok ? res.modelIds : [])
const groups = res.ok ? res.modelGroups ?? [] : []
- const allowed = new Set(pick)
const runtimeDefault = res.ok ? res.defaultModelId?.trim() ?? '' : ''
set((state) => {
+ const isSelectable = (model: string): boolean => composerModelSelectable(pick, groups, model)
+ const activeThread = state.activeThreadId
+ ? state.threads.find((thread) => thread.id === state.activeThreadId) ?? null
+ : null
+ const threadSelection = activeThread ? readThreadComposerSelection(activeThread.id) : null
const currentModel = state.composerModel.trim()
const normalizedCurrentModel = currentModel.toLowerCase() === 'auto' ? '' : currentModel
const storedModel = readStoredComposerModel(pick)
- let model = normalizedCurrentModel
- let shouldPersist = model !== state.composerModel
- if (model === '' || !allowed.has(model)) {
- model = storedModel
+ let model = activeThread
+ ? threadSelection?.model?.trim() || activeThread.model.trim()
+ : normalizedCurrentModel
+ let shouldPersist = !activeThread && model !== state.composerModel
+ if (model === '' || !isSelectable(model)) {
+ model = activeThread ? '' : storedModel
shouldPersist = false
}
- if (model === '' || !allowed.has(model)) {
+ if (model === '' || !isSelectable(model)) {
model = fallbackComposerModel(pick, runtimeDefault)
shouldPersist = false
}
if (shouldPersist) persistComposerModel(model)
- const storedProviderId = readStoredComposerProviderId(groups, model)
- const providerId = storedProviderId || providerIdForComposerModel(groups, model)
- if (providerId !== state.composerProviderId) persistComposerProviderId(providerId)
+ const threadProviderId =
+ threadSelection && providerIdMatchesComposerModel(groups, threadSelection.providerId, model)
+ ? threadSelection.providerId
+ : ''
+ const storedProviderId = activeThread ? '' : readStoredComposerProviderId(groups, model)
+ const providerId = threadProviderId || storedProviderId || providerIdForComposerModel(groups, model)
+ if (!activeThread && providerId !== state.composerProviderId) persistComposerProviderId(providerId)
+ if (
+ activeThread &&
+ (!threadSelection || threadSelection.model !== model || threadSelection.providerId !== providerId) &&
+ composerModelSelectable(pick, groups, model)
+ ) {
+ rememberThreadComposerSelection(activeThread.id, model, providerId)
+ }
return {
composerPickList: pick,
composerModel: model,
diff --git a/src/renderer/src/store/chat-store-helpers.test.ts b/src/renderer/src/store/chat-store-helpers.test.ts
index c7fa68bde..6d8207fce 100644
--- a/src/renderer/src/store/chat-store-helpers.test.ts
+++ b/src/renderer/src/store/chat-store-helpers.test.ts
@@ -3,6 +3,7 @@ import type { ClawImChannelV1 } from '@shared/app-settings'
import { CLAW_MANAGED_INSTRUCTIONS_HEADING } from '@shared/app-settings'
import {
MAX_TURN_MODEL_LABELS,
+ MAX_THREAD_COMPOSER_SELECTIONS,
MAX_CODE_WORKSPACE_ROOTS,
clawThreadIdsFromChannels,
clawThreadTitleLooksManaged,
@@ -12,12 +13,16 @@ import {
isClawThread,
mergeComposerPickList,
newClawChannel,
+ normalizeThreadComposerSelectionMap,
normalizeTurnModelMap,
+ readThreadComposerSelection,
reconcileCodeWorkspaceRoots,
+ rememberThreadComposerSelection,
rememberTurnModel
} from './chat-store-helpers'
const TURN_MODEL_STORAGE_KEY = 'kun.turnModelLabel'
+const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1'
function createMemoryStorage(): Storage {
const items = new Map()
@@ -247,4 +252,43 @@ describe('chat-store Claw helpers', () => {
{ kind: 'assistant', id: 'assistant-1', text: 'hi' }
])
})
+
+ it('normalizes and caps per-thread composer selections', () => {
+ const raw: Record = {
+ 'bad-empty-model': { model: '' },
+ 'bad-number': 42
+ }
+ for (let index = 0; index < MAX_THREAD_COMPOSER_SELECTIONS + 5; index += 1) {
+ raw[`thread-${index}`] = {
+ model: ` model-${index} `,
+ providerId: ` provider-${index} `
+ }
+ }
+
+ const normalized = normalizeThreadComposerSelectionMap(raw)
+
+ expect(Object.keys(normalized)).toHaveLength(MAX_THREAD_COMPOSER_SELECTIONS)
+ expect(normalized['thread-0']).toBeUndefined()
+ expect(normalized['thread-5']).toEqual({ model: 'model-5', providerId: 'provider-5' })
+ expect(normalized['bad-empty-model']).toBeUndefined()
+ expect(normalized['bad-number']).toBeUndefined()
+ })
+
+ it('persists composer model selections independently per thread', () => {
+ rememberThreadComposerSelection(' thread-a ', ' deepseek-v4-pro ', ' deepseek ')
+ rememberThreadComposerSelection(' thread-b ', ' MiniMax-M2 ', ' minimax ')
+
+ expect(readThreadComposerSelection('thread-a')).toEqual({
+ model: 'deepseek-v4-pro',
+ providerId: 'deepseek'
+ })
+ expect(readThreadComposerSelection('thread-b')).toEqual({
+ model: 'MiniMax-M2',
+ providerId: 'minimax'
+ })
+ expect(JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}')).toMatchObject({
+ 'thread-a': { model: 'deepseek-v4-pro', providerId: 'deepseek' },
+ 'thread-b': { model: 'MiniMax-M2', providerId: 'minimax' }
+ })
+ })
})
diff --git a/src/renderer/src/store/chat-store-helpers.ts b/src/renderer/src/store/chat-store-helpers.ts
index b93937311..05cca2076 100644
--- a/src/renderer/src/store/chat-store-helpers.ts
+++ b/src/renderer/src/store/chat-store-helpers.ts
@@ -4,6 +4,8 @@ import type { ModelProviderModelGroup } from '@shared/kun-gui-api'
import {
CLAW_MANAGED_INSTRUCTIONS_HEADING,
CLAW_MODEL_IDS,
+ isComposerChatModelId,
+ modelProfileSupportsTextChat,
type ClawImAgentProfileV1,
type ClawImChannelV1,
type ClawImPlatformCredentialV1,
@@ -21,11 +23,18 @@ import { readBrowserStorageItem, writeBrowserStorageItem } from '../lib/browser-
const COMPOSER_MODEL_STORAGE_KEY = 'kun.composerModel'
const COMPOSER_PROVIDER_STORAGE_KEY = 'kun.composerProviderId'
+const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1'
const TURN_MODEL_STORAGE_KEY = 'kun.turnModelLabel'
const CODE_WORKSPACE_ROOTS_STORAGE_KEY = 'kun.codeWorkspaceRoots.v1'
export const MAX_CODE_WORKSPACE_ROOTS = 30
+export const MAX_THREAD_COMPOSER_SELECTIONS = 500
export const MAX_TURN_MODEL_LABELS = 500
+export type ThreadComposerSelection = {
+ model: string
+ providerId: string
+}
+
export const CLAW_COMPOSER_MODEL_IDS = [...CLAW_MODEL_IDS]
export function readStoredComposerModel(allowedIds: readonly string[]): string {
@@ -63,6 +72,44 @@ export function persistComposerProviderId(providerId: string): void {
}
}
+export function readThreadComposerSelection(threadId: string): ThreadComposerSelection | null {
+ const thread = threadId.trim()
+ if (!thread) return null
+ return loadThreadComposerSelectionMap()[thread] ?? null
+}
+
+export function rememberThreadComposerSelection(
+ threadId: string,
+ model: string,
+ providerId = ''
+): void {
+ const thread = threadId.trim()
+ const nextModel = model.trim()
+ if (!thread || !nextModel) return
+ const map = loadThreadComposerSelectionMap()
+ delete map[thread]
+ map[thread] = {
+ model: nextModel,
+ providerId: providerId.trim()
+ }
+ saveThreadComposerSelectionMap(map)
+}
+
+export function normalizeThreadComposerSelectionMap(raw: unknown): Record {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
+ const entries: Array<[string, ThreadComposerSelection]> = []
+ for (const [rawKey, rawValue] of Object.entries(raw as Record)) {
+ const key = rawKey.trim()
+ if (!key || !rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) continue
+ const value = rawValue as Record
+ const model = typeof value.model === 'string' ? value.model.trim() : ''
+ const providerId = typeof value.providerId === 'string' ? value.providerId.trim() : ''
+ if (!model) continue
+ entries.push([key, { model, providerId }])
+ }
+ return Object.fromEntries(entries.slice(-MAX_THREAD_COMPOSER_SELECTIONS))
+}
+
export function providerIdForComposerModel(
modelGroups: readonly ModelProviderModelGroup[],
modelId: string
@@ -75,7 +122,50 @@ export function providerIdForComposerModel(
function modelGroupHasModel(group: ModelProviderModelGroup, modelId: string): boolean {
const normalized = normalizeComposerModelId(modelId)
if (!normalized) return false
- return group.modelIds.some((id) => normalizeComposerModelId(id) === normalized)
+ return group.modelIds.some((id) => normalizeComposerModelId(id) === normalized) ||
+ Boolean(modelProfileForComposerModel(group, modelId)?.aliases?.some(
+ (alias: string) => normalizeComposerModelId(alias) === normalized
+ ))
+}
+
+export function composerModelAllowed(pickList: readonly string[], modelId: string): boolean {
+ const normalized = normalizeComposerModelId(modelId)
+ if (!normalized) return false
+ return pickList.some((id) => normalizeComposerModelId(id) === normalized)
+}
+
+export function composerModelSelectable(
+ pickList: readonly string[],
+ modelGroups: readonly ModelProviderModelGroup[],
+ modelId: string
+): boolean {
+ if (!composerModelAllowed(pickList, modelId)) return false
+ if (!isComposerChatModelId(modelId)) return false
+ const group = modelGroups.find((item) => modelGroupHasModel(item, modelId))
+ if (!group) return true
+ return modelProfileSupportsTextChat(modelProfileForComposerModel(group, modelId))
+}
+
+export function providerIdMatchesComposerModel(
+ modelGroups: readonly ModelProviderModelGroup[],
+ providerId: string,
+ modelId: string
+): boolean {
+ const provider = providerId.trim()
+ if (!provider) return false
+ const group = modelGroups.find((item) => item.providerId === provider)
+ return group ? modelGroupHasModel(group, modelId) : false
+}
+
+function modelProfileForComposerModel(
+ group: Pick,
+ modelId: string
+): NonNullable[string] | undefined {
+ const model = modelId.trim()
+ const key = normalizeComposerModelId(model)
+ if (!key) return undefined
+ const profiles = group.modelProfiles ?? {}
+ return profiles[key] ?? profiles[model]
}
function normalizeComposerModelId(modelId: string): string {
@@ -344,3 +434,20 @@ export function normalizeTurnModelMap(raw: unknown): Record {
function saveTurnModelMap(map: Record): void {
writeBrowserStorageItem(TURN_MODEL_STORAGE_KEY, JSON.stringify(normalizeTurnModelMap(map)))
}
+
+function loadThreadComposerSelectionMap(): Record {
+ try {
+ const raw = readBrowserStorageItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY)
+ if (!raw) return {}
+ return normalizeThreadComposerSelectionMap(JSON.parse(raw))
+ } catch {
+ return {}
+ }
+}
+
+function saveThreadComposerSelectionMap(map: Record): void {
+ writeBrowserStorageItem(
+ THREAD_COMPOSER_SELECTION_STORAGE_KEY,
+ JSON.stringify(normalizeThreadComposerSelectionMap(map))
+ )
+}
diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts
index 228a1f9f9..a5b1b2553 100644
--- a/src/renderer/src/store/chat-store-thread-actions.ts
+++ b/src/renderer/src/store/chat-store-thread-actions.ts
@@ -31,14 +31,18 @@ import {
import type { ChatState, ChatStoreGet, ChatStoreSet } from './chat-store-types'
import {
activeClawChannel,
+ composerModelSelectable,
compactCodeWorkspaceRoots,
forgetCodeWorkspaceRoot,
hydrateBlockModelLabels,
isClawThread,
optimisticUserModelLabel,
+ providerIdForComposerModel,
+ providerIdMatchesComposerModel,
readCodeWorkspaceRoots,
- readStoredComposerModel,
+ readThreadComposerSelection,
rememberCodeWorkspaceRoots,
+ rememberThreadComposerSelection,
rememberTurnModel
} from './chat-store-helpers'
import {
@@ -133,6 +137,31 @@ async function ensureRuntimeProviderForSend(input: {
}
}
+function composerSelectionForThread(
+ state: ChatState,
+ thread: Pick | null | undefined
+): { model: string; providerId: string } | null {
+ if (!thread) return null
+ const pickList = state.composerPickList
+ const stored = readThreadComposerSelection(thread.id)
+ const storedModel = stored?.model.trim() ?? ''
+ const threadModel = thread.model.trim()
+ const model = composerModelSelectable(pickList, state.composerModelGroups, storedModel)
+ ? storedModel
+ : composerModelSelectable(pickList, state.composerModelGroups, threadModel)
+ ? threadModel
+ : ''
+ if (!model) return null
+ const storedProviderId =
+ stored && providerIdMatchesComposerModel(state.composerModelGroups, stored.providerId, model)
+ ? stored.providerId
+ : ''
+ return {
+ model,
+ providerId: storedProviderId || providerIdForComposerModel(state.composerModelGroups, model)
+ }
+}
+
function subscribeThreadEventsWithRecovery(
provider: AgentProvider,
threadId: string,
@@ -326,6 +355,8 @@ export function createThreadActions(
const currentTurnUserId = busy
? latestUserMessageId ?? findLatestUserBlockId(blocks)
: null
+ const threadSnap = get().threads.find((thread) => thread.id === id) ?? null
+ const composerSelection = composerSelectionForThread(get(), threadSnap)
set({
watchTurnCompletion: nextWatch,
unreadThreadIds: nextUnread,
@@ -345,7 +376,13 @@ export function createThreadActions(
turnReasoningFirstAtByUserId: {},
turnReasoningLastAtByUserId: {},
inspectorSelectedId: null,
- queuedMessages: []
+ queuedMessages: [],
+ ...(composerSelection
+ ? {
+ composerModel: composerSelection.model,
+ composerProviderId: composerSelection.providerId
+ }
+ : {})
})
syncTurnCompletionPoll(set, get)
const ac = new AbortController()
@@ -568,6 +605,9 @@ export function createThreadActions(
throw new Error('Failed to resolve target thread id.')
}
activeThreadId = threadId
+ if (composerModel) {
+ rememberThreadComposerSelection(threadId, composerModel, composerProviderId)
+ }
set((s) => ({
activeThreadId: threadId,
codeWorkspaceRoots: rememberCodeWorkspaceRoots(s.codeWorkspaceRoots, [workspaceRoot, createdThread?.workspace]),
@@ -609,6 +649,9 @@ export function createThreadActions(
try {
const seqAtSend = get().lastSeq
const channel = get().route === 'claw' ? activeClawChannel(get()) : null
+ if (!channel && composerModel) {
+ rememberThreadComposerSelection(activeThreadId, composerModel, composerProviderId)
+ }
await ensureRuntimeProviderForSend({
providerId: channel ? undefined : composerProviderId,
model: composerModel,
From 4b66ee8c9281ae25485f3458ebfd6153f090e611 Mon Sep 17 00:00:00 2001
From: XingYu-Zhong <1736101137@qq.com>
Date: Tue, 16 Jun 2026 00:28:47 +0800
Subject: [PATCH 3/9] =?UTF-8?q?feat=EF=BC=9Aadd=20big=20change?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/KUN_CONFIG.md | 5 +
kun/README.md | 1 +
kun/README.zh-CN.md | 1 +
...ompat-model-client.endpoint-format.test.ts | 123 +++++++
kun/src/adapters/model/compat-model-client.ts | 18 +-
kun/src/config/kun-config.test.ts | 14 +-
kun/src/config/kun-config.ts | 12 +-
kun/src/contracts/capabilities.ts | 7 +-
kun/src/loop/model-context-profile.test.ts | 26 +-
kun/src/loop/model-context-profile.ts | 10 +-
kun/src/server/runtime-factory.ts | 5 +-
package-lock.json | 36 ++-
package.json | 1 +
src/main/claw-platform-install.test.ts | 71 ++--
src/main/claw-platform-install.ts | 88 +++--
src/main/claw-runtime-helpers.test.ts | 81 +++++
src/main/claw-runtime-helpers.ts | 64 ++++
src/main/claw-runtime.test.ts | 302 +++++++++++++++++-
src/main/claw-runtime.ts | 210 ++++++++++--
src/main/ipc/app-ipc-schemas.test.ts | 22 ++
src/main/ipc/app-ipc-schemas.ts | 28 +-
src/main/ipc/register-app-ipc-handlers.ts | 48 ++-
src/main/kun-process.test.ts | 3 +
src/main/kun-process.ts | 4 +-
.../legacy-session-import-service.test.ts | 165 ++++++++++
.../services/legacy-session-import-service.ts | 207 ++++++++++++
src/main/services/worktree-service.ts | 14 +-
.../write-inline-completion-service.test.ts | 56 ++++
.../write-inline-completion-service.ts | 66 +++-
src/main/settings-store.ts | 5 +-
src/preload/index.ts | 6 +
src/renderer/src/components/SettingsView.tsx | 15 +-
src/renderer/src/components/Workbench.tsx | 32 +-
.../chat/ContextCapacityPopover.tsx | 146 +++++++++
.../src/components/chat/FloatingComposer.tsx | 155 ++++++++-
src/renderer/src/components/chat/Sidebar.tsx | 5 +-
.../src/components/chat/WorkspaceModeTabs.tsx | 22 +-
.../src/components/provider-model-editor.ts | 8 +-
.../components/schedule/ScheduleTasksView.tsx | 12 +-
.../components/settings-section-agents.tsx | 18 ++
...settings-section-general-legacy-import.tsx | 178 +++++++++++
.../components/settings-section-general.tsx | 3 +
.../settings-section-provider-models.tsx | 248 ++++++++++----
.../components/settings-section-providers.tsx | 294 +++++++++++------
.../components/settings-section-worktree.tsx | 8 +-
.../src/components/settings-section-write.tsx | 204 ++++++++++++
.../components/write/WriteFontSizeControl.tsx | 79 +++++
.../src/components/write/WriteInlineAgent.tsx | 83 ++++-
.../components/write/WriteMarkdownEditor.tsx | 141 +++++++-
.../src/components/write/WriteSidebar.tsx | 5 +-
.../write/WriteWorkspaceDocumentPane.tsx | 6 +-
.../write/WriteWorkspaceToolbar.tsx | 34 +-
.../components/write/WriteWorkspaceView.tsx | 117 +++++--
src/renderer/src/lib/apply-theme.ts | 15 +
src/renderer/src/lib/context-capacity.test.ts | 114 +++++++
src/renderer/src/lib/context-capacity.ts | 186 +++++++++++
src/renderer/src/locales/en/common.json | 36 +++
src/renderer/src/locales/en/settings.json | 55 ++++
src/renderer/src/locales/zh/common.json | 36 +++
src/renderer/src/locales/zh/settings.json | 55 ++++
.../src/store/chat-store-app-actions.test.ts | 1 +
.../src/store/chat-store-app-actions.ts | 3 +
.../store/chat-store-navigation-actions.ts | 3 +-
src/renderer/src/store/chat-store-runtime.ts | 10 +-
src/renderer/src/store/chat-store-types.ts | 7 +
src/renderer/src/store/chat-store.ts | 4 +-
src/renderer/src/styles/write-editor.css | 109 ++++++-
src/renderer/src/styles/write-rich-editor.css | 8 +-
src/renderer/src/write/agent-presets.ts | 33 ++
.../write/inline-completion/feedback.test.ts | 14 +
.../src/write/inline-completion/feedback.ts | 12 +
src/renderer/src/write/quoted-selection.ts | 9 +-
.../write-workspace-file-actions.test.ts | 5 +
.../write/write-workspace-settings-actions.ts | 1 +
.../write/write-workspace-store-helpers.ts | 12 +-
.../src/write/write-workspace-store-types.ts | 15 +-
.../src/write/write-workspace-store.ts | 33 ++
src/shared/app-settings-kun.ts | 22 +-
src/shared/app-settings-provider.test.ts | 26 ++
src/shared/app-settings-provider.ts | 20 +-
src/shared/app-settings-types.ts | 96 +++++-
src/shared/app-settings-write.ts | 164 +++++++++-
src/shared/app-settings.test.ts | 62 +++-
src/shared/kun-gui-api.ts | 42 +++
src/shared/model-provider-presets.ts | 193 ++++++++++-
src/shared/worktree.ts | 1 +
86 files changed, 4508 insertions(+), 406 deletions(-)
create mode 100644 kun/src/adapters/model/compat-model-client.endpoint-format.test.ts
create mode 100644 src/main/claw-runtime-helpers.test.ts
create mode 100644 src/main/services/legacy-session-import-service.test.ts
create mode 100644 src/main/services/legacy-session-import-service.ts
create mode 100644 src/renderer/src/components/chat/ContextCapacityPopover.tsx
create mode 100644 src/renderer/src/components/settings-section-general-legacy-import.tsx
create mode 100644 src/renderer/src/components/write/WriteFontSizeControl.tsx
create mode 100644 src/renderer/src/lib/context-capacity.test.ts
create mode 100644 src/renderer/src/lib/context-capacity.ts
create mode 100644 src/renderer/src/write/agent-presets.ts
diff --git a/docs/KUN_CONFIG.md b/docs/KUN_CONFIG.md
index 0ad7fad44..6de0e2b5f 100644
--- a/docs/KUN_CONFIG.md
+++ b/docs/KUN_CONFIG.md
@@ -79,6 +79,11 @@ GUI 启动 Kun 时会按下面的顺序合并配置。
"summaryTimeoutMs": 15000,
"summaryMaxTokens": 1200,
"summaryInputMaxBytes": 98304
+ },
+ "runtime": {
+ "streamIdleTimeoutMs": 45000,
+ "toolStorm": { "enabled": true, "windowSize": 8, "threshold": 3 },
+ "toolArgumentRepair": { "maxStringBytes": 524288 }
}
}
```
diff --git a/kun/README.md b/kun/README.md
index 28d4c8a9e..88d0776e8 100644
--- a/kun/README.md
+++ b/kun/README.md
@@ -262,6 +262,7 @@ Feature flags are intentionally explicit:
- `serve.tokenEconomy` / `tokenEconomyMode` compresses tool descriptions, tool results, and history context while preserving code, paths, commands, URLs, errors, and other high-value signals.
- `contextCompaction` controls fallback long-thread compaction thresholds and summary behavior. Per-model thresholds live in `models.profiles`. Compaction preserves goals, constraints, decisions, touched files, tool outcomes, and unresolved next steps.
- `serve.runtimeTuning.toolStorm` suppresses repeated identical tool calls within a turn so useless tool loops do not keep spending tokens.
+- `runtime.streamIdleTimeoutMs` (top-level in `config.json`) caps the idle gap between streaming chunks before a turn fails with `stream_idle_timeout` (default `45000`). Raise it for local model servers that stay silent while prefilling a very large prompt; set `0` to disable the guard.
- `capabilities.web` exposes `web_fetch` and/or `web_search`. The built-in provider can fetch HTTP(S) pages; search requires a provider implementation and may report unavailable.
- `capabilities.skills` scans configured roots for `skill.json` manifests and, when `legacySkillMd` is true, older `SKILL.md` directories.
- `capabilities.attachments` stores image bytes outside thread logs and allows turns to reference `attachmentIds`. Vision-capable models receive image parts; text-only models receive a bounded compressed base64 text fallback.
diff --git a/kun/README.zh-CN.md b/kun/README.zh-CN.md
index d165695ad..e351b2732 100644
--- a/kun/README.zh-CN.md
+++ b/kun/README.zh-CN.md
@@ -239,6 +239,7 @@ Kun 默认使用混合存储:`threads/{threadId}/messages.jsonl` 与 `events.j
- `serve.tokenEconomy` / `tokenEconomyMode` 会压缩工具描述、工具结果和历史上下文;保留代码、路径、命令、URL、错误信号等高价值信息,同时省掉重复、超长或二进制 payload。
- `contextCompaction` 控制长会话压缩的兜底阈值和摘要方式;模型级阈值写在 `models.profiles`。压缩时保留目标、约束、决策、已触碰文件、工具结果和未解决事项。
- `serve.runtimeTuning.toolStorm` 会抑制同一回合内重复的相同工具调用,阻止无意义 tool loop 继续烧 token。
+- `runtime.streamIdleTimeoutMs`(`config.json` 顶层)限制流式分片之间的最大空闲间隔,超时会以 `stream_idle_timeout` 结束本轮(默认 `45000`)。本地模型预处理超大输入时会长时间静默,可调大此值;填 `0` 表示不限制。
- `capabilities.web` 暴露 `web_fetch` 与/或 `web_search`。内置 provider 负责 HTTP(S) 抓取;搜索功能依赖 provider 实现,未配置时会变为不可用。
- `capabilities.skills` 扫描 `roots` 下的 `skill.json`,并在 `legacySkillMd` 为 `true` 时兼容 `SKILL.md`。
- `capabilities.attachments` 将图片二进制从线程日志剥离,允许回合记录引用 `attachmentIds`。视觉模型直接接收图片部分,纯文本模型走受限文本 fallback。
diff --git a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts
new file mode 100644
index 000000000..0353ac01d
--- /dev/null
+++ b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+import { CompatModelClient } from './compat-model-client.js'
+import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js'
+import type { ModelEndpointFormat } from '../../contracts/model-endpoint-format.js'
+import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js'
+
+// A single provider (OpenCode Go) routes some models over chat completions
+// and others over Anthropic Messages. The wire format is resolved per request
+// model from its capability metadata, falling back to the provider format.
+
+type CapturedCall = { url: string; body: Record }
+
+function modelCapabilities(
+ overrides: Record
+): (model: string) => ModelCapabilityMetadata {
+ return (model) => ({
+ id: model,
+ inputModalities: ['text'],
+ outputModalities: ['text'],
+ supportsToolCalling: true,
+ messageParts: ['text'],
+ ...(overrides[model] ? { endpointFormat: overrides[model] } : {})
+ })
+}
+
+function fakeFetch(calls: CapturedCall[]): typeof fetch {
+ return (async (url: string, init: { body: string }) => {
+ const target = String(url)
+ calls.push({ url: target, body: JSON.parse(init.body) as Record })
+ const json = target.endsWith('/messages')
+ ? { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' }
+ : { choices: [{ index: 0, finish_reason: 'stop', message: { content: 'ok' } }] }
+ return new Response(JSON.stringify(json), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ })
+ }) as unknown as typeof fetch
+}
+
+function request(model: string): ModelRequest {
+ return {
+ threadId: 't1',
+ turnId: 'u1',
+ model,
+ systemPrompt: 'You are a helpful assistant.',
+ prefix: [],
+ history: [],
+ tools: [],
+ abortSignal: new AbortController().signal
+ }
+}
+
+async function drain(iterable: AsyncIterable): Promise {
+ const chunks: ModelStreamChunk[] = []
+ for await (const chunk of iterable) chunks.push(chunk)
+ return chunks
+}
+
+describe('CompatModelClient per-model endpointFormat', () => {
+ it('routes an override model to the Anthropic Messages endpoint while others use chat completions', async () => {
+ const calls: CapturedCall[] = []
+ const client = new CompatModelClient({
+ baseUrl: 'https://opencode.ai/zen/go/v1',
+ apiKey: 'sk-test',
+ model: 'glm-5.1',
+ endpointFormat: 'chat_completions',
+ nonStreaming: true,
+ fetchImpl: fakeFetch(calls),
+ modelCapabilities: modelCapabilities({ 'minimax-m3': 'messages' })
+ })
+
+ const messagesChunks = await drain(client.stream(request('minimax-m3')))
+ const chatChunks = await drain(client.stream(request('glm-5.1')))
+
+ // The override model hits /messages with the Anthropic body shape.
+ expect(calls[0].url).toBe('https://opencode.ai/zen/go/v1/messages')
+ expect(calls[0].body.max_tokens).toBeDefined()
+ expect(calls[0].body).not.toHaveProperty('stream_options')
+
+ // The non-override model inherits the provider format → /chat/completions.
+ expect(calls[1].url).toBe('https://opencode.ai/zen/go/v1/chat/completions')
+ expect(calls[1].body.messages).toBeDefined()
+
+ // Both responses still materialize cleanly through their respective parsers.
+ expect(messagesChunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true)
+ expect(messagesChunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' })
+ expect(chatChunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true)
+ expect(chatChunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' })
+ })
+
+ it('sets the Anthropic auth + version headers only for the messages-routed model', async () => {
+ const headerCalls: Array> = []
+ const capturingFetch = (async (_url: string, init: { headers: Record }) => {
+ headerCalls.push(init.headers)
+ const target = String(_url)
+ const json = target.endsWith('/messages')
+ ? { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' }
+ : { choices: [{ index: 0, finish_reason: 'stop', message: { content: 'ok' } }] }
+ return new Response(JSON.stringify(json), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ })
+ }) as unknown as typeof fetch
+ const client = new CompatModelClient({
+ baseUrl: 'https://opencode.ai/zen/go/v1',
+ apiKey: 'sk-test',
+ model: 'glm-5.1',
+ endpointFormat: 'chat_completions',
+ nonStreaming: true,
+ fetchImpl: capturingFetch,
+ modelCapabilities: modelCapabilities({ 'minimax-m3': 'messages' })
+ })
+
+ await drain(client.stream(request('minimax-m3')))
+ await drain(client.stream(request('glm-5.1')))
+
+ expect(headerCalls[0]['anthropic-version']).toBe('2023-06-01')
+ expect(headerCalls[0]['x-api-key']).toBe('sk-test')
+ expect(headerCalls[1]['anthropic-version']).toBeUndefined()
+ expect(headerCalls[1]['x-api-key']).toBeUndefined()
+ expect(headerCalls[1].Authorization).toBe('Bearer sk-test')
+ })
+})
diff --git a/kun/src/adapters/model/compat-model-client.ts b/kun/src/adapters/model/compat-model-client.ts
index 8ed5e7c1d..548e07ccc 100644
--- a/kun/src/adapters/model/compat-model-client.ts
+++ b/kun/src/adapters/model/compat-model-client.ts
@@ -232,7 +232,11 @@ export class CompatModelClient implements ModelClient {
yield { kind: 'error', message: 'request was aborted before start' }
return
}
- const configuredEndpointFormat = this.endpointFormat()
+ const requestModel = request.model?.trim() || this.config.model
+ // Resolve the wire format per request model: a single provider (e.g.
+ // OpenCode Go) can route some models to chat completions and others to
+ // Anthropic Messages. Falls back to the provider/runtime format.
+ const configuredEndpointFormat = this.endpointFormatForModel(requestModel)
const endpointFormat = resolveModelEndpointFormat(configuredEndpointFormat, this.config.baseUrl)
if (!endpointFormat) {
yield {
@@ -243,7 +247,6 @@ export class CompatModelClient implements ModelClient {
}
const url = buildModelEndpointUrl(this.config.baseUrl, configuredEndpointFormat)
const stream = request.stream ?? !this.config.nonStreaming
- const requestModel = request.model?.trim() || this.config.model
const body = this.buildRequestBody(request, stream, { endpointFormat })
if (round) {
round.requestBody = body
@@ -329,6 +332,17 @@ export class CompatModelClient implements ModelClient {
return normalizeModelEndpointFormat(this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT)
}
+ /**
+ * The wire format for a specific model: a per-model override (carried on
+ * the model's capability metadata) takes precedence over the
+ * provider/runtime format. Lets one provider mix chat completions and
+ * Anthropic Messages models (e.g. OpenCode Go's minimax/qwen entries).
+ */
+ private endpointFormatForModel(model: string): ModelEndpointFormat {
+ const perModel = this.config.modelCapabilities?.(model).endpointFormat
+ return normalizeModelEndpointFormat(perModel ?? this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT)
+ }
+
private modelReasoningFor(model: string): ModelCapabilityMetadata['reasoning'] | undefined {
return this.config.modelCapabilities?.(model).reasoning
}
diff --git a/kun/src/config/kun-config.test.ts b/kun/src/config/kun-config.test.ts
index 3c59fc9da..30f667f62 100644
--- a/kun/src/config/kun-config.test.ts
+++ b/kun/src/config/kun-config.test.ts
@@ -1,7 +1,19 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
-import { expandHomePath } from './kun-config.js'
+import { expandHomePath, RuntimeTuningConfigSchema } from './kun-config.js'
+
+describe('RuntimeTuningConfigSchema streamIdleTimeoutMs', () => {
+ it('accepts a custom timeout, including 0 to disable the guard', () => {
+ expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 300_000 }).success).toBe(true)
+ expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 0 }).success).toBe(true)
+ })
+
+ it('rejects negative or fractional timeouts', () => {
+ expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: -1 }).success).toBe(false)
+ expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 1.5 }).success).toBe(false)
+ })
+})
describe('expandHomePath', () => {
it('expands Windows-style home-relative paths', () => {
diff --git a/kun/src/config/kun-config.ts b/kun/src/config/kun-config.ts
index 72a64ffa9..1c64b74ce 100644
--- a/kun/src/config/kun-config.ts
+++ b/kun/src/config/kun-config.ts
@@ -62,7 +62,13 @@ export const ModelContextProfileConfigSchema = z
outputModalities: z.array(ModelInputModality).optional(),
supportsToolCalling: z.boolean().optional(),
messageParts: z.array(ModelMessagePartSupport).optional(),
- reasoning: ModelReasoningCapabilityMetadata.optional()
+ reasoning: ModelReasoningCapabilityMetadata.optional(),
+ // Per-model wire-format override. Omitted means "inherit the
+ // provider/runtime endpointFormat" — no default coercion here, otherwise
+ // every model would be pinned to chat_completions.
+ endpointFormat: z
+ .preprocess(normalizeModelEndpointFormat, z.enum(MODEL_ENDPOINT_FORMATS))
+ .optional()
})
.strict()
.superRefine((profile, ctx) => {
@@ -119,6 +125,10 @@ export const ContextCompactionConfigSchema = z
export const RuntimeTuningConfigSchema = z
.object({
+ // Max idle gap (ms) between streaming chunks before a turn fails with
+ // `stream_idle_timeout`. Local LLM servers prefilling a huge prompt can
+ // stay silent well past the 45s default; `0` disables the guard entirely.
+ streamIdleTimeoutMs: z.number().int().min(0).optional(),
toolStorm: z
.object({
enabled: z.boolean().optional(),
diff --git a/kun/src/contracts/capabilities.ts b/kun/src/contracts/capabilities.ts
index ef1cf4a64..309230c80 100644
--- a/kun/src/contracts/capabilities.ts
+++ b/kun/src/contracts/capabilities.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { MODEL_ENDPOINT_FORMATS } from './model-endpoint-format.js'
export const RUNTIME_CAPABILITY_CONTRACT_VERSION = 1
@@ -51,7 +52,11 @@ export const ModelCapabilityMetadata = z
supportsToolCalling: z.boolean(),
contextWindowTokens: z.number().int().positive().optional(),
messageParts: z.array(ModelMessagePartSupport).min(1),
- reasoning: ModelReasoningCapabilityMetadata.optional()
+ reasoning: ModelReasoningCapabilityMetadata.optional(),
+ // Per-model wire-format override. Lets one provider route some models to
+ // chat completions and others to Anthropic Messages / OpenAI Responses
+ // (e.g. OpenCode Go). Absent means "inherit the provider/runtime format".
+ endpointFormat: z.enum(MODEL_ENDPOINT_FORMATS).optional()
})
.strict()
export type ModelCapabilityMetadata = z.infer
diff --git a/kun/src/loop/model-context-profile.test.ts b/kun/src/loop/model-context-profile.test.ts
index 5b3bec812..2f5d34495 100644
--- a/kun/src/loop/model-context-profile.test.ts
+++ b/kun/src/loop/model-context-profile.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { contextThresholdsForModel } from './model-context-profile.js'
+import {
+ contextThresholdsForModel,
+ modelCapabilitiesForModel,
+ modelContextProfilesFromConfig
+} from './model-context-profile.js'
describe('contextThresholdsForModel safety cap', () => {
it('caps soft/hard thresholds to 75%/85% of the context window', () => {
@@ -49,3 +53,23 @@ describe('contextThresholdsForModel safety cap', () => {
expect(thresholds).toEqual(fallback)
})
})
+
+describe('per-model endpointFormat', () => {
+ it('carries a configured endpointFormat from models.profiles into capabilities', () => {
+ const profiles = modelContextProfilesFromConfig({
+ models: {
+ profiles: {
+ 'minimax-m3': { contextWindowTokens: 256_000, endpointFormat: 'messages' },
+ 'glm-5.1': { contextWindowTokens: 131_072 }
+ }
+ }
+ })
+ expect(modelCapabilitiesForModel('minimax-m3', profiles).endpointFormat).toBe('messages')
+ // A model without an override inherits (no endpointFormat emitted).
+ expect(modelCapabilitiesForModel('glm-5.1', profiles).endpointFormat).toBeUndefined()
+ })
+
+ it('omits endpointFormat for unknown models so they inherit the provider format', () => {
+ expect(modelCapabilitiesForModel('unknown-model', []).endpointFormat).toBeUndefined()
+ })
+})
diff --git a/kun/src/loop/model-context-profile.ts b/kun/src/loop/model-context-profile.ts
index fca6b54fd..5d6aa43ee 100644
--- a/kun/src/loop/model-context-profile.ts
+++ b/kun/src/loop/model-context-profile.ts
@@ -4,6 +4,7 @@ import type {
ModelMessagePartSupport,
ModelReasoningCapabilityMetadata
} from '../contracts/capabilities.js'
+import type { ModelEndpointFormat } from '../contracts/model-endpoint-format.js'
export type ModelContextThresholds = {
softThreshold: number
@@ -26,6 +27,7 @@ export type ModelContextProfile = ModelContextThresholds & {
supportsToolCalling: boolean
messageParts: readonly ModelMessagePartSupport[]
reasoning?: ModelReasoningCapabilityMetadata
+ endpointFormat?: ModelEndpointFormat
}
export type ModelContextProfileConfig = {
@@ -45,6 +47,7 @@ export type ModelContextProfileConfig = {
supportsToolCalling?: boolean
messageParts?: readonly ModelMessagePartSupport[]
reasoning?: ModelReasoningCapabilityMetadata
+ endpointFormat?: ModelEndpointFormat
}
export type ModelConfig = {
@@ -148,7 +151,8 @@ export function modelCapabilitiesForModel(
supportsToolCalling: profile?.supportsToolCalling ?? true,
contextWindowTokens: profile?.contextWindowTokens,
messageParts: [...(profile?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS)],
- ...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {})
+ ...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {}),
+ ...(profile?.endpointFormat ? { endpointFormat: profile.endpointFormat } : {})
}
}
@@ -232,6 +236,7 @@ function mergeModelContextProfile(
...(input.aliases ?? [])
])
const reasoning = input.reasoning ?? current?.reasoning
+ const endpointFormat = input.endpointFormat ?? current?.endpointFormat
return {
canonicalModel,
modelIds,
@@ -244,7 +249,8 @@ function mergeModelContextProfile(
messageParts: uniqueModelCapabilityValues(input.messageParts ?? current?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS),
...(reasoning
? { reasoning: copyReasoningCapability(reasoning) }
- : {})
+ : {}),
+ ...(endpointFormat ? { endpointFormat } : {})
}
}
diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts
index 716e74b7e..1c75b41c2 100644
--- a/kun/src/server/runtime-factory.ts
+++ b/kun/src/server/runtime-factory.ts
@@ -162,7 +162,10 @@ export async function createKunServeRuntime(
endpointFormat: options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT,
model: options.model,
modelCapabilities,
- debugSink: llmDebug
+ debugSink: llmDebug,
+ ...(options.runtime?.streamIdleTimeoutMs !== undefined
+ ? { streamIdleTimeoutMs: options.runtime.streamIdleTimeoutMs }
+ : {})
})
const reviewService = new ReviewService({
threadStore,
diff --git a/package-lock.json b/package-lock.json
index dff15c4d2..2ff9e76dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,14 +7,15 @@
"": {
"name": "kun-gui",
"version": "0.1.0",
- "license": "PolyForm-Noncommercial-1.0.0",
"hasInstallScript": true,
+ "license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1049.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/language-data": "^6.5.2",
+ "@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@larksuiteoapi/node-sdk": "^1.64.0",
@@ -1278,6 +1279,19 @@
"crelt": "^1.0.5"
}
},
+ "node_modules/@codemirror/merge": {
+ "version": "6.12.2",
+ "resolved": "https://registry.npmmirror.com/@codemirror/merge/-/merge-6.12.2.tgz",
+ "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/highlight": "^1.0.0",
+ "style-mod": "^4.1.0"
+ }
+ },
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz",
@@ -10066,25 +10080,25 @@
"dev": true,
"license": "MIT"
},
- "node_modules/pend": {
- "version": "1.2.0",
- "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
- "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/pdfjs-dist": {
"version": "5.4.394",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz",
"integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==",
"license": "Apache-2.0",
- "optionalDependencies": {
- "@napi-rs/canvas": "^0.1.81"
- },
"engines": {
"node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.81"
}
},
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/package.json b/package.json
index 4f588147f..ece9bda58 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/language-data": "^6.5.2",
+ "@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@larksuiteoapi/node-sdk": "^1.64.0",
diff --git a/src/main/claw-platform-install.test.ts b/src/main/claw-platform-install.test.ts
index 5ee79bb77..d11282fd7 100644
--- a/src/main/claw-platform-install.test.ts
+++ b/src/main/claw-platform-install.test.ts
@@ -37,36 +37,53 @@ describe('claw platform install', () => {
configureManagedWeixinBridgeUrlResolver(null)
})
- it('returns the official user code and polls the matching Feishu/Lark target', async () => {
+ it('always begins on Feishu and switches to Lark only when tenant_brand says so', async () => {
+ const seenActions: Array = []
+ const beginHosts: string[] = []
+ let beginCount = 0
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const hostname = requestHostname(input)
const body = new URLSearchParams(String(init?.body ?? ''))
const action = body.get('action')
-
- if (action === 'init') {
- return jsonResponse({ nonce: 'nonce' })
- }
+ seenActions.push(action)
if (action === 'begin') {
- const isLark = hostname === 'accounts.larksuite.com'
+ beginHosts.push(hostname)
+ beginCount += 1
+ // First start() stands in for a Feishu tenant, the second for Lark.
+ const deviceCode = beginCount === 1 ? 'feishu-device' : 'lark-device'
return jsonResponse({
- device_code: isLark ? 'lark-device' : 'feishu-device',
- user_code: isLark ? 'LARK-CODE' : 'FEI-CODE',
- verification_uri_complete: isLark
- ? 'https://open.larksuite.com/page/launcher?user_code=LARK-CODE'
- : 'https://open.feishu.cn/page/launcher?user_code=FEI-CODE',
+ device_code: deviceCode,
+ user_code: `CODE-${beginCount}`,
+ verification_uri_complete: `https://open.feishu.cn/page/launcher?user_code=CODE-${beginCount}`,
expires_in: 3600,
interval: 5
})
}
if (action === 'poll') {
- const isLark = hostname === 'accounts.larksuite.com'
- return jsonResponse({
- client_id: isLark ? 'cli_lark' : 'cli_feishu',
- client_secret: 'secret',
- user_info: { tenant_brand: isLark ? 'lark' : 'feishu' }
- })
+ const deviceCode = body.get('device_code')
+ if (deviceCode === 'feishu-device') {
+ // Feishu tenant: credentials are issued directly by accounts.feishu.cn.
+ return jsonResponse({
+ client_id: 'cli_feishu',
+ client_secret: 'secret',
+ user_info: { tenant_brand: 'feishu' }
+ })
+ }
+ if (deviceCode === 'lark-device') {
+ if (hostname === 'accounts.feishu.cn') {
+ // Lark tenant detected, but the secret lives on larksuite.com.
+ return jsonResponse({ user_info: { tenant_brand: 'lark' } })
+ }
+ if (hostname === 'accounts.larksuite.com') {
+ return jsonResponse({
+ client_id: 'cli_lark',
+ client_secret: 'secret',
+ user_info: { tenant_brand: 'lark' }
+ })
+ }
+ }
}
return jsonResponse({ message: 'unexpected request' }, 400)
@@ -75,10 +92,20 @@ describe('claw platform install', () => {
const feishuStart = await startFeishuInstallQrcode(false)
const larkStart = await startFeishuInstallQrcode(true)
-
- expect(feishuStart).toMatchObject({ ok: true, userCode: 'FEI-CODE' })
- expect(larkStart).toMatchObject({ ok: true, userCode: 'LARK-CODE' })
-
+ expect(feishuStart).toMatchObject({ ok: true })
+ expect(larkStart).toMatchObject({ ok: true })
+
+ // #316: the QR is always minted on Feishu — even for the Lark selection the
+ // scannable link points at open.feishu.cn, never open.larksuite.com (the
+ // latter is what the Lark app rejected as "Link expired").
+ if (!larkStart.ok) throw new Error(larkStart.message)
+ expect(larkStart.url).toContain('https://open.feishu.cn/')
+ expect(larkStart.url).not.toContain('larksuite.com')
+ expect(beginHosts).toEqual(['accounts.feishu.cn', 'accounts.feishu.cn'])
+ // ...and the flow goes straight to `begin`, with no `action: 'init'` step.
+ expect(seenActions).not.toContain('init')
+
+ // Feishu tenant: secret comes straight from accounts.feishu.cn.
await expect(pollFeishuInstall('feishu-device')).resolves.toEqual({
done: true,
kind: 'feishu',
@@ -88,6 +115,8 @@ describe('claw platform install', () => {
})
expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('accounts.feishu.cn')
+ // Lark tenant: the Feishu poll reveals tenant_brand=lark, so a single
+ // pollFeishuInstall call switches to accounts.larksuite.com for the secret.
await expect(pollFeishuInstall('lark-device')).resolves.toEqual({
done: true,
kind: 'feishu',
diff --git a/src/main/claw-platform-install.ts b/src/main/claw-platform-install.ts
index 942346261..3a494c51b 100644
--- a/src/main/claw-platform-install.ts
+++ b/src/main/claw-platform-install.ts
@@ -10,8 +10,11 @@ type ClawPlatformInstallPollResult =
| { done: true; kind: 'weixin'; accountId: string; sessionKey: string }
| { done: false; error?: string }
-let feishuInstallIsLark = false
-const feishuInstallTargets = new Map()
+type FeishuRegistrationDomain = 'feishu' | 'lark'
+const FEISHU_ACCOUNTS_URL = 'https://accounts.feishu.cn'
+const LARK_ACCOUNTS_URL = 'https://accounts.larksuite.com'
+let feishuInstallSelectedIsLark = false
+const feishuInstallDomains = new Map()
const MAX_FEISHU_INSTALL_TARGETS = 32
const weixinInstallSessions = new Map()
const MAX_WEIXIN_INSTALL_SESSIONS = 32
@@ -98,18 +101,25 @@ function normalizeIntervalSeconds(value: unknown, fallback: number): number {
return Number.isFinite(parsed) ? Math.max(3, Math.floor(parsed)) : fallback
}
-function rememberFeishuInstallTarget(deviceCode: string, isLark: boolean): void {
- feishuInstallTargets.delete(deviceCode)
- feishuInstallTargets.set(deviceCode, isLark)
- while (feishuInstallTargets.size > MAX_FEISHU_INSTALL_TARGETS) {
- const oldestDeviceCode = feishuInstallTargets.keys().next().value
+function feishuAccountsBaseUrl(domain: FeishuRegistrationDomain): string {
+ return domain === 'lark' ? LARK_ACCOUNTS_URL : FEISHU_ACCOUNTS_URL
+}
+
+function rememberFeishuInstallDomain(deviceCode: string, domain: FeishuRegistrationDomain): void {
+ feishuInstallDomains.delete(deviceCode)
+ feishuInstallDomains.set(deviceCode, domain)
+ while (feishuInstallDomains.size > MAX_FEISHU_INSTALL_TARGETS) {
+ const oldestDeviceCode = feishuInstallDomains.keys().next().value
if (!oldestDeviceCode) break
- feishuInstallTargets.delete(oldestDeviceCode)
+ feishuInstallDomains.delete(oldestDeviceCode)
}
}
-function resolveFeishuInstallTarget(deviceCode: string): boolean {
- return feishuInstallTargets.get(deviceCode) ?? feishuInstallIsLark
+function resolveFeishuInstallDomain(deviceCode: string): FeishuRegistrationDomain {
+ // Registration always begins on Feishu, so polling starts there too and only
+ // switches once tenant_brand reveals a Lark tenant. The selected brand is a
+ // fallback for the rare case the device code was evicted from the map.
+ return feishuInstallDomains.get(deviceCode) ?? (feishuInstallSelectedIsLark ? 'lark' : 'feishu')
}
function rememberWeixinInstallSession(deviceCode: string, sessionKey: string): void {
@@ -225,14 +235,22 @@ async function startWeixinBridgeChannel(
export async function startFeishuInstallQrcode(isLark: boolean): Promise {
try {
- const baseUrl = isLark ? 'https://accounts.larksuite.com' : 'https://accounts.feishu.cn'
- feishuInstallIsLark = isLark
- await postForm(`${baseUrl}/oauth/v1/app/registration`, { action: 'init' })
+ // Always begin on Feishu — even when the user picked Lark. The official
+ // Lark CLI and @larksuiteoapi SDK both mint the QR on accounts.feishu.cn
+ // (so the user scans an open.feishu.cn launcher link) and only switch to
+ // Lark while polling, once the response's tenant_brand comes back "lark".
+ // Minting the QR on accounts.larksuite.com instead yields an
+ // open.larksuite.com link that the Lark app rejects as "Link expired"
+ // (issue #316). There is also no `action: 'init'` step — the CLI/SDK go
+ // straight to `begin`; init only returns an unused 60s nonce.
+ feishuInstallSelectedIsLark = isLark
+ const baseUrl = FEISHU_ACCOUNTS_URL
const data = await postForm(`${baseUrl}/oauth/v1/app/registration`, {
action: 'begin',
archetype: 'PersonalAgent',
auth_method: 'client_secret',
- request_user_info: 'open_id'
+ // Request tenant_brand so polling can detect a Lark tenant and switch.
+ request_user_info: 'open_id tenant_brand'
})
const url = recordString(data, 'verification_uri_complete')
const deviceCode = recordString(data, 'device_code')
@@ -240,7 +258,7 @@ export async function startFeishuInstallQrcode(isLark: boolean): Promise }> {
+ return postFormResult(`${feishuAccountsBaseUrl(domain)}/oauth/v1/app/registration`, {
+ action: 'poll',
+ device_code: deviceCode
+ })
+}
+
export async function pollFeishuInstall(deviceCode: string): Promise {
try {
- const baseUrl = resolveFeishuInstallTarget(deviceCode) ? 'https://accounts.larksuite.com' : 'https://accounts.feishu.cn'
- const result = await postFormResult(`${baseUrl}/oauth/v1/app/registration`, {
- action: 'poll',
- device_code: deviceCode
- })
+ let domain = resolveFeishuInstallDomain(deviceCode)
+ let result = await pollFeishuRegistration(domain, deviceCode)
+
+ // Once the scanning user is identified as a Lark tenant, the credentials are
+ // issued by accounts.larksuite.com — switch there and re-poll immediately,
+ // then persist the switch for subsequent polls (matches the official
+ // CLI/SDK). The Feishu poll returns tenant_brand="lark" with no secret.
+ if (domain === 'feishu') {
+ const tenantBrand = recordString(asRecord(result.data.user_info), 'tenant_brand')
+ const hasSecret = recordString(result.data, 'client_secret') !== ''
+ if (tenantBrand === 'lark' && !hasSecret) {
+ domain = 'lark'
+ rememberFeishuInstallDomain(deviceCode, 'lark')
+ result = await pollFeishuRegistration(domain, deviceCode)
+ }
+ }
+
const data = result.data
const error = recordString(data, 'error')
if (error) {
if (error === 'authorization_pending' || error === 'slow_down') return { done: false }
- feishuInstallTargets.delete(deviceCode)
+ feishuInstallDomains.delete(deviceCode)
return { done: false, error: recordString(data, 'error_description') || error }
}
if (!result.ok) {
- feishuInstallTargets.delete(deviceCode)
+ feishuInstallDomains.delete(deviceCode)
return {
done: false,
error: recordString(data, 'error_description') || recordString(data, 'message') || `HTTP ${result.status}`
@@ -278,9 +318,7 @@ export async function pollFeishuInstall(deviceCode: string): Promise {
+ it('returns the concluding text that follows the last tool activity', () => {
+ const detail = singleTurnDetail([
+ { kind: 'assistant_text', text: '我的计划:先读文件,再修改' },
+ { kind: 'tool_call' },
+ { kind: 'tool_result' },
+ { kind: 'assistant_text', text: '已完成:结果是 42' }
+ ])
+ expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('已完成:结果是 42')
+ })
+
+ it('skips the pre-tool plan when the turn ends without concluding text', () => {
+ // The exact bug: the model narrates a plan as text, performs the work
+ // through tools, and stops without a final message. The plan must not
+ // be mistaken for the result.
+ const detail = singleTurnDetail([
+ { kind: 'assistant_reasoning', text: '正在思考……' },
+ { kind: 'assistant_text', text: '我的计划:先读文件,再修改' },
+ { kind: 'tool_call' },
+ { kind: 'tool_result' }
+ ])
+ expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('')
+ })
+
+ it('never treats reasoning as the reply', () => {
+ const detail = singleTurnDetail([
+ { kind: 'assistant_reasoning', text: '思考:结论应该是 X' },
+ { kind: 'tool_call' },
+ { kind: 'tool_result' },
+ { kind: 'assistant_reasoning', text: '结束思考:已经完整完成 X' }
+ ])
+ expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('')
+ })
+
+ it('returns the last message for a pure chat turn with no tools', () => {
+ const detail = singleTurnDetail([
+ { kind: 'assistant_text', text: '第一段' },
+ { kind: 'assistant_text', text: '最终答案' }
+ ])
+ expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('最终答案')
+ })
+
+ it('scopes extraction to the requested turn and ignores earlier turns', () => {
+ const detail: ThreadDetailJson = {
+ turns: [
+ { id: 'turn_prev', status: 'completed', items: [{ kind: 'assistant_text', text: '旧回复' }] },
+ { id: 'turn_cur', status: 'completed', items: [{ kind: 'tool_call' }, { kind: 'tool_result' }] }
+ ]
+ }
+ expect(finalAssistantReplyText(detail, { turnId: 'turn_cur' })).toBe('')
+ expect(finalAssistantReplyText(detail, { turnId: 'turn_prev' })).toBe('旧回复')
+ })
+})
+
+describe('imCompletionReplyForPush', () => {
+ it('is the plain completion note when no files were produced', () => {
+ expect(imCompletionReplyForPush([])).toBe(IM_COMPLETED_NO_TEXT_REPLY)
+ })
+
+ it('lists generated file names so they can be retrieved later', () => {
+ const reply = imCompletionReplyForPush([
+ { path: '/w/a.md', fileName: 'a.md' },
+ { path: '/w/b.png', fileName: 'b.png' }
+ ])
+ expect(reply).toContain('a.md')
+ expect(reply).toContain('b.png')
+ })
+})
diff --git a/src/main/claw-runtime-helpers.ts b/src/main/claw-runtime-helpers.ts
index 7ce5f4343..28a66b0b8 100644
--- a/src/main/claw-runtime-helpers.ts
+++ b/src/main/claw-runtime-helpers.ts
@@ -194,6 +194,70 @@ export function latestAssistantText(
return ''
}
+/** Reply sent when a turn finished but produced no concluding text. */
+export const IM_COMPLETED_NO_TEXT_REPLY = '✅ 任务已完成。'
+
+/**
+ * Ack sent when a turn outruns the IM response timeout. The turn keeps
+ * running and the real result is pushed back when it finishes.
+ */
+export const IM_PROCESSING_ACK = '⏳ 收到,正在处理,完成后会把结果发给你。'
+
+const TOOL_ITEM_KINDS = new Set(['tool_call', 'tool_result'])
+
+/**
+ * The turn's *concluding* assistant message — the last `assistant_text`
+ * that appears after the final tool activity.
+ *
+ * Mid-turn narration is intentionally skipped: a model often writes an
+ * upfront plan as text ("先做 X,再做 Y") and then performs the work
+ * through tool calls, frequently ending without any further text (the
+ * wrap-up stays in `reasoning_content`, or it just stops after the last
+ * tool succeeds). `latestAssistantText` would return that stale plan,
+ * so the phone received the plan instead of the result. Scanning only
+ * the post-tool tail fixes that.
+ *
+ * Pure chat turns (no tool calls) fall back to the last assistant
+ * message. Returns '' when the turn ended without concluding text;
+ * callers then substitute {@link IM_COMPLETED_NO_TEXT_REPLY}.
+ */
+export function finalAssistantReplyText(
+ detail: ThreadDetailJson,
+ options: { turnId?: string } = {}
+): string {
+ const turnId = options.turnId?.trim()
+ const items = turnId
+ ? threadItems(detail).filter((item) => item.turnId === turnId)
+ : threadItems(detail)
+ let lastToolIndex = -1
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ if (TOOL_ITEM_KINDS.has(items[index].kind)) {
+ lastToolIndex = index
+ break
+ }
+ }
+ for (let index = items.length - 1; index > lastToolIndex; index -= 1) {
+ const item = items[index]
+ if (item.kind !== 'assistant_text' && item.kind !== 'agent_message') continue
+ const text = (item.text ?? item.detail ?? item.summary ?? '').trim()
+ if (text) return text
+ }
+ return ''
+}
+
+/**
+ * Reply used by the asynchronous result push when the finished turn has
+ * no concluding text. Files generated during a long run cannot be media
+ * pushed out-of-band, so their names are surfaced for retrieval instead.
+ */
+export function imCompletionReplyForPush(files: readonly ClawGeneratedFileV1[]): string {
+ if (files.length > 0) {
+ const names = files.map((file) => file.fileName).join('、')
+ return `${IM_COMPLETED_NO_TEXT_REPLY}(生成的文件:${names},回复"发给我"获取)`
+ }
+ return IM_COMPLETED_NO_TEXT_REPLY
+}
+
function outputRecord(output: unknown): Record | null {
return typeof output === 'object' && output !== null && !Array.isArray(output)
? output as Record
diff --git a/src/main/claw-runtime.test.ts b/src/main/claw-runtime.test.ts
index 72b4675d6..7efd9dea6 100644
--- a/src/main/claw-runtime.test.ts
+++ b/src/main/claw-runtime.test.ts
@@ -1877,11 +1877,13 @@ describe('ClawRuntime', () => {
handleWebhook: (request: typeof req, response: typeof res) => Promise
}).handleWebhook(req, res)
- expect(status).toBe(500)
- expect(JSON.parse(responseBody)).toMatchObject({
- ok: false,
- message: 'Internal server error.'
- })
+ // A completed turn with no concluding text of its own replies with a
+ // completion note — never the previous turn's historical text.
+ expect(status).toBe(200)
+ const parsed = JSON.parse(responseBody)
+ expect(parsed.ok).toBe(true)
+ expect(parsed.reply).toContain('已完成')
+ expect(responseBody).not.toContain('previous reply')
})
it('does not return historical WeChat text when the current turn fails', async () => {
@@ -1968,10 +1970,294 @@ describe('ClawRuntime', () => {
handleWebhook: (request: typeof req, response: typeof res) => Promise
}).handleWebhook(req, res)
+ // A failed turn surfaces the failure — never the previous turn's text.
expect(status).toBe(500)
- expect(JSON.parse(responseBody)).toMatchObject({
- ok: false,
- message: 'Internal server error.'
+ const parsed = JSON.parse(responseBody)
+ expect(parsed.ok).toBe(false)
+ expect(parsed.message).toContain('failed')
+ expect(responseBody).not.toContain('previous reply')
+ })
+
+ it('replies with a completion note, not the mid-turn plan, when the turn ends without concluding text', async () => {
+ const settings = buildSettings()
+ settings.claw.im.enabled = true
+ settings.claw.im.responseTimeoutMs = 2_000
+ settings.claw.channels = [buildChannel({
+ provider: 'weixin' as const,
+ id: 'channel_weixin',
+ label: 'WeChat',
+ threadId: 'thr_weixin',
+ welcomeSentAt: new Date().toISOString(),
+ conversations: [buildConversation({
+ chatId: 'wx_user_1',
+ latestMessageId: 'wx_previous',
+ senderId: 'wx_user_1',
+ senderName: 'Alice',
+ localThreadId: 'thr_weixin'
+ })]
+ })]
+ const { store } = mutableSettingsStore(settings)
+ const runtimeRequest = vi.fn(async (_settings, path, init) => {
+ if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+ return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+ }
+ if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+ return {
+ ok: true,
+ status: 200,
+ body: JSON.stringify({
+ id: 'thr_weixin',
+ status: 'idle',
+ turns: [
+ {
+ id: 'turn_current',
+ status: 'completed',
+ // The model narrated a plan, did the work via tools, then
+ // stopped without a final message — the classic shape that
+ // used to leak the plan to the phone.
+ items: [
+ { kind: 'assistant_text', text: '我的计划:先读文件,再修改' },
+ { kind: 'tool_call' },
+ { kind: 'tool_result' }
+ ]
+ }
+ ]
+ })
+ }
+ }
+ throw new Error(`unexpected path ${path}`)
+ })
+ const runtime = createClawRuntime({
+ store: store as never,
+ runtimeRequest: runtimeRequest as never,
+ logError: () => undefined,
+ createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+ })
+ const body = JSON.stringify({
+ text: 'do the task',
+ provider: 'weixin',
+ channelId: 'channel_weixin',
+ chatId: 'wx_user_1',
+ messageId: 'wx_msg_2',
+ senderId: 'wx_user_1',
+ senderName: 'Alice'
+ })
+ const req = {
+ method: 'POST',
+ url: settings.claw.im.path,
+ headers: {},
+ async *[Symbol.asyncIterator]() {
+ yield Buffer.from(body)
+ }
+ }
+ let status = 0
+ let responseBody = ''
+ const res = {
+ writeHead: vi.fn((nextStatus: number) => {
+ status = nextStatus
+ }),
+ end: vi.fn((payload: string) => {
+ responseBody = payload
+ })
+ }
+
+ await (runtime as unknown as {
+ handleWebhook: (request: typeof req, response: typeof res) => Promise
+ }).handleWebhook(req, res)
+
+ expect(status).toBe(200)
+ const parsed = JSON.parse(responseBody)
+ expect(parsed.ok).toBe(true)
+ expect(parsed.reply).toContain('已完成')
+ expect(responseBody).not.toContain('我的计划')
+ })
+
+ it('returns the concluding text produced after tool calls', async () => {
+ const settings = buildSettings()
+ settings.claw.im.enabled = true
+ settings.claw.im.responseTimeoutMs = 2_000
+ settings.claw.channels = [buildChannel({
+ provider: 'weixin' as const,
+ id: 'channel_weixin',
+ label: 'WeChat',
+ threadId: 'thr_weixin',
+ welcomeSentAt: new Date().toISOString(),
+ conversations: [buildConversation({
+ chatId: 'wx_user_1',
+ latestMessageId: 'wx_previous',
+ senderId: 'wx_user_1',
+ senderName: 'Alice',
+ localThreadId: 'thr_weixin'
+ })]
+ })]
+ const { store } = mutableSettingsStore(settings)
+ const runtimeRequest = vi.fn(async (_settings, path, init) => {
+ if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+ return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+ }
+ if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+ return {
+ ok: true,
+ status: 200,
+ body: JSON.stringify({
+ id: 'thr_weixin',
+ status: 'idle',
+ turns: [
+ {
+ id: 'turn_current',
+ status: 'completed',
+ items: [
+ { kind: 'assistant_text', text: '我的计划:先读文件,再修改' },
+ { kind: 'tool_call' },
+ { kind: 'tool_result' },
+ { kind: 'assistant_text', text: '已完成:共修改 3 处' }
+ ]
+ }
+ ]
+ })
+ }
+ }
+ throw new Error(`unexpected path ${path}`)
+ })
+ const runtime = createClawRuntime({
+ store: store as never,
+ runtimeRequest: runtimeRequest as never,
+ logError: () => undefined,
+ createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+ })
+ const body = JSON.stringify({
+ text: 'do the task',
+ provider: 'weixin',
+ channelId: 'channel_weixin',
+ chatId: 'wx_user_1',
+ messageId: 'wx_msg_2',
+ senderId: 'wx_user_1',
+ senderName: 'Alice'
+ })
+ const req = {
+ method: 'POST',
+ url: settings.claw.im.path,
+ headers: {},
+ async *[Symbol.asyncIterator]() {
+ yield Buffer.from(body)
+ }
+ }
+ let status = 0
+ let responseBody = ''
+ const res = {
+ writeHead: vi.fn((nextStatus: number) => {
+ status = nextStatus
+ }),
+ end: vi.fn((payload: string) => {
+ responseBody = payload
+ })
+ }
+
+ await (runtime as unknown as {
+ handleWebhook: (request: typeof req, response: typeof res) => Promise
+ }).handleWebhook(req, res)
+
+ expect(status).toBe(200)
+ const parsed = JSON.parse(responseBody)
+ expect(parsed.ok).toBe(true)
+ expect(parsed.reply).toContain('已完成:共修改 3 处')
+ expect(responseBody).not.toContain('我的计划')
+ })
+
+ it('acks a long-running turn and pushes the result back to WeChat once it finishes', async () => {
+ const settings = buildSettings()
+ settings.claw.im.enabled = true
+ // Tiny window so the synchronous wait times out on the first poll.
+ settings.claw.im.responseTimeoutMs = 10
+ settings.claw.channels = [buildChannel({
+ provider: 'weixin' as const,
+ id: 'channel_weixin',
+ label: 'WeChat',
+ threadId: 'thr_weixin',
+ platformCredential: {
+ kind: 'weixin',
+ accountId: 'acc_1',
+ sessionKey: 'sess_1',
+ createdAt: '2026-06-02T00:00:00.000Z'
+ },
+ conversations: [buildConversation({
+ chatId: 'wx_user_1',
+ latestMessageId: 'wx_previous',
+ senderId: 'wx_user_1',
+ senderName: 'Alice',
+ localThreadId: 'thr_weixin'
+ })]
+ })]
+ const { store } = mutableSettingsStore(settings)
+ let getCount = 0
+ const runtimeRequest = vi.fn(async (_settings, path, init) => {
+ if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') {
+ return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) }
+ }
+ if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') {
+ getCount += 1
+ // First read (synchronous wait): still running → ack. Later reads
+ // (background push poller): completed with the real result.
+ const status = getCount <= 1 ? 'in_progress' : 'completed'
+ const items = getCount <= 1 ? [] : [{ kind: 'assistant_text', text: '已完成:最终结论 XYZ' }]
+ return {
+ ok: true,
+ status: 200,
+ body: JSON.stringify({ id: 'thr_weixin', status: 'idle', turns: [{ id: 'turn_current', status, items }] })
+ }
+ }
+ throw new Error(`unexpected path ${path}`)
+ })
+ const sendWeixinBridgeMessage = vi.fn(async () => ({ ok: true as const, messageId: 'wx_out_1' }))
+ const runtime = createClawRuntime({
+ store: store as never,
+ runtimeRequest: runtimeRequest as never,
+ logError: () => undefined,
+ sendWeixinBridgeMessage,
+ createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const }))
+ })
+ const body = JSON.stringify({
+ text: 'do a long task',
+ provider: 'weixin',
+ channelId: 'channel_weixin',
+ chatId: 'wx_user_1',
+ messageId: 'wx_msg_2',
+ senderId: 'wx_user_1',
+ senderName: 'Alice'
+ })
+ const req = {
+ method: 'POST',
+ url: settings.claw.im.path,
+ headers: {},
+ async *[Symbol.asyncIterator]() {
+ yield Buffer.from(body)
+ }
+ }
+ let status = 0
+ let responseBody = ''
+ const res = {
+ writeHead: vi.fn((nextStatus: number) => {
+ status = nextStatus
+ }),
+ end: vi.fn((payload: string) => {
+ responseBody = payload
+ })
+ }
+
+ await (runtime as unknown as {
+ handleWebhook: (request: typeof req, response: typeof res) => Promise
+ }).handleWebhook(req, res)
+
+ // Synchronous reply is the ack, never an intermediate step.
+ expect(status).toBe(200)
+ expect(JSON.parse(responseBody).reply).toContain('正在处理')
+
+ // The real result is pushed back once the turn finishes.
+ await vi.waitFor(() => expect(sendWeixinBridgeMessage).toHaveBeenCalledTimes(1), { timeout: 8_000, interval: 100 })
+ expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({
+ accountId: 'acc_1',
+ to: 'wx_user_1',
+ text: '已完成:最终结论 XYZ'
})
})
diff --git a/src/main/claw-runtime.ts b/src/main/claw-runtime.ts
index 79dceccc3..e00bc80fa 100644
--- a/src/main/claw-runtime.ts
+++ b/src/main/claw-runtime.ts
@@ -49,10 +49,13 @@ import {
extractIncomingRemoteSession,
extractSenderLabel,
feishuSenderLabel,
+ finalAssistantReplyText,
formatFeishuMirrorText,
+ imCompletionReplyForPush,
isRunningStatus,
+ IM_COMPLETED_NO_TEXT_REPLY,
+ IM_PROCESSING_ACK,
latestGeneratedFiles,
- latestAssistantText,
nestedRecord,
normalizeTaskModel,
parseJsonObject,
@@ -401,6 +404,13 @@ export function imWelcomeText(settings: AppSettingsV1, channel?: ClawImChannelV1
].join('\n\n')
}
+/**
+ * How long the background push keeps polling a turn that outran the IM
+ * response window before giving up (30 min). Generous enough for long
+ * agentic runs, bounded so a stuck turn never leaks a forever-poll.
+ */
+const RESULT_PUSH_MAX_WAIT_MS = 30 * 60 * 1_000
+
export class ClawRuntime {
private readonly deps: ClawRuntimeDeps
private server: Server | null = null
@@ -412,6 +422,8 @@ export class ClawRuntime {
private readonly welcomeInFlight = new Set()
/** WeChat channels already greeted (or attempted) at connect time this run. */
private readonly weixinConnectWelcomeAttempted = new Set()
+ /** `${threadId}:${turnId}` of turns with an in-flight delayed-result push. */
+ private readonly pendingResultPushes = new Set()
constructor(deps: ClawRuntimeDeps) {
this.deps = deps
@@ -603,14 +615,39 @@ export class ClawRuntime {
return { ok: true, threadId: thread.id, turnId, message: 'Started' }
}
- const result = await this.waitForAssistantResult(runtimeSettings, thread.id, turnId, options.responseTimeoutMs, workspace)
+ const outcome = await this.waitForAssistantResult(
+ runtimeSettings,
+ thread.id,
+ turnId,
+ options.responseTimeoutMs,
+ workspace
+ )
+ if (outcome.status === 'failed' || outcome.status === 'aborted') {
+ return { ok: false, message: outcome.error || `Agent turn ${outcome.status}.` }
+ }
+ if (outcome.status === 'timeout') {
+ // The turn outran the response window but keeps running in the
+ // runtime. Ack now; the caller pushes the real result back when
+ // the turn finishes (see `scheduleImResultPush`). Returning the
+ // last-seen text here is what used to leak an intermediate plan.
+ return {
+ ok: true,
+ threadId: thread.id,
+ turnId,
+ text: '',
+ message: IM_PROCESSING_ACK,
+ files: [],
+ completed: false
+ }
+ }
return {
ok: true,
threadId: thread.id,
turnId,
- text: result.text,
- message: result.text || 'Completed',
- files: result.files
+ text: outcome.text,
+ message: outcome.text || IM_COMPLETED_NO_TEXT_REPLY,
+ files: outcome.files,
+ completed: true
}
}
@@ -626,16 +663,26 @@ export class ClawRuntime {
)
}
+ /**
+ * Polls a turn to completion. Resolves with the turn's *concluding*
+ * text (never an intermediate plan — see {@link finalAssistantReplyText})
+ * and any generated files. Non-throwing on a failed/aborted/timed-out
+ * turn so both the synchronous reply and the asynchronous push can
+ * decide what to send; still throws when the thread read itself fails.
+ */
private async waitForAssistantResult(
settings: AppSettingsV1,
threadId: string,
turnId: string,
timeoutMs: number,
workspaceRoot?: string
- ): Promise<{ text: string; files: ClawGeneratedFileV1[] }> {
+ ): Promise<{
+ status: 'completed' | 'failed' | 'aborted' | 'timeout'
+ text: string
+ files: ClawGeneratedFileV1[]
+ error?: string
+ }> {
const deadline = Date.now() + timeoutMs
- let lastText = ''
- let lastDetail: ThreadDetailJson | null = null
while (Date.now() < deadline) {
await sleep(1_500)
const detailRes = await this.deps.runtimeRequest(
@@ -647,31 +694,122 @@ export class ClawRuntime {
throw new Error(runtimeErrorMessage(detailRes, 'Failed to read thread result.'))
}
const detail = JSON.parse(detailRes.body) as ThreadDetailJson
- lastDetail = detail
- lastText = latestAssistantText(detail, { turnId }) || lastText
const targetTurn = Array.isArray(detail.turns)
? detail.turns.find((turn) => turn.id === turnId)
: undefined
if (!targetTurn) continue
if (isRunningStatus(targetTurn.status)) continue
if (targetTurn.status === 'failed' || targetTurn.status === 'aborted') {
- const error = targetTurn.error?.trim()
- throw new Error(error || `Agent turn ${targetTurn.status}.`)
+ return {
+ status: targetTurn.status,
+ text: '',
+ files: [],
+ error: targetTurn.error?.trim() || `Agent turn ${targetTurn.status}.`
+ }
}
- if (targetTurn.status === 'completed' && lastText) {
+ if (targetTurn.status === 'completed') {
return {
- text: lastText,
+ status: 'completed',
+ text: finalAssistantReplyText(detail, { turnId }),
files: latestGeneratedFiles(detail, { turnId, workspaceRoot })
}
}
}
- if (lastText && lastDetail) {
- return {
- text: lastText,
- files: latestGeneratedFiles(lastDetail, { turnId, workspaceRoot })
+ return { status: 'timeout', text: '', files: [] }
+ }
+
+ /**
+ * Fire-and-forget delivery of a turn's result that outran the IM
+ * response window. Keeps polling in the background and pushes the
+ * concluding text (or a completion note) back over the bridge when the
+ * turn finishes. No-op for providers/recipients we cannot push to, and
+ * deduped per turn so a retried inbound never double-pushes.
+ */
+ private scheduleImResultPush(
+ settings: AppSettingsV1,
+ input: {
+ channel?: ClawImChannelV1
+ remoteSession?: Pick
+ threadId: string
+ turnId?: string
+ workspaceRoot: string
+ }
+ ): void {
+ const { channel, turnId } = input
+ if (!channel || !turnId) return
+ const canPush =
+ (channel.provider === 'weixin' && Boolean(this.deps.sendWeixinBridgeMessage)) ||
+ (channel.provider === 'feishu' && this.feishuChannels.has(channel.id))
+ if (!canPush) return
+ const key = `${input.threadId}:${turnId}`
+ if (this.pendingResultPushes.has(key)) return
+ this.pendingResultPushes.add(key)
+ void (async () => {
+ try {
+ const outcome = await this.waitForAssistantResult(
+ settings,
+ input.threadId,
+ turnId,
+ RESULT_PUSH_MAX_WAIT_MS,
+ input.workspaceRoot
+ )
+ if (outcome.status === 'timeout') {
+ this.deps.logError(
+ 'claw-im',
+ 'Gave up pushing a delayed agent result: turn still running after the maximum wait.',
+ { threadId: input.threadId, turnId }
+ )
+ return
+ }
+ const body =
+ outcome.status === 'completed'
+ ? outcome.text.trim() || imCompletionReplyForPush(outcome.files)
+ : `❌ 任务未完成:${outcome.error || outcome.status}`
+ await this.pushImMessage(channel, input.remoteSession, body)
+ } catch (error) {
+ this.deps.logError('claw-im', 'Failed to push a delayed agent result.', {
+ message: errorMessage(error),
+ threadId: input.threadId,
+ turnId
+ })
+ } finally {
+ this.pendingResultPushes.delete(key)
}
+ })()
+ }
+
+ /** Pushes a standalone bridge message to the sender of an inbound IM. */
+ private async pushImMessage(
+ channel: ClawImChannelV1,
+ remoteSession: Pick | undefined,
+ text: string
+ ): Promise {
+ if (channel.provider === 'weixin') {
+ const credential = channel.platformCredential
+ if (credential?.kind !== 'weixin' || !credential.accountId.trim() || !this.deps.sendWeixinBridgeMessage) return
+ const to = remoteSession?.chatId.trim() || channel.remoteSession?.chatId.trim() || ''
+ if (!to) return
+ const result = await this.deps.sendWeixinBridgeMessage({ accountId: credential.accountId, to, text })
+ if (!result.ok) {
+ this.deps.logError('claw-weixin', 'Failed to push delayed result over the WeChat bridge.', {
+ channelId: channel.id,
+ message: result.message
+ })
+ }
+ return
+ }
+ if (channel.provider === 'feishu') {
+ const bridge = this.feishuChannels.get(channel.id)
+ const to = remoteSession?.chatId.trim() || channel.remoteSession?.chatId.trim() || ''
+ if (!bridge || !to) return
+ await this.sendFeishuMessage(
+ bridge,
+ to,
+ { markdown: text },
+ {},
+ { purpose: 'agent-reply-delayed', channelId: channel.id, chatId: to }
+ )
}
- throw new Error('Timed out waiting for agent response.')
}
private resolveChannelWorkspaceRoot(settings: AppSettingsV1, channel?: ClawImChannelV1): string {
@@ -1587,6 +1725,18 @@ export class ClawRuntime {
return
}
+ if (result.ok && result.completed === false) {
+ // The turn outran the response window; the reply below is the ack
+ // (carried on `result.message`). Deliver the real result when the
+ // turn finishes.
+ this.scheduleImResultPush(settings, {
+ channel,
+ remoteSession,
+ threadId: result.threadId,
+ turnId: result.turnId,
+ workspaceRoot
+ })
+ }
const generatedFiles = result.ok ? result.files ?? [] : []
const filesToSend = result.ok && (generatedFiles.length > 0 || shouldSendGeneratedFilesForPrompt(message.content))
? await this.resolveImGeneratedFiles(generatedFiles, workspaceRoot, {
@@ -1955,6 +2105,25 @@ export class ClawRuntime {
writeJson(res, 500, result)
return
}
+ if (result.completed === false) {
+ // The turn outran the response window. Ack now and push the real
+ // result back when it finishes, instead of replying with whatever
+ // intermediate text happened to exist at the timeout.
+ this.scheduleImResultPush(settings, {
+ channel,
+ remoteSession: remoteSession ?? undefined,
+ threadId: result.threadId,
+ turnId: result.turnId,
+ workspaceRoot: this.resolveIncomingWorkspaceRoot(settings, channel, conversation, remoteSession ?? undefined)
+ })
+ writeJson(res, 200, {
+ ok: true,
+ threadId: result.threadId,
+ turnId: result.turnId,
+ reply: `${welcomePrefix}${IM_PROCESSING_ACK}`
+ })
+ return
+ }
// Current-turn deliverable media files ride along in the response so
// push-capable bridges (WeChat) can upload them after the text reply.
// The prompt heuristic remains as a fallback for explicit file-send
@@ -1973,7 +2142,8 @@ export class ClawRuntime {
}
)
: []
- writeJson(res, 200, { ...result, files, reply: `${welcomePrefix}${result.text ?? ''}` })
+ const replyBody = result.text?.trim() || result.message?.trim() || IM_COMPLETED_NO_TEXT_REPLY
+ writeJson(res, 200, { ...result, files, reply: `${welcomePrefix}${replyBody}` })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.deps.logError('claw-webhook', 'Claw IM webhook request failed', { message })
diff --git a/src/main/ipc/app-ipc-schemas.test.ts b/src/main/ipc/app-ipc-schemas.test.ts
index d2ad30aa7..c3e566b9a 100644
--- a/src/main/ipc/app-ipc-schemas.test.ts
+++ b/src/main/ipc/app-ipc-schemas.test.ts
@@ -409,6 +409,28 @@ describe('app-ipc-schemas', () => {
expect(payload.keyboardShortcuts?.bindings?.settings).toEqual(['Ctrl+,'])
})
+ it('accepts a configurable stream idle timeout in runtime tuning patches', () => {
+ const payload = settingsPatchSchema.parse({
+ agents: {
+ kun: {
+ runtimeTuning: {
+ streamIdleTimeoutMs: 300000
+ }
+ }
+ }
+ })
+
+ expect(payload.agents?.kun?.runtimeTuning?.streamIdleTimeoutMs).toBe(300000)
+ })
+
+ it('rejects an out-of-range stream idle timeout', () => {
+ expect(() =>
+ settingsPatchSchema.parse({
+ agents: { kun: { runtimeTuning: { streamIdleTimeoutMs: -1 } } }
+ })
+ ).toThrow()
+ })
+
it('rejects unknown settings patch fields', () => {
expect(() =>
settingsPatchSchema.parse({
diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts
index 777166034..127871390 100644
--- a/src/main/ipc/app-ipc-schemas.ts
+++ b/src/main/ipc/app-ipc-schemas.ts
@@ -94,6 +94,12 @@ export const confirmDialogPayloadSchema = z
})
.strict()
+export const legacySessionImportPayloadSchema = z
+ .object({
+ sourceDir: defaultPathSchema
+ })
+ .strict()
+
export const providerProbePayloadSchema = z
.object({
baseUrl: trimmedString(MAX_URL_LENGTH),
@@ -230,7 +236,8 @@ const modelProfilePatchSchema = z.object({
supportedEfforts: z.array(modelReasoningEffortSchema).min(1).max(8),
defaultEffort: modelReasoningEffortSchema,
requestProtocol: modelReasoningRequestProtocolSchema
- }).strict().optional()
+ }).strict().optional(),
+ endpointFormat: modelEndpointFormatSchema.optional()
}).strict()
const modelProviderPatchSchema = z.object({
@@ -327,6 +334,7 @@ const kunRuntimePatchSchema = z.object({
summaryInputMaxBytes: z.number().int().positive().max(8 * 1024 * 1024).optional()
}).strict().optional(),
runtimeTuning: z.object({
+ streamIdleTimeoutMs: z.number().int().min(0).max(3_600_000).optional(),
toolStorm: z.object({
enabled: z.boolean().optional(),
windowSize: z.number().int().positive().max(128).optional(),
@@ -457,12 +465,28 @@ const writeSelectionAssistPatchSchema = z.object({
quickActions: z.array(writeQuickActionSchema).max(24).optional()
}).strict()
+const writeTypographyPatchSchema = z.object({
+ fontPreset: z.string().max(32).optional(),
+ customFontFamily: z.string().max(200).optional(),
+ fontSizePx: z.number().optional(),
+ lineHeight: z.number().optional()
+}).strict()
+
+const writeAgentPresetSchema = z.object({
+ id: trimmedString(64),
+ name: z.string().max(64).optional(),
+ emoji: z.string().max(16).optional(),
+ persona: z.string().max(4_000).optional()
+}).strict()
+
const writeSettingsPatchSchema = z.object({
defaultWorkspaceRoot: defaultPathSchema,
activeWorkspaceRoot: defaultPathSchema,
workspaces: z.array(trimmedString(MAX_PATH_LENGTH)).max(256).optional(),
inlineCompletion: writeInlineCompletionPatchSchema.optional(),
- selectionAssist: writeSelectionAssistPatchSchema.optional()
+ selectionAssist: writeSelectionAssistPatchSchema.optional(),
+ typography: writeTypographyPatchSchema.optional(),
+ agentPresets: z.array(writeAgentPresetSchema).max(24).optional()
}).strict()
const clawSkillPatchSchema = z.object({
diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts
index b2d441ff4..4a1099894 100644
--- a/src/main/ipc/register-app-ipc-handlers.ts
+++ b/src/main/ipc/register-app-ipc-handlers.ts
@@ -75,8 +75,11 @@ import {
writeInlineCompletionPayloadSchema,
writePrototypeFilePayloadSchema,
writeRetrievalPayloadSchema,
- workspaceRootSchema
+ workspaceRootSchema,
+ legacySessionImportPayloadSchema
} from './app-ipc-schemas'
+import { DEFAULT_KUN_DATA_DIR, resolveKunRuntimeSettings } from '../../shared/app-settings'
+import { detectLegacySessions, importLegacySessions } from '../services/legacy-session-import-service'
import type { JsonSettingsStore } from '../settings-store'
import { probeModelProvider } from '../provider-connection'
import type { ClawRuntime } from '../claw-runtime'
@@ -771,6 +774,49 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions):
}
})
+ const resolveKunThreadsDataDir = async (): Promise => {
+ const settings = await store.load()
+ const runtime = resolveKunRuntimeSettings(settings)
+ return expandHomePath(runtime.dataDir?.trim() || DEFAULT_KUN_DATA_DIR)
+ }
+
+ ipcMain.handle('kun:sessions:detect-legacy', async () =>
+ detectLegacySessions({ homeDir: homedir(), destDataDir: await resolveKunThreadsDataDir() })
+ )
+
+ ipcMain.handle('kun:sessions:import-legacy', async (_, payload: unknown) => {
+ const request = parseIpcPayload('kun:sessions:import-legacy', legacySessionImportPayloadSchema, payload)
+ try {
+ const summary = await importLegacySessions({
+ homeDir: homedir(),
+ destDataDir: await resolveKunThreadsDataDir(),
+ ...(request.sourceDir ? { sourceDir: request.sourceDir } : {}),
+ log: (message, detail) => logError('legacy-session-import', message, detail)
+ })
+ return { ok: true as const, ...summary }
+ } catch (error) {
+ return {
+ ok: false as const,
+ message: error instanceof Error ? error.message : String(error)
+ }
+ }
+ })
+
+ ipcMain.handle('kun:sessions:pick-source-dir', async (): Promise => {
+ const options: Electron.OpenDialogOptions = {
+ title: 'Select a folder containing previous conversations',
+ properties: ['openDirectory', 'dontAddToRecent']
+ }
+ const mainWindow = getMainWindow()
+ const result = mainWindow
+ ? await dialog.showOpenDialog(mainWindow, options)
+ : await dialog.showOpenDialog(options)
+ return {
+ canceled: result.canceled,
+ path: result.canceled ? null : (result.filePaths[0] ?? null)
+ }
+ })
+
ipcMain.handle('git:branches', async (_, workspaceRoot: unknown) =>
getGitBranches(parseIpcPayload('git:branches', workspaceRootSchema, workspaceRoot))
)
diff --git a/src/main/kun-process.test.ts b/src/main/kun-process.test.ts
index f2cc3ef17..f0a1160f2 100644
--- a/src/main/kun-process.test.ts
+++ b/src/main/kun-process.test.ts
@@ -335,6 +335,7 @@ describe('syncGuiManagedKunConfig', () => {
hardThreshold: 990_000
}
})
+ expect(parsed.runtime.streamIdleTimeoutMs).toBe(45000)
expect(parsed.runtime.toolStorm).toMatchObject({ enabled: true, windowSize: 8, threshold: 3 })
expect(parsed.runtime.toolArgumentRepair).toMatchObject({ maxStringBytes: 524288 })
expect(parsed.capabilities.attachments).toMatchObject({ enabled: true })
@@ -697,6 +698,7 @@ describe('syncGuiManagedKunConfig', () => {
summaryInputMaxBytes: 131072
},
runtimeTuning: {
+ streamIdleTimeoutMs: 120000,
toolStorm: {
enabled: false,
windowSize: 12,
@@ -786,6 +788,7 @@ describe('syncGuiManagedKunConfig', () => {
expect(parsed.runtime.toolStorm.customStormFlag).toBeUndefined()
expect(parsed.runtime.customRuntimeFlag).toBeUndefined()
expect(parsed.runtime.toolArgumentRepair).toMatchObject({ maxStringBytes: 262144 })
+ expect(parsed.runtime.streamIdleTimeoutMs).toBe(120000)
expect(parsed.capabilities.attachments).toMatchObject({ enabled: true })
expect(parsed.capabilities.mcp.servers.github.command).toBe('github-mcp')
expect(parsed.capabilities.web.fetchEnabled).toBe(true)
diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts
index e9028d463..4679ffe0c 100644
--- a/src/main/kun-process.ts
+++ b/src/main/kun-process.ts
@@ -678,7 +678,8 @@ function modelConfigProfilesFromProviderProfiles(
outputModalities: profile.outputModalities,
supportsToolCalling: profile.supportsToolCalling,
messageParts: profile.messageParts,
- ...(profile.reasoning ? { reasoning: profile.reasoning } : {})
+ ...(profile.reasoning ? { reasoning: profile.reasoning } : {}),
+ ...(profile.endpointFormat ? { endpointFormat: profile.endpointFormat } : {})
}
}
return out
@@ -851,6 +852,7 @@ function runtimeTuningConfigForRuntime(
const existingToolArgumentRepair = objectValue(existing.toolArgumentRepair)
return {
...existing,
+ streamIdleTimeoutMs: runtimeTuning.streamIdleTimeoutMs,
toolStorm: {
...existingToolStorm,
enabled: runtimeTuning.toolStorm.enabled,
diff --git a/src/main/services/legacy-session-import-service.test.ts b/src/main/services/legacy-session-import-service.test.ts
new file mode 100644
index 000000000..94cce4af1
--- /dev/null
+++ b/src/main/services/legacy-session-import-service.test.ts
@@ -0,0 +1,165 @@
+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ defaultLegacySourceCandidates,
+ detectLegacySessions,
+ importLegacySessions
+} from './legacy-session-import-service'
+
+const tempRoots: string[] = []
+
+async function makeTempRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'kun-session-import-'))
+ tempRoots.push(root)
+ return root
+}
+
+/** Create a thread folder with a minimal metadata.jsonl so it hydrates like the real store. */
+async function writeThread(threadsDir: string, threadId: string, title = threadId): Promise {
+ const dir = join(threadsDir, threadId)
+ await mkdir(dir, { recursive: true })
+ const metadata = {
+ kind: 'thread_metadata',
+ version: 1,
+ timestamp: '2026-06-15T00:00:00.000Z',
+ thread: { id: threadId, title, turns: [] }
+ }
+ await writeFile(join(dir, 'metadata.jsonl'), `${JSON.stringify(metadata)}\n`, 'utf8')
+ await writeFile(join(dir, 'messages.jsonl'), '', 'utf8')
+ await writeFile(join(dir, 'events.jsonl'), '', 'utf8')
+}
+
+afterEach(async () => {
+ while (tempRoots.length > 0) {
+ const root = tempRoots.pop()
+ if (root) await rm(root, { recursive: true, force: true })
+ }
+})
+
+describe('defaultLegacySourceCandidates', () => {
+ it('points at the legacy DeepSeek GUI kun and coreagent threads dirs', () => {
+ const candidates = defaultLegacySourceCandidates('/home/zoe')
+ expect(candidates.map((c) => c.path)).toEqual([
+ join('/home/zoe', '.deepseekgui', 'kun', 'threads'),
+ join('/home/zoe', '.deepseekgui', 'coreagent', 'threads')
+ ])
+ expect(candidates.map((c) => c.kind)).toEqual(['kun', 'coreagent'])
+ })
+})
+
+describe('detectLegacySessions', () => {
+ it('reports thread counts and how many are new vs the destination', async () => {
+ const root = await makeTempRoot()
+ const kunThreads = join(root, '.deepseekgui', 'kun', 'threads')
+ await writeThread(kunThreads, 'thr_a')
+ await writeThread(kunThreads, 'thr_b')
+ const coreagentThreads = join(root, '.deepseekgui', 'coreagent', 'threads')
+ await writeThread(coreagentThreads, 'thr_c')
+ // One of the kun threads already exists in the destination.
+ const dataDir = join(root, '.kun', 'data')
+ await writeThread(join(dataDir, 'threads'), 'thr_a')
+
+ const detection = await detectLegacySessions({ homeDir: root, destDataDir: dataDir })
+
+ expect(detection.destDir).toBe(join(dataDir, 'threads'))
+ const kun = detection.sources.find((s) => s.kind === 'kun')
+ expect(kun).toMatchObject({ threadCount: 2, newCount: 1 })
+ const coreagent = detection.sources.find((s) => s.kind === 'coreagent')
+ expect(coreagent).toMatchObject({ threadCount: 1, newCount: 1 })
+ })
+
+ it('omits sources that do not exist', async () => {
+ const root = await makeTempRoot()
+ await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_a')
+ const detection = await detectLegacySessions({
+ homeDir: root,
+ destDataDir: join(root, '.kun', 'data')
+ })
+ expect(detection.sources.map((s) => s.kind)).toEqual(['kun'])
+ })
+})
+
+describe('importLegacySessions', () => {
+ it('copies all auto-detected legacy threads into the destination', async () => {
+ const root = await makeTempRoot()
+ await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_a')
+ await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_b')
+ await writeThread(join(root, '.deepseekgui', 'coreagent', 'threads'), 'thr_c')
+ const dataDir = join(root, '.kun', 'data')
+
+ const summary = await importLegacySessions({ homeDir: root, destDataDir: dataDir })
+
+ expect(summary).toMatchObject({ total: 3, imported: 3, skipped: 0 })
+ const copied = (await readdir(join(dataDir, 'threads'))).sort()
+ expect(copied).toEqual(['thr_a', 'thr_b', 'thr_c'])
+ // Content is copied verbatim (no transformation).
+ const meta = await readFile(join(dataDir, 'threads', 'thr_a', 'metadata.jsonl'), 'utf8')
+ expect(meta).toContain('"id":"thr_a"')
+ })
+
+ it('never overwrites a thread that already exists in the destination', async () => {
+ const root = await makeTempRoot()
+ await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_a', 'legacy title')
+ const dataDir = join(root, '.kun', 'data')
+ await writeThread(join(dataDir, 'threads'), 'thr_a', 'current title')
+
+ const summary = await importLegacySessions({ homeDir: root, destDataDir: dataDir })
+
+ expect(summary).toMatchObject({ total: 1, imported: 0, skipped: 1 })
+ const meta = await readFile(join(dataDir, 'threads', 'thr_a', 'metadata.jsonl'), 'utf8')
+ expect(meta).toContain('current title')
+ expect(meta).not.toContain('legacy title')
+ })
+
+ it('imports from an explicitly chosen folder, descending into a threads subdir', async () => {
+ const root = await makeTempRoot()
+ // User picks the parent (…/backup/kun), not the threads dir itself.
+ const pickedParent = join(root, 'backup', 'kun')
+ await writeThread(join(pickedParent, 'threads'), 'thr_x')
+ const dataDir = join(root, '.kun', 'data')
+
+ const summary = await importLegacySessions({
+ homeDir: root,
+ destDataDir: dataDir,
+ sourceDir: pickedParent
+ })
+
+ expect(summary).toMatchObject({ total: 1, imported: 1, skipped: 0 })
+ expect(await readdir(join(dataDir, 'threads'))).toEqual(['thr_x'])
+ })
+
+ it('ignores non-thread entries and accepts marker-only folders', async () => {
+ const root = await makeTempRoot()
+ const source = join(root, 'backup')
+ await writeThread(source, 'thr_a')
+ // A loose index file and an unrelated directory must be ignored.
+ await writeFile(join(source, 'index.json'), '{}', 'utf8')
+ await mkdir(join(source, 'notes'), { recursive: true })
+ await writeFile(join(source, 'notes', 'todo.txt'), 'hi', 'utf8')
+ // A folder without the thr_ prefix but containing a thread marker is accepted.
+ const oddDir = join(source, 'session-1')
+ await mkdir(oddDir, { recursive: true })
+ await writeFile(join(oddDir, 'thread.json'), '{"id":"session-1","title":"x","turns":[]}', 'utf8')
+ const dataDir = join(root, '.kun', 'data')
+
+ const summary = await importLegacySessions({
+ homeDir: root,
+ destDataDir: dataDir,
+ sourceDir: source
+ })
+
+ expect(summary.imported).toBe(2)
+ expect((await readdir(join(dataDir, 'threads'))).sort()).toEqual(['session-1', 'thr_a'])
+ })
+
+ it('returns zero when there is nothing to import', async () => {
+ const root = await makeTempRoot()
+ const summary = await importLegacySessions({
+ homeDir: root,
+ destDataDir: join(root, '.kun', 'data')
+ })
+ expect(summary).toMatchObject({ total: 0, imported: 0, skipped: 0 })
+ })
+})
diff --git a/src/main/services/legacy-session-import-service.ts b/src/main/services/legacy-session-import-service.ts
new file mode 100644
index 000000000..0637fc98e
--- /dev/null
+++ b/src/main/services/legacy-session-import-service.ts
@@ -0,0 +1,207 @@
+import { cp, mkdir, readdir, realpath, stat } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import type {
+ LegacySessionDetectResult,
+ LegacySessionDetectedSource,
+ LegacySessionImportSummary,
+ LegacySessionSourceKind
+} from '../../shared/kun-gui-api'
+
+/**
+ * 把“DeepSeek GUI”时代遗留的会话目录导入到当前 Kun 数据目录。
+ *
+ * 关键事实(决定了实现方式):
+ * - 新旧版本会话的磁盘格式完全一致:每个线程一个目录,内含
+ * metadata.jsonl / messages.jsonl / events.jsonl(早期还有 thread.json)。
+ * - HybridThreadStore 把 JSONL 文件当作权威来源、SQLite 只是可重建的索引;
+ * 启动时的 backfill() 会扫描线程目录并把未入库的线程补进索引。
+ * 因此“导入”本质上只是把线程目录拷进 {dataDir}/threads,再重启运行时
+ * 触发 backfill 即可——无需任何格式转换。
+ *
+ * 设计约束:
+ * 1. 绝不覆盖目标已存在的线程目录(按目录名/线程 ID 去重),保证幂等且非破坏性。
+ * 2. 拷贝而非移动,旧数据原地保留作为兜底。
+ * 3. 任何单个线程拷贝失败都被吞掉并计入 skipped,不让整个导入中断。
+ *
+ * 这个模块刻意不 import electron,方便在 vitest 里注入临时目录直接测试。
+ */
+
+const THREAD_DIR_MARKERS = ['metadata.jsonl', 'thread.json', 'messages.jsonl'] as const
+
+export type LegacySessionSourceCandidate = {
+ id: string
+ kind: LegacySessionSourceKind
+ /** 旧版线程目录的绝对路径,如 ~/.deepseekgui/kun/threads。 */
+ path: string
+}
+
+export type LegacySessionImportLogger = (message: string, detail?: unknown) => void
+
+/**
+ * 自动检测的旧数据来源。顺序即展示优先级:先列近期版本(kun),
+ * 再列更早的 coreagent 时代数据。两者磁盘格式相同。
+ */
+export function defaultLegacySourceCandidates(homeDir: string): LegacySessionSourceCandidate[] {
+ return [
+ { id: 'deepseekgui-kun', kind: 'kun', path: join(homeDir, '.deepseekgui', 'kun', 'threads') },
+ {
+ id: 'deepseekgui-coreagent',
+ kind: 'coreagent',
+ path: join(homeDir, '.deepseekgui', 'coreagent', 'threads')
+ }
+ ]
+}
+
+async function pathExists(target: string): Promise {
+ try {
+ await stat(target)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/** realpath 解析失败(路径不存在等)时回退到原路径,只用于同目录判定。 */
+async function safeRealpath(target: string): Promise {
+ try {
+ return await realpath(target)
+ } catch {
+ return target
+ }
+}
+
+/**
+ * 列出 parent 下“看起来像线程目录”的子目录名。判定:目录名以 thr_ 开头,
+ * 或目录内含已知线程标志文件(兼容自定义命名 / 更早格式)。
+ */
+async function listThreadDirNames(parent: string): Promise {
+ const entries = await readdir(parent, { withFileTypes: true }).catch(() => null)
+ if (!entries) return []
+ const names: string[] = []
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ if (entry.name.startsWith('thr_')) {
+ names.push(entry.name)
+ continue
+ }
+ const dir = join(parent, entry.name)
+ for (const marker of THREAD_DIR_MARKERS) {
+ if (await pathExists(join(dir, marker))) {
+ names.push(entry.name)
+ break
+ }
+ }
+ }
+ return names
+}
+
+/**
+ * 把用户手选的文件夹解析成真正的 threads 目录:既支持直接选中 threads 目录,
+ * 也支持选中它的上级(如 .../kun),自动下探一层 threads。
+ */
+async function resolveSourceThreadsDir(picked: string): Promise {
+ if ((await listThreadDirNames(picked)).length > 0) return picked
+ const nested = join(picked, 'threads')
+ if ((await listThreadDirNames(nested)).length > 0) return nested
+ return picked
+}
+
+/** 检测可导入的旧会话来源,以及其中有多少是目标里尚不存在的。 */
+export async function detectLegacySessions(input: {
+ destDataDir: string
+ homeDir?: string
+}): Promise {
+ const homeDir = input.homeDir ?? homedir()
+ const destDir = join(input.destDataDir, 'threads')
+ const destReal = await safeRealpath(destDir)
+ const existing = new Set(await listThreadDirNames(destDir))
+
+ const sources: LegacySessionDetectedSource[] = []
+ for (const candidate of defaultLegacySourceCandidates(homeDir)) {
+ if (!(await pathExists(candidate.path))) continue
+ // 已经是当前数据目录本身(老版本启动迁移留下的符号链接)——无需再导入。
+ if ((await safeRealpath(candidate.path)) === destReal) continue
+ const names = await listThreadDirNames(candidate.path)
+ if (names.length === 0) continue
+ const newCount = names.reduce((count, name) => (existing.has(name) ? count : count + 1), 0)
+ sources.push({
+ id: candidate.id,
+ kind: candidate.kind,
+ path: candidate.path,
+ threadCount: names.length,
+ newCount
+ })
+ }
+ return { destDir, sources }
+}
+
+/**
+ * 执行导入。sourceDir 为空 = 导入所有自动检测到的默认来源;否则只导入用户
+ * 手选的目录。已存在的线程目录一律跳过(skipped),不覆盖。
+ */
+export async function importLegacySessions(input: {
+ destDataDir: string
+ homeDir?: string
+ sourceDir?: string
+ log?: LegacySessionImportLogger
+}): Promise {
+ const homeDir = input.homeDir ?? homedir()
+ const destDir = join(input.destDataDir, 'threads')
+ await mkdir(destDir, { recursive: true })
+ const destReal = await safeRealpath(destDir)
+
+ const sourceDirs: string[] = []
+ const picked = input.sourceDir?.trim()
+ if (picked) {
+ sourceDirs.push(await resolveSourceThreadsDir(picked))
+ } else {
+ for (const candidate of defaultLegacySourceCandidates(homeDir)) {
+ if (await pathExists(candidate.path)) sourceDirs.push(candidate.path)
+ }
+ }
+
+ const summary: LegacySessionImportSummary = {
+ destDir,
+ total: 0,
+ imported: 0,
+ skipped: 0,
+ sources: []
+ }
+
+ for (const sourceDir of sourceDirs) {
+ // 跳过指向目标本身的来源,避免把目录拷进自己。
+ if ((await safeRealpath(sourceDir)) === destReal) continue
+ const names = await listThreadDirNames(sourceDir)
+ let imported = 0
+ let skipped = 0
+ for (const name of names) {
+ const target = join(destDir, name)
+ if (await pathExists(target)) {
+ skipped += 1
+ continue
+ }
+ try {
+ await cp(join(sourceDir, name), target, {
+ recursive: true,
+ preserveTimestamps: true,
+ errorOnExist: false
+ })
+ imported += 1
+ } catch (error) {
+ input.log?.('legacy-session-import: failed to copy thread', {
+ name,
+ sourceDir,
+ message: error instanceof Error ? error.message : String(error)
+ })
+ skipped += 1
+ }
+ }
+ summary.sources.push({ path: sourceDir, total: names.length, imported, skipped })
+ summary.total += names.length
+ summary.imported += imported
+ summary.skipped += skipped
+ }
+
+ return summary
+}
diff --git a/src/main/services/worktree-service.ts b/src/main/services/worktree-service.ts
index 02e58abb9..ae1438b37 100644
--- a/src/main/services/worktree-service.ts
+++ b/src/main/services/worktree-service.ts
@@ -150,8 +150,16 @@ export async function listWorktrees(params: {
}): Promise {
const { projectPath, worktreeRoot } = params
const poolDir = resolvePoolDir(projectPath, worktreeRoot)
- const mainBranch = await detectMainBranch(projectPath)
- const headCommit = await getHeadCommit(projectPath)
+
+ let mainBranch: string
+ let headCommit: string
+ try {
+ mainBranch = await detectMainBranch(projectPath)
+ headCommit = await getHeadCommit(projectPath)
+ } catch {
+ return { projectPath, poolDir, mainBranch: '', headCommit: '', worktrees: [], inUseCount: 0, isGitRepo: false }
+ }
+
const worktrees: WorktreeInfo[] = []
let inUseCount = 0
@@ -177,7 +185,7 @@ export async function listWorktrees(params: {
})
}
- return { projectPath, poolDir, mainBranch, headCommit, worktrees, inUseCount }
+ return { projectPath, poolDir, mainBranch, headCommit, worktrees, inUseCount, isGitRepo: true }
}
export async function removeWorktree(params: {
diff --git a/src/main/services/write-inline-completion-service.test.ts b/src/main/services/write-inline-completion-service.test.ts
index fd2708840..99bae087b 100644
--- a/src/main/services/write-inline-completion-service.test.ts
+++ b/src/main/services/write-inline-completion-service.test.ts
@@ -815,4 +815,60 @@ describe('parseWriteInlineAction', () => {
scopeKind: 'selection'
})
})
+
+ it('returns an empty completion for a malformed marker skeleton instead of leaking markers', () => {
+ // Regression: a degenerate single-line skeleton used to fall through to the
+ // plain-text fallback and render ">>> <<>> <<>> <<>> <<>> <<>> <<>>', { fallbackKind: 'long' })).toEqual({
+ kind: 'long',
+ text: ''
+ })
+ })
+
+ it('returns an empty completion when the model parrots the protocol template', () => {
+ const template = [
+ '<<>>',
+ '<<>>',
+ '<<>>'
+ ].join('\n')
+ expect(parseWriteInlineAction(template)).toEqual({ kind: 'short', text: '' })
+ })
+
+ it('parses same-line marked blocks', () => {
+ expect(parseWriteInlineAction('<<>>')).toEqual({
+ kind: 'short',
+ text: 'next words'
+ })
+ })
+
+ it('prefers the first non-empty block when an earlier block is empty', () => {
+ expect(parseWriteInlineAction('<<>>\n<<>>')).toEqual({
+ kind: 'long',
+ text: 'A fuller continuation.'
+ })
+ })
+
+ it('extracts a block that dropped its closing marker without swallowing the next marker', () => {
+ expect(parseWriteInlineAction('<<>>')).toEqual({
+ kind: 'short',
+ text: 'next words'
+ })
+ })
+
+ it('keeps plain text that legitimately contains >>> when no protocol marker is present', () => {
+ expect(parseWriteInlineAction('>>> a Python prompt')).toEqual({
+ kind: 'short',
+ text: '>>> a Python prompt'
+ })
+ })
})
diff --git a/src/main/services/write-inline-completion-service.ts b/src/main/services/write-inline-completion-service.ts
index 7d16830b8..122f280a9 100644
--- a/src/main/services/write-inline-completion-service.ts
+++ b/src/main/services/write-inline-completion-service.ts
@@ -35,6 +35,21 @@ const MAX_INLINE_COMPLETION_DEBUG_ENTRIES = 120
const MAX_DEBUG_TEXT_CHARS = 80_000
const INPUT_BOUNDARY_MARKERS = ['PREFIX', 'SUFFIX', 'EDIT_SCOPE'] as const
const OUTPUT_ACTION_MARKERS = ['SHORT', 'LONG', 'EDIT'] as const
+// Every protocol marker name, longest first so the alternation prefers EDIT_SCOPE
+// over EDIT. Used to terminate a marked body that lost its closing >>> and to
+// scrub malformed marker soup that must never reach the ghost text.
+const PROTOCOL_MARKER_NAMES = [...INPUT_BOUNDARY_MARKERS, ...OUTPUT_ACTION_MARKERS]
+ .slice()
+ .sort((a, b) => b.length - a.length)
+ .join('|')
+const PROTOCOL_MARKER_OPENER = new RegExp(`<<<[ \\t]*(?:${PROTOCOL_MARKER_NAMES})\\b`, 'i')
+// The placeholder lines from buildResponseProtocolPromptBlock(). A weak model
+// sometimes parrots them verbatim; such an echo is never a real suggestion.
+const PROTOCOL_PLACEHOLDER_BODIES = new Set([
+ 'short text to insert at the cursor',
+ 'longer continuation to insert at the cursor',
+ 'replacement text for the editable local scope'
+])
type ChatCompletionResponse = {
choices?: Array<{
@@ -642,15 +657,50 @@ function containsInputBoundaryEcho(text: string): boolean {
text.includes('Return only the text to insert at the cursor.')
}
+/**
+ * Strip protocol marker tokens that leaked into a malformed response so they can
+ * never surface as ghost text. Guarded on an actual marker opener being present,
+ * so ordinary prose that merely contains ">>>" (a REPL transcript, a merge
+ * conflict marker) is returned untouched.
+ */
+function stripActionMarkerArtifacts(text: string): string {
+ if (!PROTOCOL_MARKER_OPENER.test(text)) return text
+ return text
+ .replace(new RegExp(`<<<[ \\t]*(?:${PROTOCOL_MARKER_NAMES})\\b[ \\t]*`, 'gi'), '')
+ .replace(/>>>/g, '')
+ // Drop any line that is a bare echo of the protocol placeholder text, so a
+ // full-template parrot collapses to empty rather than leaking the sample lines.
+ .split('\n')
+ .filter((line) => !PROTOCOL_PLACEHOLDER_BODIES.has(line.trim().toLowerCase()))
+ .join('\n')
+ .replace(/[ \t]+$/gm, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
function parseMarkedActionBlock(
text: string,
options: { editTarget?: WriteInlineActionEditTarget }
): WriteInlineCompletionAction | null {
+ // A body ends at the first closing >>> or the next protocol opener, whichever
+ // comes first, so a block that dropped its >>> never swallows later markers.
+ const bodyTerminator = new RegExp(`>>>|<<<[ \\t]*(?:${PROTOCOL_MARKER_NAMES})\\b`, 'i')
for (const marker of OUTPUT_ACTION_MARKERS) {
- const exact = new RegExp(`^<<<[ \\t]*${marker}[ \\t]*\\n([\\s\\S]*?)\\n?>>>$`, 'i').exec(text)
- const embedded = exact ?? new RegExp(`<<<[ \\t]*${marker}[ \\t]*\\n([\\s\\S]*?)\\n?>>>`, 'i').exec(text)
- if (!embedded) continue
- const body = trimMarkerPadding(embedded[1])
+ // Tolerant opener: trailing spaces/tabs and an optional single newline after
+ // the keyword, so same-line bodies (<<>>) parse too.
+ const opener = new RegExp(`<<<[ \\t]*${marker}\\b[ \\t]*\\n?`, 'i').exec(text)
+ if (!opener) continue
+ const rest = text.slice(opener.index + opener[0].length)
+ const end = rest.search(bodyTerminator)
+ // Drop horizontal whitespace abutting the close marker, then a single
+ // trailing newline; leading whitespace is kept so a continuation like
+ // " next words" stays intact.
+ const body = trimMarkerPadding((end >= 0 ? rest.slice(0, end) : rest).replace(/[ \t]+$/, ''))
+ // Skip empty blocks (a contentless SHORT must not shadow a filled LONG, and
+ // a pure marker skeleton must fall through to the scrubbing fallback below)
+ // and skip a verbatim echo of the protocol's own placeholder text.
+ const condensed = body.trim().toLowerCase()
+ if (!condensed || PROTOCOL_PLACEHOLDER_BODIES.has(condensed)) continue
if (marker === 'SHORT') return completionAction(body, 'short')
if (marker === 'LONG') return completionAction(body, 'long')
return editAction(body, options.editTarget)
@@ -716,9 +766,13 @@ export function parseWriteInlineAction(
const labeledEdit = trimmed.match(/^(?:edit|replacement|replace|new text|edited text|替换文本|修改后|修改|替换)[::]\s*([\s\S]*)$/i)
if (labeledEdit) return editAction(labeledEdit[1], options.editTarget)
+ // Last resort: treat the response as plain insertable text, but scrub any
+ // leaked protocol markers first so a malformed skeleton (">>> <<>>
+ // << 0 ? writeWorkspaces : [writeDefaultRoot],
- inlineCompletion: normalized.write.inlineCompletion,
- selectionAssist: normalized.write.selectionAssist
+ workspaces: writeWorkspaces.length > 0 ? writeWorkspaces : [writeDefaultRoot]
},
claw: {
...normalized.claw,
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 79ad234ad..a6d717336 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -27,6 +27,12 @@ const api = {
ipcRenderer.invoke('workspace:pick-directory', defaultPath),
confirmDialog: (options) =>
ipcRenderer.invoke('dialog:confirm', options),
+ detectLegacySessions: () =>
+ ipcRenderer.invoke('kun:sessions:detect-legacy'),
+ importLegacySessions: (sourceDir) =>
+ ipcRenderer.invoke('kun:sessions:import-legacy', { sourceDir }),
+ pickLegacySessionDir: () =>
+ ipcRenderer.invoke('kun:sessions:pick-source-dir'),
listSkills: (workspaceRoot) =>
ipcRenderer.invoke('skill:list', { workspaceRoot }),
listSkillRoots: (workspaceRoot) =>
diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx
index accafa444..3dcdd46d4 100644
--- a/src/renderer/src/components/SettingsView.tsx
+++ b/src/renderer/src/components/SettingsView.tsx
@@ -24,7 +24,7 @@ import type {
CoreRuntimeToolDiagnosticsJson
} from '../agent/kun-contract'
import type { WriteInlineCompletionDebugEntry } from '@shared/write-inline-completion'
-import { applyTheme, applyUiFontScale } from '../lib/apply-theme'
+import { applyTheme, applyUiFontScale, applyWriteTypography } from '../lib/apply-theme'
import { formatWorkspacePickerError } from '../lib/format-workspace-picker-error'
import type { SkillRootListItem } from '@shared/kun-gui-api'
import { normalizeWorkspaceRoot } from '../lib/workspace-path'
@@ -124,6 +124,7 @@ export function SettingsView(): ReactElement {
const permissionsSectionRef = useRef(null)
const formTheme = form?.theme
const formUiFontScale = form?.uiFontScale
+ const writeTypography = form?.write?.typography
const formWorkspaceRoot = form?.workspaceRoot
const formKun = form ? getKunRuntimeSettings(form) : null
const formPort = formKun?.port
@@ -172,6 +173,18 @@ export function SettingsView(): ReactElement {
applyUiFontScale(formUiFontScale)
}, [formTheme, formUiFontScale])
+ // Live-preview the Write editor typography as the form changes, mirroring the
+ // theme/scale preview above. Keyed on the scalar fields so it only re-applies
+ // on real changes.
+ useEffect(() => {
+ if (writeTypography) applyWriteTypography(writeTypography)
+ }, [
+ writeTypography?.fontPreset,
+ writeTypography?.customFontFamily,
+ writeTypography?.fontSizePx,
+ writeTypography?.lineHeight
+ ])
+
useEffect(() => {
const onSettingsChanged = (event: Event): void => {
const next = (event as CustomEvent).detail
diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx
index cc4972e60..1a0127d03 100644
--- a/src/renderer/src/components/Workbench.tsx
+++ b/src/renderer/src/components/Workbench.tsx
@@ -55,6 +55,7 @@ import { SddAssistantPanel } from './sdd/SddAssistantPanel'
import { SddDraftEditorView } from './sdd/SddDraftEditorView'
import { SidebarTitlebarToggleButton } from './sidebar/SidebarPrimitives'
import { composeWritePrompt } from '../write/quoted-selection'
+import { resolveWriteAgentPreset } from '../write/agent-presets'
import { useWriteWorkspaceStore } from '../write/write-workspace-store'
import { isWriteThreadId } from '../write/write-thread-registry'
import { buildSddDraftId, createSddDraft, forgetRememberedSddDraft, useSddDraftStore } from '../sdd/sdd-draft-store'
@@ -1075,10 +1076,15 @@ export function Workbench(): ReactElement {
}
}
const messageText = v || t('composerImageOnlyPrompt')
+ const activeAgentPreset = writeState.agentPresets.find(
+ (preset) => preset.id === writeState.assistantAgentPresetId
+ )
+ const agentPersona = activeAgentPreset ? resolveWriteAgentPreset(activeAgentPreset).persona : ''
const prompt = composeWritePrompt(messageText, writeState.quotedSelections, {
workspaceRoot: writeWorkspaceRoot,
activeFilePath: writeState.activeFilePath,
- retrieval
+ retrieval,
+ ...(agentPersona ? { agentPersona } : {})
})
const model = writeState.assistantModel.trim()
const providerId =
@@ -2185,7 +2191,6 @@ export function Workbench(): ReactElement {
onWriteOpen={openWriteMode}
onOpenSettings={(section) => openSettings(section)}
onToggleConnectPhone={toggleConnectPhone}
- onToggleSidebar={toggleLeftSidebar}
/>
) : (
)}
@@ -2265,6 +2269,7 @@ export function Workbench(): ReactElement {
input={input}
setInput={setInput}
onSubmitPrompt={sendWritePrompt}
+ onOpenAgentSettings={() => openSettings('write')}
/>
{renderRightPanel()}
@@ -2296,13 +2301,11 @@ export function Workbench(): ReactElement {
leftSidebarCollapsed ? 'ds-window-controls-safe-inset' : ''
}`}
>
- {leftSidebarCollapsed ? (
-
- ) : null}
+
@@ -2361,6 +2364,15 @@ export function Workbench(): ReactElement {
busy={busy}
runtimeReady={runtimeConnection === 'ready'}
hasActiveThread={Boolean(activeThreadId)}
+ contextWindowTokens={runtimeInfo?.capabilities.model.contextWindowTokens}
+ runtimeToolCount={
+ runtimeInfo
+ ? runtimeInfo.capabilities.mcp.search?.active
+ ? runtimeInfo.capabilities.mcp.search.advertisedToolCount
+ : runtimeInfo.capabilities.mcp.toolCount
+ : undefined
+ }
+ runtimeSkillCount={runtimeInfo?.capabilities.skills.discoveredSkills}
composerModel={
route === 'claw'
? clawChannels.find((channel) => channel.id === activeClawChannelId)?.model ?? 'auto'
diff --git a/src/renderer/src/components/chat/ContextCapacityPopover.tsx b/src/renderer/src/components/chat/ContextCapacityPopover.tsx
new file mode 100644
index 000000000..dbe102ddc
--- /dev/null
+++ b/src/renderer/src/components/chat/ContextCapacityPopover.tsx
@@ -0,0 +1,146 @@
+import type { ReactElement } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { ContextCapacity, ContextCategoryKey } from '../../lib/context-capacity'
+import { formatCompactNumber, formatPercent } from '../../hooks/use-thread-usage'
+
+const CATEGORY_COLORS: Record
= {
+ tools: '#3b82d8',
+ system: '#8b7be8',
+ skills: '#1d9e75',
+ messages: '#e0673a',
+ other: '#d8910d'
+}
+
+const CATEGORY_ORDER: ContextCategoryKey[] = ['tools', 'system', 'skills', 'messages', 'other']
+
+const WARN_RATIO = 0.75
+
+function stateColor(usedRatio: number, thresholdRatio: number): string {
+ if (usedRatio >= thresholdRatio) return '#d9544e'
+ if (usedRatio >= WARN_RATIO) return '#d9920f'
+ return 'var(--ds-accent)'
+}
+
+type Props = {
+ capacity: ContextCapacity
+ /** Approximate auto-compaction trigger, as a share of the window. */
+ thresholdRatio?: number
+}
+
+export function ContextCapacityPopover({ capacity, thresholdRatio = 0.9 }: Props): ReactElement {
+ const { t } = useTranslation()
+ const accent = stateColor(capacity.usedRatio, thresholdRatio)
+
+ const labelFor = (key: ContextCategoryKey): string =>
+ t(`contextCapacityCat_${key}`, { defaultValue: key })
+
+ const visibleSegments = CATEGORY_ORDER.map((key) =>
+ capacity.categories.find((c) => c.key === key)
+ ).filter((c): c is NonNullable => Boolean(c) && (c?.tokens ?? 0) > 0)
+
+ const statusText =
+ capacity.usedRatio >= thresholdRatio
+ ? t('contextCapacityOverLimit')
+ : capacity.usedRatio >= WARN_RATIO
+ ? t('contextCapacityNearLimit')
+ : t('contextCapacityShareNote')
+
+ return (
+
+
+ {t('contextCapacityTitle')}
+
+
+ {formatCompactNumber(capacity.usedTokens)}
+
+ {' / '}
+ {formatCompactNumber(capacity.windowTokens)}
+ {' · '}
+
+ {formatPercent(capacity.usedRatio)}
+
+
+
+
+
+
+ {visibleSegments.map((segment) => (
+
+ ))}
+
+
+
+
+
+
+ {statusText}
+
+
+ {t('contextCapacityThresholdLabel', { percent: formatPercent(thresholdRatio) })}
+
+
+
+
+ {CATEGORY_ORDER.map((key) => {
+ const category = capacity.categories.find((c) => c.key === key)
+ if (!category) return null
+ return (
+
+
+ {labelFor(key)}
+
+ {formatCompactNumber(category.tokens)}
+
+
+ {formatPercent(category.ratio)}
+
+
+ )
+ })}
+
+
+
+ {t('contextCapacityCat_free')}
+
+ {formatCompactNumber(capacity.freeTokens)}
+
+
+ {formatPercent(capacity.freeRatio)}
+
+
+
+
+ {capacity.estimated ? (
+
+ {capacity.hasMeasuredTotal
+ ? t('contextCapacityEstimatedBreakdown')
+ : t('contextCapacityEstimatedAll')}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx
index 8b46c1a05..6182f9acf 100644
--- a/src/renderer/src/components/chat/FloatingComposer.tsx
+++ b/src/renderer/src/components/chat/FloatingComposer.tsx
@@ -38,7 +38,7 @@ import {
import { useTranslation } from 'react-i18next'
import type { ModelProviderModelGroup } from '@shared/kun-gui-api'
import type { WorkspaceEntry } from '@shared/workspace-file'
-import type { AttachmentReference, ReviewTarget } from '../../agent/types'
+import type { AttachmentReference, ChatBlock, ReviewTarget } from '../../agent/types'
import { useChatStore } from '../../store/chat-store'
import { normalizeWorkspaceRoot } from '../../lib/workspace-path'
import {
@@ -72,6 +72,8 @@ import {
formatPercent,
useThreadUsageState
} from '../../hooks/use-thread-usage'
+import { buildContextCapacity, estimateBlockTokens } from '../../lib/context-capacity'
+import { ContextCapacityPopover } from './ContextCapacityPopover'
import { GitBranchPicker } from './GitBranchPicker'
import { WorkspaceProjectPicker } from './WorkspaceProjectPicker'
import {
@@ -164,10 +166,17 @@ type Props = {
* Hide the `/btw` slash entry (e.g. inside a side conversation).
*/
hideBtwCommand?: boolean
+ /** Active model's context window, for the 上下文容量 gauge. */
+ contextWindowTokens?: number
+ /** Tool definitions advertised to the model (built-ins are added on top). */
+ runtimeToolCount?: number
+ /** Skills in the always-injected catalog. */
+ runtimeSkillCount?: number
}
type SkillCommand = NonNullable[number]
+const EMPTY_CONTEXT_BLOCKS: ChatBlock[] = []
const EMPTY_MODEL_GROUPS: ModelProviderModelGroup[] = []
const EMPTY_ATTACHMENTS: AttachmentReference[] = []
const EMPTY_FILE_REFERENCES: ComposerFileReference[] = []
@@ -609,13 +618,17 @@ export function FloatingComposer({
onReviewChanges,
reviewChangesDisabled = false,
onBtwCommand,
- hideBtwCommand = false
+ hideBtwCommand = false,
+ contextWindowTokens,
+ runtimeToolCount,
+ runtimeSkillCount
}: Props): ReactElement {
const { t, i18n } = useTranslation('common')
const route = useChatStore((s) => s.route)
const workspaceRoot = useChatStore((s) => s.workspaceRoot)
const activeThreadId = useChatStore((s) => s.activeThreadId)
const usageRefreshKey = useChatStore((s) => s.usageRefreshKey)
+ const lastTurnUsage = useChatStore((s) => s.lastTurnUsage)
const threads = useChatStore((s) => s.threads)
const compactActiveThread = useChatStore((s) => s.compactActiveThread)
const forkActiveThread = useChatStore((s) => s.forkActiveThread)
@@ -727,11 +740,90 @@ export function FloatingComposer({
const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null)
const [composerMenuOpen, setComposerMenuOpen] = useState(false)
const [goalPanelOpen, setGoalPanelOpen] = useState(false)
+ const [contextCapacityOpen, setContextCapacityOpen] = useState(false)
const [goalRuntimeNowMs, setGoalRuntimeNowMs] = useState(() => Date.now())
const composerRootRef = useRef(null)
const composerMenuButtonRef = useRef(null)
const composerMenuPanelRef = useRef(null)
const goalPanelRef = useRef(null)
+ const contextCapacityRef = useRef(null)
+ const messageTokenCacheRef = useRef>(new WeakMap())
+ // Cache the last-known runtime capacity inputs. `runtimeInfo` (and thus these
+ // props) goes null whenever the runtime drops/reconnects; without caching, the
+ // chip would vanish ("context 没有了") and flap in/out as the connection flaps,
+ // which itself reads as flicker. Writing refs during render is idempotent here.
+ const lastKnownWindowRef = useRef(0)
+ if (typeof contextWindowTokens === 'number' && contextWindowTokens > 0) {
+ lastKnownWindowRef.current = contextWindowTokens
+ }
+ const lastKnownToolCountRef = useRef(0)
+ if (typeof runtimeToolCount === 'number') lastKnownToolCountRef.current = runtimeToolCount
+ const lastKnownSkillCountRef = useRef(0)
+ if (typeof runtimeSkillCount === 'number') lastKnownSkillCountRef.current = runtimeSkillCount
+ const effectiveContextWindow =
+ typeof contextWindowTokens === 'number' && contextWindowTokens > 0
+ ? contextWindowTokens
+ : lastKnownWindowRef.current
+ const effectiveToolCount =
+ typeof runtimeToolCount === 'number' ? runtimeToolCount : lastKnownToolCountRef.current
+ const effectiveSkillCount =
+ typeof runtimeSkillCount === 'number' ? runtimeSkillCount : lastKnownSkillCountRef.current
+ const canShowContextCapacity =
+ !compact && route === 'chat' && Boolean(activeThreadId) && effectiveContextWindow > 0
+ // Freeze the measured total for the duration of a turn: the runtime can emit
+ // several `usage` events while streaming, and tracking them live makes the
+ // chip jitter (visible flicker). Adopt the latest value only while idle.
+ const liveMeasuredTotal =
+ lastTurnUsage && lastTurnUsage.threadId === activeThreadId
+ ? lastTurnUsage.snapshot.inputTokens
+ : null
+ const measuredTotalRef = useRef(null)
+ if (!busy) measuredTotalRef.current = liveMeasuredTotal
+ const measuredContextTotal = busy ? measuredTotalRef.current : liveMeasuredTotal
+ // The message estimate only feeds the per-category split (popover) or the
+ // no-measured-total fallback. Never subscribe to `blocks` while streaming with
+ // the popover closed — blocks churn on every delta and re-render the whole
+ // composer. Freeze the last estimate in a ref instead.
+ const needMessageEstimate =
+ canShowContextCapacity && (contextCapacityOpen || measuredContextTotal == null)
+ const subscribeContextBlocks = needMessageEstimate && (contextCapacityOpen || !busy)
+ const contextBlocks = useChatStore((s) => (subscribeContextBlocks ? s.blocks : EMPTY_CONTEXT_BLOCKS))
+ const conversationTokensRef = useRef(0)
+ const conversationTokens = useMemo(() => {
+ if (!subscribeContextBlocks) return conversationTokensRef.current
+ // Cache per block: block identity is preserved for unchanged history across
+ // streaming updates, so only the block that changed is re-estimated.
+ const cache = messageTokenCacheRef.current
+ let sum = 0
+ for (const block of contextBlocks) {
+ let cached = cache.get(block)
+ if (cached === undefined) {
+ cached = estimateBlockTokens(block)
+ cache.set(block, cached)
+ }
+ sum += cached
+ }
+ conversationTokensRef.current = sum
+ return sum
+ }, [subscribeContextBlocks, contextBlocks])
+ const contextCapacity = useMemo(() => {
+ if (!canShowContextCapacity) return null
+ return buildContextCapacity({
+ windowTokens: effectiveContextWindow,
+ lastTurnInputTokens: measuredContextTotal,
+ messageTokens: conversationTokens,
+ toolCount: effectiveToolCount,
+ skillCount: effectiveSkillCount
+ })
+ }, [
+ canShowContextCapacity,
+ effectiveContextWindow,
+ measuredContextTotal,
+ conversationTokens,
+ effectiveToolCount,
+ effectiveSkillCount
+ ])
+ const showContextCapacity = canShowContextCapacity && Boolean(contextCapacity)
const goalRuntimeStartedAtRef = useRef(null)
const placeholder = !runtimeReady
? t('runtimeActionNeedsConnection')
@@ -1055,6 +1147,25 @@ export function FloatingComposer({
}
}, [composerMenuOpen, goalPanelOpen])
+ useEffect(() => {
+ if (!contextCapacityOpen) return
+ const onPointerDown = (event: PointerEvent): void => {
+ const target = event.target
+ if (!(target instanceof Node)) return
+ if (contextCapacityRef.current?.contains(target)) return
+ setContextCapacityOpen(false)
+ }
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Escape') setContextCapacityOpen(false)
+ }
+ window.addEventListener('pointerdown', onPointerDown)
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('pointerdown', onPointerDown)
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [contextCapacityOpen])
+
useEffect(() => {
const shouldTimeGoal = busy && activeThreadGoal?.status === 'active'
if (!shouldTimeGoal) {
@@ -2055,6 +2166,46 @@ export function FloatingComposer({
>
) : (
<>
+ {showContextCapacity && contextCapacity ? (
+
+
setContextCapacityOpen((open) => !open)}
+ className="ds-composer-context ds-no-drag inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-ds-border-muted bg-ds-card/70 px-2.5 text-[12.5px] font-medium text-ds-muted transition hover:bg-ds-hover"
+ aria-label={t('contextCapacityChipAria', {
+ percent: formatPercent(contextCapacity.usedRatio)
+ })}
+ aria-expanded={contextCapacityOpen}
+ title={t('contextCapacityTitle')}
+ >
+
+ = 0.9
+ ? '#d9544e'
+ : contextCapacity.usedRatio >= 0.75
+ ? '#d9920f'
+ : 'var(--ds-accent)'
+ }}
+ />
+
+
+ {formatPercent(contextCapacity.usedRatio)}
+
+
+ {contextCapacityOpen ? (
+
+
+
+ ) : null}
+
+ ) : null}
{hideModelPicker ? null : (
void
onWriteOpen: () => void
onScheduleOpen: () => void
- onToggleSidebar: () => void
}
export function Sidebar({
@@ -88,8 +87,7 @@ export function Sidebar({
onToggleConnectPhone,
onCodeOpen,
onWriteOpen,
- onScheduleOpen,
- onToggleSidebar
+ onScheduleOpen
}: Props): ReactElement {
const { t, i18n } = useTranslation('common')
const workspaceRoot = useChatStore((s) => s.workspaceRoot)
@@ -117,7 +115,6 @@ export function Sidebar({
<>
diff --git a/src/renderer/src/components/chat/WorkspaceModeTabs.tsx b/src/renderer/src/components/chat/WorkspaceModeTabs.tsx
index 9406a1b8e..f544628c1 100644
--- a/src/renderer/src/components/chat/WorkspaceModeTabs.tsx
+++ b/src/renderer/src/components/chat/WorkspaceModeTabs.tsx
@@ -16,24 +16,24 @@ export function WorkspaceModeTabs({
const { t } = useTranslation('common')
const tabClass = (active: boolean): string =>
- `group inline-flex min-h-[32px] flex-1 min-w-0 items-center justify-center gap-2 rounded-[8px] px-2.5 py-1.5 text-left text-[13px] outline-none transition focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/20 ${
+ `group inline-flex min-h-[30px] flex-1 min-w-0 items-center justify-center gap-1.5 rounded-[7px] px-2.5 py-1 text-[13px] outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/20 ${
active
- ? 'bg-[var(--ds-sidebar-field-focus)] font-medium text-[#182230] shadow-[0_1px_3px_rgba(20,47,95,0.07),inset_0_0_0_1px_var(--ds-sidebar-row-ring),inset_0_1px_0_rgba(255,255,255,0.78)] dark:bg-white/[0.09] dark:text-white dark:shadow-[0_1px_5px_rgba(0,0,0,0.24),inset_0_0_0_1px_rgba(255,255,255,0.1)]'
- : 'font-normal text-[#5c6675] hover:bg-[color-mix(in_srgb,var(--ds-sidebar-field-focus)_56%,transparent)] hover:text-[#1f2733] dark:text-white/58 dark:hover:bg-white/[0.055] dark:hover:text-white/88'
+ ? 'bg-white font-medium text-[#1f2733] shadow-[0_1px_2px_rgba(20,47,95,0.12),0_2px_5px_rgba(20,47,95,0.06)] dark:bg-white/[0.12] dark:text-white dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]'
+ : 'font-normal text-[#646e7c] hover:text-[#1f2733] dark:text-white/55 dark:hover:text-white/90'
}`
const iconClass = (active: boolean): string =>
- `flex h-[21px] w-[21px] shrink-0 items-center justify-center rounded-[7px] transition ${
+ `h-[15px] w-[15px] shrink-0 transition-colors ${
active
- ? 'bg-[var(--ds-accent-soft)] text-[var(--ds-accent)] shadow-[inset_0_0_0_1px_rgba(59,130,216,0.12)] dark:bg-[rgba(111,176,232,0.2)] dark:text-[#78bdff] dark:shadow-[inset_0_0_0_1px_rgba(111,176,232,0.16)]'
- : 'text-[#6f7a89] group-hover:bg-white/55 group-hover:text-[#344055] dark:text-white/48 dark:group-hover:bg-white/[0.06] dark:group-hover:text-white/78'
+ ? 'text-[#1f2733] dark:text-white'
+ : 'text-[#8b95a3] group-hover:text-[#1f2733] dark:text-white/45 dark:group-hover:text-white/85'
}`
return (
-
-
-
+
{t('code')}
-
-
-
+
{t('write')}
diff --git a/src/renderer/src/components/provider-model-editor.ts b/src/renderer/src/components/provider-model-editor.ts
index 43085afda..92c3ec754 100644
--- a/src/renderer/src/components/provider-model-editor.ts
+++ b/src/renderer/src/components/provider-model-editor.ts
@@ -11,6 +11,7 @@ import {
isSpeechToTextModelId,
isTextToSpeechModelId,
isVideoGenerationModelId,
+ type ModelEndpointFormat,
type ModelProviderModelProfileV1,
type ModelProviderProfileV1,
type ModelProviderReasoningCapabilityV1,
@@ -50,6 +51,8 @@ export type ProviderModelForm = {
reasoningEfforts: ModelReasoningEffort[]
reasoningDefaultEffort: ModelReasoningEffort
reasoningProtocol: ModelReasoningRequestProtocol
+ /** Per-model wire-format override; null means "inherit the provider's format". */
+ endpointFormat: ModelEndpointFormat | null
aliases: string[]
}
@@ -100,6 +103,7 @@ export function newProviderModelForm(
reasoningEfforts: [...PROVIDER_MODEL_REASONING_EFFORT_CHOICES],
reasoningDefaultEffort: 'medium',
reasoningProtocol: defaultReasoningProtocolForProvider(provider),
+ endpointFormat: null,
aliases: []
}
}
@@ -128,6 +132,7 @@ export function providerModelFormForExisting(
: base.reasoningEfforts,
reasoningDefaultEffort: profile.reasoning?.defaultEffort ?? base.reasoningDefaultEffort,
reasoningProtocol: profile.reasoning?.requestProtocol ?? base.reasoningProtocol,
+ endpointFormat: profile.endpointFormat ?? null,
aliases: [...(profile.aliases ?? [])]
}
}
@@ -408,7 +413,8 @@ function chatProfileFromForm(form: ProviderModelForm): ModelProviderModelProfile
messageParts: form.visionInput ? ['text', 'image_url'] : ['text'],
...(form.reasoningEnabled && form.reasoningEfforts.length > 0
? { reasoning: reasoningCapabilityFromForm(form) }
- : {})
+ : {}),
+ ...(form.endpointFormat ? { endpointFormat: form.endpointFormat } : {})
}
}
diff --git a/src/renderer/src/components/schedule/ScheduleTasksView.tsx b/src/renderer/src/components/schedule/ScheduleTasksView.tsx
index e1fb2f559..029a982f6 100644
--- a/src/renderer/src/components/schedule/ScheduleTasksView.tsx
+++ b/src/renderer/src/components/schedule/ScheduleTasksView.tsx
@@ -605,13 +605,11 @@ export function ScheduleTasksView({
leftSidebarCollapsed ? 'ds-window-controls-safe-inset' : ''
}`}
>
- {leftSidebarCollapsed ? (
-
- ) : null}
+
{t('schedule')}
diff --git a/src/renderer/src/components/settings-section-agents.tsx b/src/renderer/src/components/settings-section-agents.tsx
index c511b2f10..8cf18ecd5 100644
--- a/src/renderer/src/components/settings-section-agents.tsx
+++ b/src/renderer/src/components/settings-section-agents.tsx
@@ -284,6 +284,7 @@ export function AgentsSettingsSection({ ctx }: { ctx: Record
}): Re
fallbackHardThreshold: contextCompaction.defaultHardThreshold
})
const runtimeTuning = kun.runtimeTuning ?? {
+ streamIdleTimeoutMs: 45000,
toolStorm: {
enabled: true,
windowSize: 8,
@@ -1181,6 +1182,23 @@ export function AgentsSettingsSection({ ctx }: { ctx: Record }): Re
}
/>
+
+ updateRuntimeTuning({ streamIdleTimeoutMs: Number(e.target.value) })
+ }
+ />
+ }
+ />
) => string
+
+const buttonClass =
+ 'inline-flex items-center gap-1.5 rounded-xl border border-ds-border bg-ds-card px-3 py-2 text-[13px] font-medium text-ds-ink shadow-sm transition hover:bg-ds-hover disabled:cursor-not-allowed disabled:opacity-50'
+
+function sum(detection: LegacySessionDetectResult | null, key: 'threadCount' | 'newCount'): number {
+ return detection?.sources.reduce((total, source) => total + source[key], 0) ?? 0
+}
+
+export function LegacySessionImportCard({
+ t,
+ tCommon
+}: {
+ t: TranslateFn
+ tCommon: TranslateFn
+}): ReactElement {
+ const [detection, setDetection] = useState(null)
+ const [detecting, setDetecting] = useState(true)
+ const [busy, setBusy] = useState(false)
+ const [restarting, setRestarting] = useState(false)
+ const [notice, setNotice] = useState(null)
+
+ const refreshDetection = useCallback(async () => {
+ if (typeof window.kunGui?.detectLegacySessions !== 'function') {
+ setDetecting(false)
+ return
+ }
+ setDetecting(true)
+ try {
+ setDetection(await window.kunGui.detectLegacySessions())
+ } catch (error) {
+ setNotice({ tone: 'error', message: error instanceof Error ? error.message : String(error) })
+ } finally {
+ setDetecting(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void refreshDetection()
+ }, [refreshDetection])
+
+ const runImport = useCallback(
+ async (sourceDir?: string) => {
+ if (typeof window.kunGui?.importLegacySessions !== 'function') return
+ setBusy(true)
+ setNotice(null)
+ try {
+ const result = await window.kunGui.importLegacySessions(sourceDir)
+ if (!result.ok) {
+ setNotice({ tone: 'error', message: result.message })
+ return
+ }
+ if (result.total === 0) {
+ setNotice({ tone: 'info', message: t('legacyImportResultNone') })
+ return
+ }
+ setNotice({
+ tone: 'success',
+ message: t('legacyImportResult', { imported: result.imported, skipped: result.skipped })
+ })
+ await refreshDetection()
+ if (result.imported > 0 && typeof window.kunGui?.confirmDialog === 'function') {
+ const restart = await window.kunGui.confirmDialog({
+ message: t('legacyImportRestartTitle'),
+ detail: t('legacyImportRestartDetail', { count: result.imported }),
+ confirmLabel: t('legacyImportRestartConfirm'),
+ cancelLabel: tCommon('cancel')
+ })
+ if (restart && typeof window.kunGui?.restartRuntime === 'function') {
+ setRestarting(true)
+ try {
+ await window.kunGui.restartRuntime()
+ } finally {
+ setRestarting(false)
+ }
+ }
+ }
+ } catch (error) {
+ setNotice({ tone: 'error', message: error instanceof Error ? error.message : String(error) })
+ } finally {
+ setBusy(false)
+ }
+ },
+ [refreshDetection, t, tCommon]
+ )
+
+ const pickAndImport = useCallback(async () => {
+ if (typeof window.kunGui?.pickLegacySessionDir !== 'function') return
+ try {
+ const picked = await window.kunGui.pickLegacySessionDir()
+ if (picked.canceled || !picked.path) return
+ await runImport(picked.path)
+ } catch (error) {
+ setNotice({ tone: 'error', message: error instanceof Error ? error.message : String(error) })
+ }
+ }, [runImport])
+
+ const totalNew = sum(detection, 'newCount')
+ const totalFound = sum(detection, 'threadCount')
+ const working = busy || restarting
+
+ const statusText = detecting
+ ? t('legacyImportScanning')
+ : totalNew > 0
+ ? t('legacyImportFound', { count: totalNew })
+ : totalFound > 0
+ ? t('legacyImportAllPresent')
+ : t('legacyImportNoneFound')
+
+ return (
+
+
+
+ {detecting ? : null}
+ {statusText}
+
+
+ {detection && detection.sources.length > 0 ? (
+
+ {detection.sources.map((source) => (
+
+
+ {t('legacyImportSourceCount', {
+ newCount: source.newCount,
+ total: source.threadCount
+ })}
+
+ {source.path}
+
+ ))}
+
+ ) : null}
+
+
+
void runImport()}
+ >
+ {busy && !restarting ? (
+
+ ) : (
+
+ )}
+ {restarting ? t('legacyImportRestarting') : t('legacyImportButton')}
+
+
void pickAndImport()}
+ >
+
+ {t('legacyImportPick')}
+
+
+
+ {notice ? : null}
+
+ }
+ />
+
+ )
+}
diff --git a/src/renderer/src/components/settings-section-general.tsx b/src/renderer/src/components/settings-section-general.tsx
index 623500248..e99663145 100644
--- a/src/renderer/src/components/settings-section-general.tsx
+++ b/src/renderer/src/components/settings-section-general.tsx
@@ -18,6 +18,7 @@ import {
SettingRow,
Toggle
} from './settings-controls'
+import { LegacySessionImportCard } from './settings-section-general-legacy-import'
export function GeneralSettingsSection({ ctx }: { ctx: Record }): ReactElement {
const {
@@ -263,6 +264,8 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R
/>
+
+
= {
'deepseek-chat-completions': 'providerModelReasoningProtocolDeepseek',
'glm-chat-completions': 'providerModelReasoningProtocolGlm',
@@ -62,6 +72,13 @@ const REASONING_EFFORT_LABEL_KEYS: Record = {
max: 'providerModelEffortMax'
}
+const ENDPOINT_FORMAT_LABEL_KEYS: Record = {
+ chat_completions: 'modelEndpointChatCompletions',
+ responses: 'modelEndpointResponses',
+ messages: 'modelEndpointMessages',
+ custom_endpoint: 'modelEndpointCustomEndpoint'
+}
+
const MODEL_KIND_META: Array<{
kind: ProviderModelKind
icon: typeof MessageSquareText
@@ -263,6 +280,8 @@ export function ProviderModelsManager({
onChange: (next: ModelProviderProfileV1) => void
}): ReactElement {
const [editor, setEditor] = useState(null)
+ const [query, setQuery] = useState('')
+ const [page, setPage] = useState(0)
const updateForm = (patch: Partial): void => {
setEditor((prev) => prev ? { ...prev, form: { ...prev.form, ...patch } } : prev)
@@ -286,6 +305,18 @@ export function ProviderModelsManager({
}
const modelEntries = providerModelListEntries(provider)
+ // Search + pagination only kick in once a provider has more than one page of
+ // models; smaller lists stay as a plain list (search box would just be noise).
+ const showListTools = modelEntries.length > MODEL_LIST_PAGE_SIZE
+ const normalizedQuery = query.trim().toLowerCase()
+ const filteredEntries = showListTools && normalizedQuery
+ ? modelEntries.filter(({ modelId }) => modelId.toLowerCase().includes(normalizedQuery))
+ : modelEntries
+ const pageCount = Math.max(1, Math.ceil(filteredEntries.length / MODEL_LIST_PAGE_SIZE))
+ const safePage = Math.min(page, pageCount - 1)
+ const visibleEntries = showListTools
+ ? filteredEntries.slice(safePage * MODEL_LIST_PAGE_SIZE, safePage * MODEL_LIST_PAGE_SIZE + MODEL_LIST_PAGE_SIZE)
+ : filteredEntries
const effectiveForm = editor ? effectiveFormForEditor(editor) : null
const errors = editor && effectiveForm ? validateProviderModelForm(effectiveForm, provider) : []
const showNonTextWarning = Boolean(effectiveForm && chatModelIdLooksNonText(effectiveForm))
@@ -304,71 +335,128 @@ export function ProviderModelsManager({
{t('providerModelEmpty')}
) : (
-
- {modelEntries.map(({ kind, modelId }) => {
- const profile = kind === 'chat' ? chatModelProfile(provider, modelId) : undefined
- const active = editingKey !== '' && editingKey === modelEntryKey(kind, modelId)
- return (
-
-
-
-
-
- {t(modelKindLabelKey(kind))}
-
- {kind === 'chat' && profile ? (
- <>
- {profile.contextWindowTokens ? (
- {t('providerModelContextBadge', {
- size: describeContextWindowTokens(profile.contextWindowTokens)
- })}
- ) : null}
- {profile.inputModalities.includes('image') ? (
- }>
- {t('modelProviderVisionBadge')}
-
- ) : null}
- {profile.reasoning ? (
- }>
- {t('providerModelReasoningBadge')}
-
- ) : null}
- {!profile.supportsToolCalling ? (
- {t('providerModelNoToolsBadge')}
- ) : null}
- >
- ) : kind === 'chat' ? (
- {t('providerModelDefaultProfileBadge')}
- ) : null}
-
-
-
- setEditor(editorStateForExisting(provider, kind, modelId))}
- className="rounded-full p-1.5 text-ds-faint transition hover:bg-ds-hover hover:text-ds-ink"
- >
-
-
- deleteModel(kind, modelId)}
- className="rounded-full p-1.5 text-ds-faint transition hover:bg-ds-hover hover:text-red-600 dark:hover:text-red-300"
+ <>
+ {showListTools ? (
+
+
+ {
+ setQuery(e.target.value)
+ setPage(0)
+ }}
+ />
+
+ ) : null}
+ {filteredEntries.length === 0 ? (
+
+ {t('providerModelSearchEmpty', { query: query.trim() })}
+
+ ) : (
+
+ {visibleEntries.map(({ kind, modelId }) => {
+ const profile = kind === 'chat' ? chatModelProfile(provider, modelId) : undefined
+ const active = editingKey !== '' && editingKey === modelEntryKey(kind, modelId)
+ return (
+
-
-
+
+
+
+
+ {t(modelKindLabelKey(kind))}
+
+ {kind === 'chat' && profile ? (
+ <>
+ {profile.contextWindowTokens ? (
+ {t('providerModelContextBadge', {
+ size: describeContextWindowTokens(profile.contextWindowTokens)
+ })}
+ ) : null}
+ {profile.inputModalities.includes('image') ? (
+ }>
+ {t('modelProviderVisionBadge')}
+
+ ) : null}
+ {profile.reasoning ? (
+ }>
+ {t('providerModelReasoningBadge')}
+
+ ) : null}
+ {!profile.supportsToolCalling ? (
+ {t('providerModelNoToolsBadge')}
+ ) : null}
+ >
+ ) : kind === 'chat' ? (
+ {t('providerModelDefaultProfileBadge')}
+ ) : null}
+
+
+
+ setEditor(editorStateForExisting(provider, kind, modelId))}
+ className="rounded-full p-1.5 text-ds-faint transition hover:bg-ds-hover hover:text-ds-ink"
+ >
+
+
+ deleteModel(kind, modelId)}
+ className="rounded-full p-1.5 text-ds-faint transition hover:bg-ds-hover hover:text-red-600 dark:hover:text-red-300"
+ >
+
+
+
+
+ )
+ })}
+
+ )}
+ {showListTools && filteredEntries.length > MODEL_LIST_PAGE_SIZE ? (
+
+
+ {t('providerModelPageCount', { shown: visibleEntries.length, total: filteredEntries.length })}
+
+
+ setPage(Math.max(0, safePage - 1))}
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-ds-border bg-ds-card text-ds-muted transition hover:bg-ds-hover hover:text-ds-ink disabled:cursor-not-allowed disabled:opacity-40"
+ >
+
+
+
+ {t('providerModelPageIndicator', { page: safePage + 1, total: pageCount })}
-
- )
- })}
-
+ = pageCount - 1}
+ aria-label={t('providerModelPageNext')}
+ onClick={() => setPage(Math.min(pageCount - 1, safePage + 1))}
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-ds-border bg-ds-card text-ds-muted transition hover:bg-ds-hover hover:text-ds-ink disabled:cursor-not-allowed disabled:opacity-40"
+ >
+
+
+
+
+ ) : null}
+ >
)}
{editor === null ? (
) : null}
+
+ {t('providerModelEndpointFormatLabel')}
+ updateForm({
+ endpointFormat: e.target.value === ''
+ ? null
+ : e.target.value as ModelEndpointFormat
+ })}
+ >
+
+ {t('providerModelEndpointInherit', {
+ format: t(ENDPOINT_FORMAT_LABEL_KEYS[provider.endpointFormat])
+ })}
+
+ {MODEL_ENDPOINT_FORMATS.map((format) => (
+
+ {t(ENDPOINT_FORMAT_LABEL_KEYS[format])}
+
+ ))}
+
+
+ {t('providerModelEndpointFormatHint')}
+
+
{t('providerModelAliasesLabel')}
-token-plan)或本身就是订阅制的预设(category==='subscription');
+// 其余(默认 / 按量预设 / 自定义)归入「按量 API」组,便于一眼分辨两类计费方式。
+function isSubscriptionProviderId(id: string): boolean {
+ if (tokenPlanPresetForProfileId(id)) return true
+ return getModelProviderPreset(id)?.category === 'subscription'
+}
+
function mergeProviderModelIds(primary: readonly string[], secondary: readonly string[]): string[] {
const ids = new Set()
for (const model of [...primary, ...secondary]) {
@@ -337,6 +344,28 @@ function ProviderBadge({
)
}
+function ProviderListGroup({
+ label,
+ count,
+ children
+}: {
+ label: string
+ count: number
+ children: ReactNode
+}): ReactElement {
+ return (
+
+
+ {label}
+
+ {count}
+
+
+ {children}
+
+ )
+}
+
function ModelChipsInput({
values,
onChange,
@@ -434,6 +463,26 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }):
kun.providerId?.trim() || modelProviders[0]?.id || DEFAULT_MODEL_PROVIDER_ID
)
const [addMenuOpen, setAddMenuOpen] = useState(false)
+ const addMenuRef = useRef(null)
+ // 点击菜单外部或按 Esc 关闭「添加供应商」下拉。用监听器代替全屏遮罩:全屏 fixed 遮罩会吞掉滚轮事件,
+ // 导致下拉打开时整个设置页无法滚动(用户反馈的 bug)。
+ useEffect(() => {
+ if (!addMenuOpen) return
+ const onPointerDown = (event: PointerEvent): void => {
+ const target = event.target
+ if (target instanceof Node && addMenuRef.current?.contains(target)) return
+ setAddMenuOpen(false)
+ }
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Escape') setAddMenuOpen(false)
+ }
+ window.addEventListener('pointerdown', onPointerDown)
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('pointerdown', onPointerDown)
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [addMenuOpen])
const [probeStates, setProbeStates] = useState>({})
// 新增供应商先停留在本地草稿,点「添加」才写入设置,避免半配置状态被持久化。
const [draftProvider, setDraftProvider] = useState(null)
@@ -867,7 +916,9 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }):
const providerKindLabel = (item: ModelProviderProfileV1): string => {
if (item.id === DEFAULT_MODEL_PROVIDER_ID) return t('modelProviderDefaultBadge')
if (tokenPlanPresetForProfileId(item.id)) return t('modelProviderTokenPlanBadge')
- if (getModelProviderPreset(item.id)) return t('modelProviderPresetBadge')
+ const preset = getModelProviderPreset(item.id)
+ if (preset?.category === 'subscription') return t('modelProviderPlanBadge')
+ if (preset) return t('modelProviderPresetBadge')
return t('modelProviderCustomBadge')
}
@@ -912,6 +963,109 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }):
const activeTokenPlanRegions = activeProvider
? tokenPlanPresetForProfileId(activeProvider.id)?.tokenPlan?.regions ?? []
: []
+
+ const planProviders = displayProviders.filter((item) => isSubscriptionProviderId(item.id))
+ const apiProviders = displayProviders.filter((item) => !isSubscriptionProviderId(item.id))
+ // 只要存在任一套餐类供应商就分组展示;否则(通常只有默认 DeepSeek)保持单一平铺列表。
+ const grouped = planProviders.length > 0
+
+ const renderProviderButton = (item: ModelProviderProfileV1): ReactElement => {
+ const selected = activeProvider?.id === item.id
+ const isDraft = draftProvider?.id === item.id
+ const inUse = !isDraft && activeKunProviderId === item.id
+ const missingKey = !item.apiKey.trim()
+ return (
+ setSelectedProviderId(item.id)}
+ className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
+ selected
+ ? 'border-accent/60 bg-ds-main/45 ring-1 ring-accent/30'
+ : 'border-ds-border bg-ds-card hover:bg-ds-hover'
+ }`}
+ >
+
+
+ {item.name.trim() || item.id}
+
+ {isDraft ?
{t('modelProviderDraftBadge')} : null}
+ {inUse ?
{t('modelProviderInUse')} : null}
+ {!isDraft && missingKey ?
{t('modelProviderMissingKey')} : null}
+
+
+
{t('modelProviderModelCount', { total: providerModelCount(item) })}
+
·
+
{providerKindLabel(item)}
+ {item.apiKey.trim() ?
: null}
+ {item.image ?
: null}
+ {item.models.some((model) =>
+ modelSupportsImageInput(profileForModel(item, model))
+ ) ?
{t('modelProviderVisionBadge')} : null}
+ {item.speech ?
: null}
+ {item.textToSpeech ?
: null}
+ {item.music ?
: null}
+ {item.video ?
: null}
+
+
+ )
+ }
+
+ const addMenuEntries = MODEL_PROVIDER_PRESETS.flatMap((preset) => {
+ const entries: {
+ preset: ModelProviderPreset
+ mode: 'api' | 'token-plan'
+ profileId: string
+ label: string
+ group: 'subscription' | 'api'
+ }[] = [
+ {
+ preset,
+ mode: 'api',
+ profileId: preset.id,
+ label: preset.name,
+ group: preset.category === 'subscription' ? 'subscription' : 'api'
+ }
+ ]
+ if (preset.tokenPlan) {
+ entries.push({
+ preset,
+ mode: 'token-plan',
+ profileId: tokenPlanProviderId(preset.id),
+ label: `${preset.name} · Token Plan`,
+ group: 'subscription'
+ })
+ }
+ return entries
+ })
+ const planAddEntries = addMenuEntries.filter((entry) => entry.group === 'subscription')
+ const apiAddEntries = addMenuEntries.filter((entry) => entry.group === 'api')
+ const renderAddEntry = (entry: (typeof addMenuEntries)[number]): ReactElement => {
+ const exists = modelProviders.some((item) => item.id === entry.profileId)
+ return (
+ {
+ setAddMenuOpen(false)
+ void addPresetModelProvider(entry.preset, entry.mode)
+ }}
+ className="flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] text-ds-ink transition hover:bg-ds-hover"
+ >
+ {entry.label}
+
+ {exists
+ ? t('modelProviderPresetUpdateTag')
+ : entry.group === 'subscription'
+ ? t('modelProviderPlanBadge')
+ : t('modelProviderPresetBadge')}
+
+
+ )
+ }
+
return (
}):
wideControl
control={
-
- {displayProviders.map((item) => {
- const selected = activeProvider?.id === item.id
- const isDraft = draftProvider?.id === item.id
- const inUse = !isDraft && activeKunProviderId === item.id
- const missingKey = !item.apiKey.trim()
- return (
-
setSelectedProviderId(item.id)}
- className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
- selected
- ? 'border-accent/60 bg-ds-main/45 ring-1 ring-accent/30'
- : 'border-ds-border bg-ds-card hover:bg-ds-hover'
- }`}
- >
-
-
- {item.name.trim() || item.id}
-
- {isDraft ?
{t('modelProviderDraftBadge')} : null}
- {inUse ?
{t('modelProviderInUse')} : null}
- {!isDraft && missingKey ?
{t('modelProviderMissingKey')} : null}
-
-
-
{t('modelProviderModelCount', { total: providerModelCount(item) })}
-
·
-
{providerKindLabel(item)}
- {item.apiKey.trim() ?
: null}
- {item.image ?
: null}
- {item.models.some((model) =>
- modelSupportsImageInput(profileForModel(item, model))
- ) ?
{t('modelProviderVisionBadge')} : null}
- {item.speech ?
: null}
- {item.textToSpeech ?
: null}
- {item.music ?
: null}
- {item.video ?
: null}
-
-
- )
- })}
-
+
+ {grouped ? (
+ <>
+
+ {planProviders.map(renderProviderButton)}
+
+
+ {apiProviders.map(renderProviderButton)}
+
+ >
+ ) : (
+
{displayProviders.map(renderProviderButton)}
+ )}
+
}):
{addMenuOpen ? (
- <>
+
+
+ {t('modelProviderGroupPlans')}
+
+ {planAddEntries.map(renderAddEntry)}
+
+
+ {t('modelProviderGroupApi')}
+
+ {apiAddEntries.map(renderAddEntry)}
+
setAddMenuOpen(false)}
- />
- {
+ setAddMenuOpen(false)
+ addModelProvider()
+ }}
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] text-ds-ink transition hover:bg-ds-hover"
>
- {MODEL_PROVIDER_PRESETS.flatMap((preset) => {
- const entries: { mode: 'api' | 'token-plan'; profileId: string; label: string }[] = [
- { mode: 'api', profileId: preset.id, label: preset.name }
- ]
- if (preset.tokenPlan) {
- entries.push({
- mode: 'token-plan',
- profileId: tokenPlanProviderId(preset.id),
- label: `${preset.name} · Token Plan`
- })
- }
- return entries.map((entry) => {
- const exists = modelProviders.some((item) => item.id === entry.profileId)
- return (
-
{
- setAddMenuOpen(false)
- void addPresetModelProvider(preset, entry.mode)
- }}
- className="flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] text-ds-ink transition hover:bg-ds-hover"
- >
- {entry.label}
-
- {exists ? t('modelProviderPresetUpdateTag') : t('modelProviderPresetBadge')}
-
-
- )
- })
- })}
-
-
{
- setAddMenuOpen(false)
- addModelProvider()
- }}
- className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] text-ds-ink transition hover:bg-ds-hover"
- >
- {t('modelProviderAddMenuCustom')}
-
-
- >
+ {t('modelProviderAddMenuCustom')}
+
+
) : null}
diff --git a/src/renderer/src/components/settings-section-worktree.tsx b/src/renderer/src/components/settings-section-worktree.tsx
index d415c9ca9..eae2d2af1 100644
--- a/src/renderer/src/components/settings-section-worktree.tsx
+++ b/src/renderer/src/components/settings-section-worktree.tsx
@@ -216,11 +216,15 @@ export function WorktreeSettingsSection({ ctx }: { ctx: Record
}):
- {error && (
+ {poolStatus?.isGitRepo === false ? (
+
+ {t('worktreeNotGitRepo')}
+
+ ) : error ? (
{error}
- )}
+ ) : null}
{/* Pool cards */}
diff --git a/src/renderer/src/components/settings-section-write.tsx b/src/renderer/src/components/settings-section-write.tsx
index 9cbbc1371..193718cbf 100644
--- a/src/renderer/src/components/settings-section-write.tsx
+++ b/src/renderer/src/components/settings-section-write.tsx
@@ -5,11 +5,21 @@ import {
DEFAULT_WRITE_INLINE_COMPLETION_MODEL,
DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS,
DEFAULT_MODEL_PROVIDER_ID,
+ WRITE_EDITOR_FONT_SIZE_MAX,
+ WRITE_EDITOR_FONT_SIZE_MIN,
+ WRITE_EDITOR_LINE_HEIGHT_MAX,
+ WRITE_EDITOR_LINE_HEIGHT_MIN,
+ WRITE_FONT_PRESETS,
+ WRITE_AGENT_PRESET_MAX_COUNT,
WRITE_INLINE_COMPLETION_MODEL_IDS,
WRITE_QUICK_ACTION_MAX_COUNT,
defaultModelProviderSettings,
+ defaultWriteAgentPresets,
defaultWriteSelectionAssistSettings,
+ defaultWriteTypography,
resolveWriteInlineCompletionProviderId,
+ type WriteAgentPresetV1,
+ type WriteFontPreset,
type WriteQuickActionV1
} from '@shared/app-settings'
import { WRITE_DESIGN_DRAFT_DEFAULT_PROMPT, WRITE_INFOGRAPHIC_DEFAULT_PROMPT } from '@shared/write-infographic'
@@ -29,6 +39,17 @@ const textInputClass =
const ghostButtonClass =
'inline-flex items-center gap-1.5 rounded-xl border border-ds-border bg-ds-card px-3 py-2 text-[13px] font-medium text-ds-ink shadow-sm transition hover:bg-ds-hover'
+const WRITE_FONT_PRESET_LABEL_KEYS: Record = {
+ system: 'writeFontSystem',
+ sourceHanSans: 'writeFontSourceHanSans',
+ yahei: 'writeFontYahei',
+ pingfang: 'writeFontPingfang',
+ simhei: 'writeFontSimhei',
+ simsun: 'writeFontSimsun',
+ kaiti: 'writeFontKaiti',
+ custom: 'writeFontCustom'
+}
+
export function writeInlineCompletionModelOptions(providerModels: readonly string[]): string[] {
const scopedModels = providerModels
.map((model) => model.trim())
@@ -56,6 +77,11 @@ export function WriteSettingsSection({ ctx }: { ctx: Record }): Rea
const { t: tCommon } = useTranslation('common')
const providerSettings = provider ?? defaultModelProviderSettings()
const selectionAssist = form.write.selectionAssist ?? defaultWriteSelectionAssistSettings()
+ const typography = form.write.typography ?? defaultWriteTypography()
+ const agentPresets: WriteAgentPresetV1[] = form.write.agentPresets ?? defaultWriteAgentPresets()
+ const updateAgentPresets = (next: WriteAgentPresetV1[]): void => {
+ update({ write: { agentPresets: next } })
+ }
const updateQuickActions = (quickActions: WriteQuickActionV1[]): void => {
update({ write: { selectionAssist: { quickActions } } })
}
@@ -123,6 +149,103 @@ export function WriteSettingsSection({ ctx }: { ctx: Record }): Rea
/>
+
+
+
+ update({ write: { typography: { fontPreset: e.target.value as WriteFontPreset } } })
+ }
+ >
+ {WRITE_FONT_PRESETS.map((preset) => (
+
+ {t(WRITE_FONT_PRESET_LABEL_KEYS[preset])}
+
+ ))}
+
+ {typography.fontPreset === 'custom' ? (
+
+ update({ write: { typography: { customFontFamily: e.target.value } } })
+ }
+ placeholder={t('writeFontCustomPlaceholder')}
+ />
+ ) : null}
+
+ }
+ />
+
+
+ update({ write: { typography: { fontSizePx: Number(e.target.value) } } })
+ }
+ className="flex-1 accent-accent"
+ aria-label={t('writeFontSize')}
+ />
+
+ {typography.fontSizePx}px
+
+
+ }
+ />
+
+
+ update({ write: { typography: { lineHeight: Number(e.target.value) } } })
+ }
+ className="flex-1 accent-accent"
+ aria-label={t('writeLineHeight')}
+ />
+
+ {typography.lineHeight.toFixed(2)}
+
+
+ }
+ />
+ update({ write: { typography: defaultWriteTypography() } })}
+ className={ghostButtonClass}
+ >
+
+ {t('writeTypographyResetButton')}
+
+ }
+ />
+
+
}): Rea
+
+
+ {t('writeAgentPresetsDesc')}
+
+
+ {agentPresets.map((preset: WriteAgentPresetV1, index: number) => {
+ return (
+
+ )
+ })}
+
+
+
= WRITE_AGENT_PRESET_MAX_COUNT}
+ onClick={() =>
+ updateAgentPresets([
+ ...agentPresets,
+ { id: `custom-${Date.now().toString(36)}`, name: '', emoji: '🤖', persona: '' }
+ ])
+ }
+ >
+
+ {t('writeAgentPresetAdd')}
+
+
+
+
0 ? parsed : DEFAULT_WRITE_EDITOR_FONT_SIZE_PX
+}
+
+/**
+ * Steps the editor font size by `delta`, clamped to the supported range. The CSS
+ * variable is the live source of truth (set by applyWriteTypography), so we read
+ * it back, nudge it for instant feedback, and persist the new value silently so
+ * it survives restarts and stays in sync with the settings panel.
+ */
+function bumpEditorFontSize(delta: number): number {
+ const next = Math.max(
+ WRITE_EDITOR_FONT_SIZE_MIN,
+ Math.min(WRITE_EDITOR_FONT_SIZE_MAX, readEditorFontSize() + delta)
+ )
+ document.documentElement.style.setProperty(FONT_SIZE_VAR, `${next}px`)
+ void window.kunGui?.saveSettingsSilent?.({ write: { typography: { fontSizePx: next } } })
+ return next
+}
+
+/**
+ * Compact in-toolbar stepper for the writing font size — the quick, in-context
+ * counterpart to the slider in Settings. Self-contained: it drives the shared
+ * `--write-editor-font-size` variable and persists through the silent settings
+ * API, so it needs no store wiring.
+ */
+export function WriteFontSizeControl(): ReactElement {
+ const { t } = useTranslation('common')
+ const [size, setSize] = useState(() => readEditorFontSize())
+
+ const buttonClass =
+ 'flex h-7 w-7 items-center justify-center rounded-lg text-ds-ink transition hover:bg-ds-hover/80 disabled:cursor-not-allowed disabled:opacity-40'
+
+ return (
+
+
setSize(bumpEditorFontSize(-1))}
+ disabled={size <= WRITE_EDITOR_FONT_SIZE_MIN}
+ className={buttonClass}
+ title={t('writeFontSizeDecrease')}
+ aria-label={t('writeFontSizeDecrease')}
+ >
+
+
+
+ {size}
+
+
setSize(bumpEditorFontSize(1))}
+ disabled={size >= WRITE_EDITOR_FONT_SIZE_MAX}
+ className={buttonClass}
+ title={t('writeFontSizeIncrease')}
+ aria-label={t('writeFontSizeIncrease')}
+ >
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/write/WriteInlineAgent.tsx b/src/renderer/src/components/write/WriteInlineAgent.tsx
index fa9594423..5fef6cd97 100644
--- a/src/renderer/src/components/write/WriteInlineAgent.tsx
+++ b/src/renderer/src/components/write/WriteInlineAgent.tsx
@@ -13,6 +13,7 @@ import {
AppWindow,
Bold,
ChevronDown,
+ ChevronRight,
Code,
Heading1,
Heading2,
@@ -28,6 +29,7 @@ import {
Pilcrow,
Quote,
Replace,
+ Settings2,
Sparkles,
Strikethrough,
Wand2,
@@ -38,6 +40,7 @@ import { useTranslation } from 'react-i18next'
import { WRITE_BLOCK_TYPES, type WriteBlockType } from '../../write/block-type'
import type { WriteInlineFormatKind } from '../../write/inline-format'
import type { ResolvedWriteQuickAction } from '../../write/quick-actions'
+import type { ResolvedWriteAgentPreset } from '../../write/agent-presets'
import { clamp, INLINE_AGENT_GAP, type WriteInlineAgentPosition } from './write-workspace-view-utils'
type Props = {
@@ -58,6 +61,12 @@ type Props = {
/** Configurable AI quick actions (edit ones rewrite in place, chat ones go to the sidebar). */
quickActions?: ResolvedWriteQuickAction[]
onQuickAction?: (action: ResolvedWriteQuickAction) => void
+ /** Writing-assistant persona presets for quick role switching; active '' = no agent. */
+ agentPresets?: ResolvedWriteAgentPreset[]
+ activeAgentId?: string
+ onSelectAgent?: (id: string) => void
+ /** Opens the writing-agent settings (empty-state hint + manage link). */
+ onOpenAgentSettings?: () => void
/** Adds the current selection to the writing assistant quote tray without sending a message. */
onQuoteSelection?: () => void
/** Shown only when the image generation provider is configured. Generation
@@ -165,6 +174,10 @@ export function WriteInlineAgent({
onSetBlockType,
quickActions = [],
onQuickAction,
+ agentPresets = [],
+ activeAgentId = '',
+ onSelectAgent,
+ onOpenAgentSettings,
onQuoteSelection,
infographicEnabled = false,
onGenerateInfographic,
@@ -183,6 +196,8 @@ export function WriteInlineAgent({
const showFormatting = !imageMode && formattingEnabled && Boolean(onApplyFormat)
const showQuoteSelection = !imageMode && Boolean(onQuoteSelection)
const showQuickActions = !imageMode && quickActions.length > 0 && Boolean(onQuickAction)
+ const showAgentSwitcher =
+ !imageMode && Boolean(onSelectAgent) && (agentPresets.length > 0 || Boolean(onOpenAgentSettings))
const showInfographic = !imageMode && infographicEnabled && Boolean(onGenerateInfographic)
const showDesignDraft = designDraftEnabled && Boolean(onGenerateDesignDraft)
const showPrototype = prototypeEnabled && Boolean(onGeneratePrototype)
@@ -324,9 +339,73 @@ export function WriteInlineAgent({
) : null}
- {showQuoteSelection || showQuickActions || showInfographic || showDesignDraft || showPrototype ? (
+ {showAgentSwitcher || showQuoteSelection || showQuickActions || showInfographic || showDesignDraft || showPrototype ? (
-
{t('writeSelectionSkills')}
+ {showAgentSwitcher ? (
+
+
+ {t('writeAgentSwitcherLabel')}
+ {onOpenAgentSettings && agentPresets.length > 0 ? (
+ e.preventDefault()}
+ onClick={() => onOpenAgentSettings()}
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-ds-faint transition hover:text-accent"
+ >
+
+ {t('writeAgentSwitcherManage')}
+
+ ) : null}
+
+ {agentPresets.length > 0 ? (
+
+ e.preventDefault()}
+ onClick={() => onSelectAgent?.('')}
+ className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[12px] font-medium transition ${
+ activeAgentId === ''
+ ? 'border-accent/40 bg-accent/12 text-accent'
+ : 'border-ds-border bg-ds-card text-ds-faint hover:border-accent/40 hover:bg-accent/5'
+ }`}
+ >
+ {t('writeAgentSwitcherNone')}
+
+ {agentPresets.map((preset) => (
+ e.preventDefault()}
+ onClick={() => onSelectAgent?.(activeAgentId === preset.id ? '' : preset.id)}
+ className={`inline-flex max-w-[150px] items-center gap-1 rounded-full border px-2.5 py-1 text-[12px] font-medium transition ${
+ activeAgentId === preset.id
+ ? 'border-accent/40 bg-accent/12 text-accent'
+ : 'border-ds-border bg-ds-card text-ds-ink hover:border-accent/40 hover:bg-accent/5'
+ }`}
+ >
+ {preset.emoji}
+ {preset.name}
+
+ ))}
+
+ ) : (
+
e.preventDefault()}
+ onClick={() => onOpenAgentSettings?.()}
+ className="mt-1 flex w-full items-center gap-2 rounded-lg border border-dashed border-ds-border px-2.5 py-1.5 text-[12px] text-ds-ink transition hover:border-accent/40 hover:bg-accent/5"
+ >
+
+ {t('writeAgentSwitcherEmptyHint')}
+
+
+ )}
+
+ ) : null}
+ {showQuoteSelection || showQuickActions || showInfographic || showDesignDraft || showPrototype ? (
+
{t('writeSelectionSkills')}
+ ) : null}
{showQuoteSelection ? (
boolean
/** Rewrite the block markers of the lines spanning the current selection. */
setBlockType: (type: WriteBlockType) => boolean
+ /**
+ * Enters an inline red/green diff review: swaps the document to `nextDoc` and
+ * shows a per-chunk accept/reject merge view against `original`. Returns false
+ * when the editor is read-only/unavailable, a review is already running, or
+ * the texts are identical.
+ */
+ beginDiffReview: (params: { original: string; nextDoc: string }) => boolean
+ /** True while an inline diff review is in progress. */
+ isDiffReviewActive: () => boolean
+ /** Accepts every pending diff chunk and commits the result. */
+ acceptAllDiff: () => void
+ /** Rejects every pending diff chunk (reverting to the original) and commits. */
+ rejectAllDiff: () => void
}
type Props = {
@@ -111,6 +126,8 @@ type Props = {
onSaveShortcut: () => void
onImagePasteSaved?: () => void
onImagePasteError?: (message: string) => void
+ /** Notified when an inline diff review starts (true) or commits/cancels (false). */
+ onReviewStateChange?: (active: boolean) => void
handleRef?: MutableRefObject
}
@@ -270,14 +287,16 @@ function buildEditorTheme(appearance: 'source' | 'live'): Extension {
minHeight: '0',
color: 'var(--ds-text)',
backgroundColor: 'transparent',
+ // Prose (live) appearance follows the configured editor font; the raw
+ // source appearance keeps a monospace family but still honors the size.
fontFamily: sourceMode
? 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace'
- : "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif",
- fontSize: sourceMode ? '14px' : '16px'
+ : "var(--write-editor-font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif)",
+ fontSize: 'var(--write-editor-font-size, 16px)'
},
'.cm-scroller': {
overflow: 'auto',
- lineHeight: '1.75',
+ lineHeight: 'var(--write-editor-line-height, 1.75)',
backgroundColor: 'transparent'
},
'.cm-content': {
@@ -387,6 +406,7 @@ export function WriteMarkdownEditor({
onSaveShortcut,
onImagePasteSaved,
onImagePasteError,
+ onReviewStateChange,
handleRef
}: Props): ReactElement {
const hostRef = useRef(null)
@@ -414,6 +434,9 @@ export function WriteMarkdownEditor({
const onSaveShortcutRef = useRef(onSaveShortcut)
const onImagePasteSavedRef = useRef(onImagePasteSaved)
const onImagePasteErrorRef = useRef(onImagePasteError)
+ const onReviewStateChangeRef = useRef(onReviewStateChange)
+ const mergeCompartmentRef = useRef(null)
+ const reviewActiveRef = useRef(false)
const valueRef = useRef(value)
const lastSelectionRef = useRef(null)
const lastEmittedValueRef = useRef(null)
@@ -438,6 +461,7 @@ export function WriteMarkdownEditor({
onSaveShortcutRef.current = onSaveShortcut
onImagePasteSavedRef.current = onImagePasteSaved
onImagePasteErrorRef.current = onImagePasteError
+ onReviewStateChangeRef.current = onReviewStateChange
valueRef.current = value
useEffect(() => {
@@ -447,9 +471,94 @@ export function WriteMarkdownEditor({
const themeCompartment = new Compartment()
const livePreviewCompartment = new Compartment()
const editableCompartment = new Compartment()
+ const mergeCompartment = new Compartment()
themeCompartmentRef.current = themeCompartment
livePreviewCompartmentRef.current = livePreviewCompartment
editableCompartmentRef.current = editableCompartment
+ mergeCompartmentRef.current = mergeCompartment
+
+ // --- Inline red/green diff review -----------------------------------------
+ // AI rewrites enter a unified merge view (per-line accept/reject) instead of
+ // overwriting the document. While a review is active, onChange is suppressed
+ // so nothing is persisted until the user resolves every chunk.
+ const finishDiffReview = (): void => {
+ const instance = viewRef.current
+ if (!instance || !reviewActiveRef.current) return
+ const finalDoc = instance.state.doc.toString()
+ reviewActiveRef.current = false
+ instance.dispatch({
+ effects: [
+ mergeCompartment.reconfigure([]),
+ // Restore the live-preview decorations that were suspended for review.
+ livePreviewCompartment.reconfigure(
+ appearanceRef.current === 'live' && livePreviewEnabledRef.current
+ ? writeMarkdownLivePreviewExtensions(filePathRef.current, workspaceRootRef.current)
+ : []
+ )
+ ]
+ })
+ lastEmittedValueRef.current = finalDoc
+ onChangeRef.current(finalDoc)
+ onReviewStateChangeRef.current?.(false)
+ }
+ const resolveAllDiffChunks = (mode: 'accept' | 'reject'): void => {
+ const instance = viewRef.current
+ if (!instance || !reviewActiveRef.current) return
+ for (let guard = 0; guard < 10_000; guard += 1) {
+ const data = getChunks(instance.state)
+ if (!data || data.chunks.length === 0) break
+ const pos = data.chunks[0].fromB
+ if (mode === 'accept') acceptChunk(instance, pos)
+ else rejectChunk(instance, pos)
+ }
+ finishDiffReview()
+ }
+ const buildDiffReviewPanel = (): Panel => {
+ const dom = document.createElement('div')
+ dom.className = 'cm-write-diff-panel'
+ const label = document.createElement('span')
+ label.className = 'cm-write-diff-panel-label'
+ label.textContent = i18n.t('writeDiffReviewing', { ns: 'common' })
+ const reject = document.createElement('button')
+ reject.type = 'button'
+ reject.className = 'cm-write-diff-reject-all'
+ reject.textContent = i18n.t('writeDiffRejectAll', { ns: 'common' })
+ reject.addEventListener('mousedown', (event) => event.preventDefault())
+ reject.addEventListener('click', () => resolveAllDiffChunks('reject'))
+ const accept = document.createElement('button')
+ accept.type = 'button'
+ accept.className = 'cm-write-diff-accept-all'
+ accept.textContent = i18n.t('writeDiffAcceptAll', { ns: 'common' })
+ accept.addEventListener('mousedown', (event) => event.preventDefault())
+ accept.addEventListener('click', () => resolveAllDiffChunks('accept'))
+ dom.append(label, reject, accept)
+ return { dom, top: true }
+ }
+ // Restartable: when a review is already active (e.g. the agent edits the
+ // file again mid-turn), this re-points the merge view at the new target
+ // against the same baseline instead of bailing.
+ const beginDiffReview = (original: string, nextDoc: string): boolean => {
+ const instance = viewRef.current
+ if (!instance || readOnlyRef.current) return false
+ if (nextDoc === original) return false
+ reviewActiveRef.current = true
+ instance.dispatch({
+ changes: { from: 0, to: instance.state.doc.length, insert: nextDoc },
+ annotations: externalValueSyncAnnotation.of(true),
+ effects: [
+ // Suspend live-preview decorations so the raw red/green diff (and the
+ // merge view's deleted-line widgets) render cleanly during review.
+ livePreviewCompartment.reconfigure([]),
+ mergeCompartment.reconfigure([
+ unifiedMergeView({ original, gutter: false, collapseUnchanged: { margin: 3, minSize: 4 } }),
+ showPanel.of(buildDiffReviewPanel)
+ ])
+ ]
+ })
+ lastEmittedValueRef.current = nextDoc
+ onReviewStateChangeRef.current?.(true)
+ return true
+ }
const inlineCompletionExtension = buildInlineCompletionExtension({
getDebounceMs: () => completionDebounceMsRef.current,
getMinAcceptScore: () => completionMinAcceptScoreRef.current,
@@ -498,6 +607,7 @@ export function WriteMarkdownEditor({
: []
),
editableCompartment.of(buildInteractionExtensions(readOnlyRef.current, appearanceRef.current)),
+ mergeCompartment.of([]),
markdown({ base: markdownLanguage, codeLanguages: languages }),
history(),
drawSelection(),
@@ -586,6 +696,14 @@ export function WriteMarkdownEditor({
const termPropagationSync = update.transactions.some((transaction) =>
transaction.annotation(termPropagationAnnotation)
)
+ // A diff review resolves once every chunk is accepted/rejected. Commit
+ // it after the dispatch settles to avoid reentrant transactions.
+ if (reviewActiveRef.current && !externalValueSync) {
+ const chunkData = getChunks(update.state)
+ if (!chunkData || chunkData.chunks.length === 0) {
+ queueMicrotask(() => finishDiffReview())
+ }
+ }
// Materialise the document string at most once per update; on large
// documents doc.toString() walks the whole rope and used to run for
// both the onChange emit and the term propagation scan.
@@ -594,7 +712,7 @@ export function WriteMarkdownEditor({
if (docString === null) docString = update.state.doc.toString()
return docString
}
- if (update.docChanged && !externalValueSync) {
+ if (update.docChanged && !externalValueSync && !reviewActiveRef.current) {
const recentEdits = recentEditsFromUpdate(update, filePathRef.current)
if (recentEdits.length > 0) onDocumentEditRef.current?.(recentEdits)
lastEmittedValueRef.current = docText()
@@ -610,7 +728,7 @@ export function WriteMarkdownEditor({
onSelectionChangeRef.current(nextSelection)
}
}
- if (update.docChanged && !externalValueSync && !termPropagationSync) {
+ if (update.docChanged && !externalValueSync && !termPropagationSync && !reviewActiveRef.current) {
const seed = termReplacementSeedFromUpdate(update)
if (seed) {
const content = docText()
@@ -682,17 +800,23 @@ export function WriteMarkdownEditor({
scrollIntoView: true
})
return true
- }
+ },
+ beginDiffReview: ({ original, nextDoc }) => beginDiffReview(original, nextDoc),
+ isDiffReviewActive: () => reviewActiveRef.current,
+ acceptAllDiff: () => resolveAllDiffChunks('accept'),
+ rejectAllDiff: () => resolveAllDiffChunks('reject')
}
}
return () => {
if (handleRef) handleRef.current = null
+ reviewActiveRef.current = false
view.destroy()
viewRef.current = null
themeCompartmentRef.current = null
livePreviewCompartmentRef.current = null
editableCompartmentRef.current = null
+ mergeCompartmentRef.current = null
}
// Mount-once editor; handleRef is a stable ref container from the parent.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -720,6 +844,9 @@ export function WriteMarkdownEditor({
useEffect(() => {
const view = viewRef.current
if (!view) return
+ // While an inline diff review owns the document, never let the controlled
+ // value sync clobber the in-progress merge view.
+ if (reviewActiveRef.current) return
// The value usually round-trips from our own onChange emit; comparing the
// reference first avoids re-serialising the whole document per keystroke.
if (value === lastEmittedValueRef.current) return
diff --git a/src/renderer/src/components/write/WriteSidebar.tsx b/src/renderer/src/components/write/WriteSidebar.tsx
index 8db26d218..090dcafab 100644
--- a/src/renderer/src/components/write/WriteSidebar.tsx
+++ b/src/renderer/src/components/write/WriteSidebar.tsx
@@ -44,7 +44,6 @@ type Props = {
onWriteOpen: () => void
onOpenSettings: (section?: SettingsRouteSection) => void
onToggleConnectPhone: () => void
- onToggleSidebar: () => void
}
type EntryDialog =
@@ -61,8 +60,7 @@ export function WriteSidebar({
onCodeOpen,
onWriteOpen,
onOpenSettings,
- onToggleConnectPhone,
- onToggleSidebar
+ onToggleConnectPhone
}: Props): ReactElement {
const { t } = useTranslation('common')
const clawChannels = useChatStore((s) => s.clawChannels)
@@ -259,7 +257,6 @@ export function WriteSidebar({
<>
void
onImagePasteSaved: () => void
onImagePasteError: (message: string) => void
+ onMarkdownReviewStateChange?: (active: boolean) => void
}
export function WriteWorkspaceDocumentPane({
@@ -103,7 +104,8 @@ export function WriteWorkspaceDocumentPane({
onSelectionChange,
onSaveShortcut,
onImagePasteSaved,
- onImagePasteError
+ onImagePasteError,
+ onMarkdownReviewStateChange
}: Props): ReactElement {
const { t } = useTranslation('common')
@@ -218,6 +220,7 @@ export function WriteWorkspaceDocumentPane({
onSaveShortcut={onSaveShortcut}
onImagePasteSaved={onImagePasteSaved}
onImagePasteError={onImagePasteError}
+ onReviewStateChange={onMarkdownReviewStateChange}
handleRef={markdownHandleRef}
/>
}
@@ -244,6 +247,7 @@ export function WriteWorkspaceDocumentPane({
onSaveShortcut={onSaveShortcut}
onImagePasteSaved={onImagePasteSaved}
onImagePasteError={onImagePasteError}
+ onReviewStateChange={onMarkdownReviewStateChange}
handleRef={markdownHandleRef}
/>
)}
diff --git a/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx b/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx
index 86cf73c68..adea9f487 100644
--- a/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx
+++ b/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
import type { WriteExportFormat } from '@shared/write-export'
import type { WritePreviewMode, WriteSaveStatus } from '../../write/write-workspace-store'
import { SidebarTitlebarToggleButton } from '../sidebar/SidebarPrimitives'
+import { WriteFontSizeControl } from './WriteFontSizeControl'
import {
WRITE_EXPORT_FORMATS,
exportFormatLabel,
@@ -48,6 +49,7 @@ type Props = {
readOnly: boolean
saveLabel: string
saveStatus: WriteSaveStatus
+ reviewActive?: boolean
setAssistantOpen: (open: boolean) => void
setExportMenuOpen: (open: boolean | ((open: boolean) => boolean)) => void
setModeMenuOpen: (open: boolean | ((open: boolean) => boolean)) => void
@@ -78,6 +80,7 @@ export function WriteWorkspaceToolbar({
readOnly,
saveLabel,
saveStatus,
+ reviewActive = false,
setAssistantOpen,
setExportMenuOpen,
setModeMenuOpen,
@@ -94,13 +97,11 @@ export function WriteWorkspaceToolbar({
leftSidebarCollapsed ? 'ds-window-controls-safe-inset' : ''
}`}
>
- {leftSidebarCollapsed ? (
-
- ) : null}
+
@@ -147,13 +148,11 @@ export function WriteWorkspaceToolbar({
leftSidebarCollapsed ? 'ds-window-controls-safe-inset' : ''
}`}
>
- {leftSidebarCollapsed ? (
-
- ) : null}
+
@@ -234,6 +233,7 @@ export function WriteWorkspaceToolbar({
+ {activeFileIsText ?
: null}
setAssistantOpen(!assistantOpen)}
@@ -254,7 +254,9 @@ export function WriteWorkspaceToolbar({
- {saveLabel}
+ {reviewActive ? t('writeReviewPending') : saveLabel}
void
input: string; setInput: (value: string) => void
onSubmitPrompt?: (value: string) => void
+ onOpenAgentSettings?: () => void
}
export function WriteWorkspaceView({
@@ -69,7 +71,8 @@ export function WriteWorkspaceView({
onToggleLeftSidebar,
input,
setInput,
- onSubmitPrompt
+ onSubmitPrompt,
+ onOpenAgentSettings
}: Props): ReactElement {
const { t } = useTranslation('common')
const ensureWriteThreadForWorkspace = useChatStore((s) => s.ensureWriteThreadForWorkspace)
@@ -114,7 +117,14 @@ export function WriteWorkspaceView({
setAssistantOpen,
setSelection,
recordRecentEdits,
- quoteCurrentSelection
+ quoteCurrentSelection,
+ agentPresets,
+ assistantAgentPresetId,
+ setAssistantAgentPresetId,
+ pendingAgentReview,
+ clearPendingAgentReview,
+ reviewActive,
+ setReviewActive
} = useWriteWorkspaceStore(
useShallow((s) => ({
workspaceRoot: s.workspaceRoot,
@@ -124,6 +134,13 @@ export function WriteWorkspaceView({
inlineCompletion: s.inlineCompletion,
inlineCompletionApiReady: s.inlineCompletionApiReady,
selectionAssist: s.selectionAssist,
+ agentPresets: s.agentPresets,
+ assistantAgentPresetId: s.assistantAgentPresetId,
+ setAssistantAgentPresetId: s.setAssistantAgentPresetId,
+ pendingAgentReview: s.pendingAgentReview,
+ clearPendingAgentReview: s.clearPendingAgentReview,
+ reviewActive: s.reviewActive,
+ setReviewActive: s.setReviewActive,
imageGenReady: s.imageGenReady,
fileContent: s.fileContent,
imageDataUrl: s.imageDataUrl,
@@ -167,6 +184,7 @@ export function WriteWorkspaceView({
const markdownHandleRef = useRef(null)
const [inlineAgentValue, setInlineAgentValue] = useState('')
const [pointerSelecting, setPointerSelecting] = useState(false)
+ const resolvedAgentPresets = agentPresets.map((preset) => resolveWriteAgentPreset(preset))
const [inlineEditInFlight, setInlineEditInFlight] = useState(false)
const [modeMenuOpen, setModeMenuOpen] = useState(false)
const [exportMenuOpen, setExportMenuOpen] = useState(false)
@@ -236,6 +254,8 @@ export function WriteWorkspaceView({
const submitInlineAgent = (prompt: string): void => {
const trimmed = prompt.trim()
if (!trimmed || !workspaceReady || !activeFilePath) return
+ // The active agent persona is applied downstream (folded into the prompt
+ // context in sendWritePrompt) so it never shows as raw text in the bubble.
quoteCurrentSelection(workspaceRoot)
setAssistantOpen(true)
setInlineAgentValue('')
@@ -301,6 +321,10 @@ export function WriteWorkspaceView({
setFileError(t('writeReadOnlySaveDisabled'))
return
}
+ if (markdownHandleRef.current?.isDiffReviewActive()) {
+ setFileError(t('writeInlineEditReviewPending'))
+ return
+ }
if (selection.ranges.length !== 1) {
setFileError(t(selection.ranges.length > 1 ? 'writeInlineEditMultiSelection' : 'writeInlineEditNoSelection'))
return
@@ -363,41 +387,67 @@ export function WriteWorkspaceView({
}
const latest = useWriteWorkspaceStore.getState()
- if (
- latest.activeFilePath !== activeFilePath ||
- latest.activeFileKind !== 'text' ||
- latest.fileContent.slice(draft.scope.from, draft.scope.to) !== draft.scope.text
- ) {
+ if (latest.activeFilePath !== activeFilePath || latest.activeFileKind !== 'text') {
setFileError(t('writeInlineEditChanged'))
return
}
+ // The document can shift during the multi-second model call (live-preview
+ // normalization, autosave round-trips, …). Re-locate the scope in the
+ // latest content instead of hard-failing on a stale offset; only give up
+ // when the selected text is gone or no longer unique.
+ const baseline = latest.fileContent
+ let scopeFrom = draft.scope.from
+ let scopeTo = draft.scope.to
+ if (baseline.slice(scopeFrom, scopeTo) !== draft.scope.text) {
+ const firstMatch = draft.scope.text ? baseline.indexOf(draft.scope.text) : -1
+ const unique = firstMatch >= 0 && baseline.indexOf(draft.scope.text, firstMatch + 1) === -1
+ if (!unique) {
+ setFileError(t('writeInlineEditChanged'))
+ return
+ }
+ scopeFrom = firstMatch
+ scopeTo = firstMatch + draft.scope.text.length
+ }
- const nextContent = applyWriteInlineEditReplacement(latest.fileContent, draft.scope, replacement)
+ const nextContent = applyWriteInlineEditReplacement(
+ baseline,
+ { ...draft.scope, from: scopeFrom, to: scopeTo },
+ replacement
+ )
const inlineEditRecord = createWriteRecentEdit({
source: 'inline-edit',
filePath: activeFilePath,
- from: draft.scope.from,
- to: draft.scope.to,
+ from: scopeFrom,
+ to: scopeTo,
deletedText: draft.scope.text,
insertedText: replacement,
- beforeContext: latest.fileContent.slice(
- Math.max(0, draft.scope.from - INLINE_EDIT_RECENT_CONTEXT_CHARS),
- draft.scope.from
- ),
+ beforeContext: baseline.slice(Math.max(0, scopeFrom - INLINE_EDIT_RECENT_CONTEXT_CHARS), scopeFrom),
afterContext: nextContent.slice(
- draft.scope.from + replacement.length,
- Math.min(nextContent.length, draft.scope.from + replacement.length + INLINE_EDIT_RECENT_CONTEXT_CHARS)
+ scopeFrom + replacement.length,
+ Math.min(nextContent.length, scopeFrom + replacement.length + INLINE_EDIT_RECENT_CONTEXT_CHARS)
),
instruction: trimmed,
scopeKind: draft.scope.kind
})
- setFileContent(nextContent)
- if (inlineEditRecord) recordRecentEdits([inlineEditRecord])
+ // Land the rewrite as an inline red/green diff review when the editor
+ // supports it (source/live CodeMirror); fall back to a direct apply
+ // (rich mode, or no handle) otherwise.
+ const startedReview = markdownHandleRef.current?.beginDiffReview({
+ original: baseline,
+ nextDoc: nextContent
+ }) ?? false
+ if (!startedReview) {
+ setFileContent(nextContent)
+ if (inlineEditRecord) recordRecentEdits([inlineEditRecord])
+ }
setSelection({ text: '', ranges: [], charCount: 0 })
setInlineAgentValue('')
setFileError(null)
- showExportNotice({ tone: 'success', message: t('writeInlineEditApplied') })
+ showExportNotice({
+ tone: 'success',
+ message: startedReview ? t('writeInlineEditReview') : t('writeInlineEditApplied')
+ })
} catch (error) {
setFileError(t('writeInlineEditFailed', {
message: error instanceof Error ? error.message : String(error)
@@ -751,12 +801,31 @@ export function WriteWorkspaceView({
}
}, [exportNotice])
+ // An agent edited the active file: surface the change as a red/green diff
+ // review (baseline = what the user currently sees; the agent's version is
+ // already on disk) instead of overwriting the document.
+ useEffect(() => {
+ if (!pendingAgentReview) return
+ const nextContent = pendingAgentReview.nextContent
+ clearPendingAgentReview()
+ const baseline = useWriteWorkspaceStore.getState().fileContent
+ const started = markdownHandleRef.current?.beginDiffReview({
+ original: baseline,
+ nextDoc: nextContent
+ }) ?? false
+ if (!started) {
+ // Rich mode / no source editor / identical content: apply directly.
+ setFileContent(nextContent)
+ setReviewActive(false)
+ }
+ }, [pendingAgentReview, clearPendingAgentReview, setFileContent, setReviewActive])
+
useEffect(() => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
- if (saveStatus !== 'dirty' || !workspaceReady || !activeFileIsText || renderSafety.readOnly) return
+ if (saveStatus !== 'dirty' || !workspaceReady || !activeFileIsText || renderSafety.readOnly || reviewActive) return
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null
void flushSave(workspaceRoot)
@@ -767,7 +836,7 @@ export function WriteWorkspaceView({
saveTimerRef.current = null
}
}
- }, [flushSave, saveStatus, workspaceReady, workspaceRoot, fileContent, activeFileIsText, renderSafety.readOnly])
+ }, [flushSave, saveStatus, workspaceReady, workspaceRoot, fileContent, activeFileIsText, renderSafety.readOnly, reviewActive])
useEffect(() => () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current)
@@ -890,6 +959,7 @@ export function WriteWorkspaceView({
readOnly={renderSafety.readOnly}
saveLabel={saveLabel}
saveStatus={saveStatus}
+ reviewActive={reviewActive}
setAssistantOpen={setAssistantOpen}
setExportMenuOpen={setExportMenuOpen}
setModeMenuOpen={setModeMenuOpen}
@@ -931,6 +1001,7 @@ export function WriteWorkspaceView({
richModeActive={richModeActive}
richHandleRef={richHandleRef}
markdownHandleRef={markdownHandleRef}
+ onMarkdownReviewStateChange={setReviewActive}
debouncedPreviewContent={debouncedPreviewContent}
isMarkdown={isMarkdown}
inlineCompletion={inlineCompletion}
@@ -977,6 +1048,10 @@ export function WriteWorkspaceView({
quickActions={inlineQuickActions}
onQuickAction={runQuickAction}
onQuoteSelection={quoteSelectionToAssistant}
+ agentPresets={resolvedAgentPresets}
+ activeAgentId={assistantAgentPresetId}
+ onSelectAgent={setAssistantAgentPresetId}
+ onOpenAgentSettings={onOpenAgentSettings}
infographicEnabled={activeFileIsText && imageGenReady && isMarkdown && !renderSafety.readOnly}
onGenerateInfographic={generateInfographic}
/>
diff --git a/src/renderer/src/lib/apply-theme.ts b/src/renderer/src/lib/apply-theme.ts
index 4a3585431..1b41c5fa0 100644
--- a/src/renderer/src/lib/apply-theme.ts
+++ b/src/renderer/src/lib/apply-theme.ts
@@ -1,3 +1,5 @@
+import { writeFontStackFor, type WriteTypographySettingsV1 } from '@shared/app-settings'
+
export type ThemePreference = 'system' | 'light' | 'dark'
export type UiFontScale = 'small' | 'medium' | 'large'
@@ -47,6 +49,19 @@ export function applyUiFontScale(scale: UiFontScale): void {
root.style.setProperty('--ds-ui-scale', factor)
}
+/**
+ * Pushes the Write editor typography onto CSS variables consumed by the rich
+ * editor, the CodeMirror live appearance, and the markdown preview. Setting the
+ * variables on `` keeps chat surfaces untouched (only `.write-*` and the
+ * editor theme read them) and live-updates open editors without a rebuild.
+ */
+export function applyWriteTypography(typography: WriteTypographySettingsV1): void {
+ const root = document.documentElement.style
+ root.setProperty('--write-editor-font-family', writeFontStackFor(typography.fontPreset, typography.customFontFamily))
+ root.setProperty('--write-editor-font-size', `${typography.fontSizePx}px`)
+ root.setProperty('--write-editor-line-height', String(typography.lineHeight))
+}
+
/**
* Mirrors the active i18n locale onto `` so screen readers,
* browser spellcheck, and CSS `:lang()` selectors match the visible UI.
diff --git a/src/renderer/src/lib/context-capacity.test.ts b/src/renderer/src/lib/context-capacity.test.ts
new file mode 100644
index 000000000..a1249e559
--- /dev/null
+++ b/src/renderer/src/lib/context-capacity.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+import type { ChatBlock } from '../agent/types'
+import {
+ buildContextCapacity,
+ estimateBlockTokens,
+ estimateTokensFromText
+} from './context-capacity'
+
+describe('estimateTokensFromText', () => {
+ it('returns 0 for empty input', () => {
+ expect(estimateTokensFromText('')).toBe(0)
+ })
+
+ it('treats latin text as roughly 4 chars per token', () => {
+ expect(estimateTokensFromText('a'.repeat(40))).toBe(10)
+ })
+
+ it('treats CJK characters as roughly one token each', () => {
+ expect(estimateTokensFromText('上下文容量')).toBe(5)
+ })
+
+ it('counts astral-plane characters once, not twice', () => {
+ // An emoji is a single surrogate pair; latin heuristic -> ceil(1/4) = 1.
+ expect(estimateTokensFromText('😀')).toBe(1)
+ })
+})
+
+describe('buildContextCapacity', () => {
+ it('uses the measured total and keeps categories + free summing to the window', () => {
+ const cap = buildContextCapacity({
+ windowTokens: 200_000,
+ lastTurnInputTokens: 138_389,
+ messageTokens: 90_000,
+ toolCount: 40,
+ skillCount: 12
+ })
+ expect(cap.hasMeasuredTotal).toBe(true)
+ expect(cap.usedTokens).toBe(138_389)
+ expect(cap.freeTokens).toBe(200_000 - 138_389)
+ const sum = cap.categories.reduce((acc, c) => acc + c.tokens, 0) + cap.freeTokens
+ // Allow ±1 token of rounding drift across the five categories.
+ expect(Math.abs(sum - cap.windowTokens)).toBeLessThanOrEqual(2)
+ expect(cap.usedRatio).toBeCloseTo(138_389 / 200_000, 5)
+ })
+
+ it('clamps a measured total that exceeds the window', () => {
+ const cap = buildContextCapacity({
+ windowTokens: 100_000,
+ lastTurnInputTokens: 150_000,
+ messageTokens: 0,
+ toolCount: 10,
+ skillCount: 0
+ })
+ expect(cap.usedTokens).toBe(100_000)
+ expect(cap.freeTokens).toBe(0)
+ expect(cap.usedRatio).toBe(1)
+ })
+
+ it('falls back to a pure estimate when there is no measured turn', () => {
+ const cap = buildContextCapacity({
+ windowTokens: 200_000,
+ lastTurnInputTokens: null,
+ messageTokens: 2,
+ toolCount: 20,
+ skillCount: 5
+ })
+ expect(cap.hasMeasuredTotal).toBe(false)
+ const prefix = cap.categories
+ .filter((c) => c.key !== 'messages')
+ .reduce((acc, c) => acc + c.tokens, 0)
+ expect(prefix).toBeGreaterThan(0)
+ expect(cap.usedTokens).toBeGreaterThan(0)
+ expect(cap.usedTokens).toBeLessThan(cap.windowTokens)
+ })
+
+ it('scales a pure estimate down so it never overflows the window', () => {
+ const cap = buildContextCapacity({
+ windowTokens: 1000,
+ lastTurnInputTokens: null,
+ messageTokens: 25_000,
+ toolCount: 100,
+ skillCount: 50
+ })
+ expect(cap.usedTokens).toBeLessThanOrEqual(cap.windowTokens)
+ expect(cap.freeTokens).toBeGreaterThanOrEqual(0)
+ })
+})
+
+describe('estimateBlockTokens', () => {
+ it('estimates model-visible text per block kind', () => {
+ expect(estimateBlockTokens({ kind: 'user', id: 'u1', text: 'hello world!' } as ChatBlock)).toBe(3)
+ expect(
+ estimateBlockTokens({
+ kind: 'tool',
+ id: 't1',
+ name: 'read',
+ status: 'done',
+ detail: 'file contents here'
+ } as unknown as ChatBlock)
+ ).toBeGreaterThan(0)
+ })
+
+ it('returns 0 for blocks with no model-visible text', () => {
+ expect(
+ estimateBlockTokens({
+ kind: 'approval',
+ id: 'p1',
+ requestId: 'req',
+ toolName: 'bash',
+ createdAt: ''
+ } as unknown as ChatBlock)
+ ).toBe(0)
+ })
+})
diff --git a/src/renderer/src/lib/context-capacity.ts b/src/renderer/src/lib/context-capacity.ts
new file mode 100644
index 000000000..e7554f3ed
--- /dev/null
+++ b/src/renderer/src/lib/context-capacity.ts
@@ -0,0 +1,186 @@
+import type { ChatBlock } from '../agent/types'
+
+/**
+ * Context-window capacity model for the composer "上下文容量" popover.
+ *
+ * The total occupancy is taken from the most recent turn's real prompt-token
+ * count when available (each turn re-sends the whole context, so the last
+ * `promptTokens` ≈ what currently sits in the window). The per-category split
+ * is estimated: the conversation is estimated from the message text we hold in
+ * the renderer, and the stable prefix (tools / system prompt / skills / other)
+ * is split proportionally so the parts always add up to the real total. When no
+ * live turn has happened yet we fall back to a pure estimate.
+ *
+ * Everything is expressed as a share of the window, so the categories plus the
+ * free row always sum to 100% — that is the invariant the old display broke.
+ */
+
+export type ContextCategoryKey = 'tools' | 'system' | 'skills' | 'messages' | 'other'
+
+export type ContextCategory = {
+ key: ContextCategoryKey
+ tokens: number
+ /** Share of the whole window, 0..1. */
+ ratio: number
+}
+
+export type ContextCapacity = {
+ windowTokens: number
+ usedTokens: number
+ freeTokens: number
+ /** Used share of the window, 0..1. */
+ usedRatio: number
+ /** Free share of the window, 0..1. */
+ freeRatio: number
+ categories: ContextCategory[]
+ /** True when the breakdown (not necessarily the total) is estimated. */
+ estimated: boolean
+ /** True when the total occupancy is a real measurement, not an estimate. */
+ hasMeasuredTotal: boolean
+}
+
+export type ContextCapacityInput = {
+ windowTokens: number
+ /** Real prompt tokens from the latest turn, or null when none yet. */
+ lastTurnInputTokens: number | null
+ /**
+ * Estimated tokens for the conversation portion. Pre-computed by the caller
+ * (with per-block caching) so this function stays O(1) and never re-scans
+ * message text on every recompute.
+ */
+ messageTokens: number
+ /** Number of tool definitions advertised to the model. */
+ toolCount: number
+ /** Number of skills in the always-injected catalog. */
+ skillCount: number
+}
+
+// Rough per-item weights. These only set the *ratio* between prefix categories
+// (the absolute scale is pinned to the real total), so they don't need to be
+// exact — just plausible relative sizes.
+export const BUILTIN_TOOL_COUNT = 14
+export const TOKENS_PER_TOOL = 90
+export const TOKENS_PER_SKILL = 45
+export const SYSTEM_PROMPT_BASE_TOKENS = 1600
+export const OTHER_BASE_TOKENS = 220
+
+const CJK_TOKENS_PER_CHAR = 0.9
+const ASCII_CHARS_PER_TOKEN = 4
+
+// All ranges are in the Basic Multilingual Plane, so a UTF-16 code unit equals
+// the code point here — an indexed charCodeAt loop is both correct and fast.
+function isCjkCodeUnit(code: number): boolean {
+ return (
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
+ (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
+ (code >= 0xf900 && code <= 0xfaff) // CJK Compatibility Ideographs
+ )
+}
+
+/**
+ * Cheap token estimate that treats CJK characters (~1 token each) differently
+ * from latin text (~4 chars per token). Good enough for a usage gauge. Uses an
+ * indexed loop (no iterator allocation) so it stays fast on long messages.
+ */
+export function estimateTokensFromText(text: string): number {
+ if (!text) return 0
+ const len = text.length
+ let cjk = 0
+ for (let i = 0; i < len; i += 1) {
+ if (isCjkCodeUnit(text.charCodeAt(i))) cjk += 1
+ }
+ const ascii = len - cjk
+ return Math.ceil(cjk * CJK_TOKENS_PER_CHAR + ascii / ASCII_CHARS_PER_TOKEN)
+}
+
+/**
+ * Estimate the model-visible tokens for a single block. Cheap and pure so the
+ * caller can memoize per block (block identity is stable across streaming
+ * updates, so unchanged history is never re-scanned).
+ */
+export function estimateBlockTokens(block: ChatBlock): number {
+ switch (block.kind) {
+ case 'user':
+ case 'assistant':
+ case 'reasoning':
+ return estimateTokensFromText(block.text ?? '')
+ case 'system':
+ return estimateTokensFromText(block.text ?? '') + estimateTokensFromText(block.detail ?? '')
+ case 'tool':
+ case 'compaction':
+ return estimateTokensFromText(block.detail ?? '')
+ default:
+ return 0
+ }
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value))
+}
+
+export function buildContextCapacity(input: ContextCapacityInput): ContextCapacity {
+ const windowTokens = Math.max(1, Math.round(input.windowTokens))
+
+ const messageEstimate = Math.max(0, input.messageTokens)
+ const toolsEstimate = Math.max(0, BUILTIN_TOOL_COUNT + input.toolCount) * TOKENS_PER_TOOL
+ const skillsEstimate = Math.max(0, input.skillCount) * TOKENS_PER_SKILL
+ const systemEstimate = SYSTEM_PROMPT_BASE_TOKENS
+ const otherEstimate = OTHER_BASE_TOKENS
+ const prefixEstimate = toolsEstimate + skillsEstimate + systemEstimate + otherEstimate
+
+ const hasMeasuredTotal =
+ typeof input.lastTurnInputTokens === 'number' && input.lastTurnInputTokens > 0
+
+ let tools: number
+ let system: number
+ let skills: number
+ let other: number
+ let messages: number
+ let usedTokens: number
+
+ if (hasMeasuredTotal) {
+ // Real total; estimate the breakdown but scale the prefix so the parts add
+ // up to the measured occupancy exactly.
+ usedTokens = clamp(Math.round(input.lastTurnInputTokens as number), 0, windowTokens)
+ messages = clamp(messageEstimate, 0, usedTokens)
+ const prefixActual = Math.max(0, usedTokens - messages)
+ const scale = prefixEstimate > 0 ? prefixActual / prefixEstimate : 0
+ tools = toolsEstimate * scale
+ system = systemEstimate * scale
+ skills = skillsEstimate * scale
+ other = otherEstimate * scale
+ } else {
+ // No turn yet — pure estimate, scaled down if it would overflow the window.
+ const rawUsed = prefixEstimate + messageEstimate
+ const scale = rawUsed > windowTokens ? windowTokens / rawUsed : 1
+ tools = toolsEstimate * scale
+ system = systemEstimate * scale
+ skills = skillsEstimate * scale
+ other = otherEstimate * scale
+ messages = messageEstimate * scale
+ usedTokens = clamp(Math.round(rawUsed), 0, windowTokens)
+ }
+
+ const categories: ContextCategory[] = [
+ { key: 'tools', tokens: Math.round(tools), ratio: tools / windowTokens },
+ { key: 'system', tokens: Math.round(system), ratio: system / windowTokens },
+ { key: 'skills', tokens: Math.round(skills), ratio: skills / windowTokens },
+ { key: 'messages', tokens: Math.round(messages), ratio: messages / windowTokens },
+ { key: 'other', tokens: Math.round(other), ratio: other / windowTokens }
+ ]
+
+ const freeTokens = Math.max(0, windowTokens - usedTokens)
+
+ return {
+ windowTokens,
+ usedTokens,
+ freeTokens,
+ usedRatio: usedTokens / windowTokens,
+ freeRatio: freeTokens / windowTokens,
+ categories,
+ estimated: true,
+ hasMeasuredTotal
+ }
+}
diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json
index f2d211dbf..d79e35976 100644
--- a/src/renderer/src/locales/en/common.json
+++ b/src/renderer/src/locales/en/common.json
@@ -904,6 +904,21 @@
"sessionUsageDetailsTitle": "{{tokens}} tokens · {{cost}} · saved {{saved}} tokens · cache {{cache}} · {{cached}} cached / {{miss}} miss · {{turns}} turns",
"sessionUsageLoading": "Loading usage",
"sessionUsageUnavailable": "No usage yet",
+ "contextCapacityTitle": "Context window",
+ "contextCapacityCat_tools": "System tools",
+ "contextCapacityCat_system": "System prompt",
+ "contextCapacityCat_skills": "Skills",
+ "contextCapacityCat_messages": "Messages",
+ "contextCapacityCat_other": "Other",
+ "contextCapacityCat_free": "Free space",
+ "contextCapacityShareNote": "Share of window · sums to 100%",
+ "contextCapacityNearLimit": "Near limit",
+ "contextCapacityOverLimit": "Near threshold · will auto-compact",
+ "contextCapacityThresholdLabel": "Compacts near {{percent}}",
+ "contextCapacityChipAria": "Context window {{percent}} used",
+ "contextCapacityBarAria": "Context {{percent}} used",
+ "contextCapacityEstimatedBreakdown": "Total is measured; the per-category split is estimated.",
+ "contextCapacityEstimatedAll": "No turn yet — values are estimated and refresh after you send.",
"running": "Running…",
"guiUpdateTopbarAvailable": "Update {{version}}",
"guiUpdateTopbarDownloading": "Updating {{percent}}%",
@@ -1158,6 +1173,9 @@
"sddAssistantResearchSub": "Fill in context and options",
"sddAssistantResearchPrompt": "Based on the current requirement draft, do implementation-oriented research and summarize feasible approaches, risks, and details that need confirmation.",
"writeToggleAssistant": "Toggle AI panel",
+ "writeFontSizeControl": "Font size",
+ "writeFontSizeDecrease": "Decrease font size",
+ "writeFontSizeIncrease": "Increase font size",
"writeModeSource": "Source editor",
"writeModeLive": "Live editor",
"writeModeLiveShort": "Live",
@@ -1275,6 +1293,24 @@
"writeInlineEditChanged": "The selected text changed. Select it again and retry.",
"writeInlineEditEmpty": "The AI returned an empty rewrite; your text was left unchanged.",
"writeInlineEditApplied": "Text edit applied.",
+ "writeInlineEditReview": "Changes ready — accept or reject each line.",
+ "writeInlineEditReviewPending": "Finish reviewing the current changes first.",
+ "writeDiffReviewing": "Review AI changes",
+ "writeReviewPending": "Reviewing",
+ "writeDiffAcceptAll": "Accept all",
+ "writeDiffRejectAll": "Reject all",
+ "writeAgentPreset_coordinator_name": "Plot coordinator",
+ "writeAgentPreset_coordinator_persona": "You are the plot coordinator for a novel. Your job: hold the overall story structure and pacing, track the main and side arcs, judge each chapter's role in the whole, and propose structural and plot-progression adjustments. Answer with the big-picture read first, then a concrete next step; always stay consistent with the established world and character settings.",
+ "writeAgentPreset_editor_name": "Line editor",
+ "writeAgentPreset_editor_persona": "You are a novel line editor. Your job: polish the prose without changing the plot or a character's voice — sharpen word choice, improve rhythm and imagery, cut redundancy. Preserve the author's style when suggesting edits, adding a one-line reason when useful; never alter settings or plot direction on your own.",
+ "writeAgentPreset_foreshadowing_name": "Foreshadowing tracker",
+ "writeAgentPreset_foreshadowing_persona": "You are a foreshadowing tracker. Your job: follow the setups, suspense, and promises planted in the text, flag threads not yet paid off, suggest when and how to resolve them, and warn about contradictory or forgotten foreshadowing. Answer as a list: the setup, where it appears, status (paid off / pending), and a suggestion.",
+ "writeAgentPreset_continuity_name": "Continuity checker",
+ "writeAgentPreset_continuity_persona": "You are a continuity checker for the whole manuscript. Your job: verify the consistency of character settings, timeline, geography, and key objects; surface contradictions, time slips, and setting conflicts. Answer as a list, one issue per row: location, conflict description, severity, and a fix suggestion; only point out problems, do not rewrite the prose.",
+ "writeAgentSwitcherLabel": "Custom writing agent",
+ "writeAgentSwitcherNone": "None",
+ "writeAgentSwitcherManage": "Manage",
+ "writeAgentSwitcherEmptyHint": "Add a writing agent in Settings",
"writeInfographicGenerate": "Generate infographic",
"writeInfographicAlt": "Infographic",
"writeInfographicDrawing": "Painting your infographic",
diff --git a/src/renderer/src/locales/en/settings.json b/src/renderer/src/locales/en/settings.json
index fe8d0dc41..380c95342 100644
--- a/src/renderer/src/locales/en/settings.json
+++ b/src/renderer/src/locales/en/settings.json
@@ -104,6 +104,21 @@
"onboardingPreview": "First-run setup guide",
"onboardingPreviewDesc": "Reopen the basic setup flow shown on first launch.",
"onboardingPreviewOpen": "Open guide",
+ "legacyImportTitle": "Import legacy conversations",
+ "legacyImportDesc": "Bring conversations from a previous DeepSeek GUI installation into Kun.",
+ "legacyImportScanning": "Scanning for previous conversations…",
+ "legacyImportFound": "Found {{count}} conversation(s) ready to import.",
+ "legacyImportAllPresent": "Previous conversations are already imported.",
+ "legacyImportNoneFound": "No previous conversations found on this computer.",
+ "legacyImportSourceCount": "{{newCount}} new / {{total}} total",
+ "legacyImportButton": "Import all",
+ "legacyImportPick": "Choose folder…",
+ "legacyImportRestarting": "Restarting…",
+ "legacyImportResult": "Imported {{imported}} conversation(s); skipped {{skipped}} already present.",
+ "legacyImportResultNone": "No conversations were found to import.",
+ "legacyImportRestartTitle": "Import complete",
+ "legacyImportRestartDetail": "Imported {{count}} conversation(s). Restart the runtime now to load them into the sidebar?",
+ "legacyImportRestartConfirm": "Restart now",
"fontScale": "Font size",
"fontScaleDesc": "Adjust the overall UI text size.",
"fontScaleSmall": "Small",
@@ -195,6 +210,12 @@
"modelProviderVisionBadge": "Vision",
"providerModelListDesc": "Add models here when the provider ships new ones, or fetch them from the API. Text, image, speech, music, and video models are grouped by capability; the sections below control protocol and endpoint details.",
"providerModelEmpty": "No models yet. Click \"Add model\" to configure one, or use \"Fetch from API\".",
+ "providerModelSearchPlaceholder": "Search models…",
+ "providerModelSearchEmpty": "No models match \"{{query}}\".",
+ "providerModelPageIndicator": "{{page}} / {{total}}",
+ "providerModelPageCount": "Showing {{shown}} of {{total}}",
+ "providerModelPagePrev": "Previous page",
+ "providerModelPageNext": "Next page",
"providerModelAdd": "Add model",
"providerModelAddTitle": "Add model",
"providerModelEditTitle": "Configure {{model}}",
@@ -231,6 +252,9 @@
"providerModelReasoningDefault": "Default effort",
"providerModelReasoningProtocol": "Reasoning request protocol",
"providerModelReasoningProtocolHint": "How reasoning parameters are sent in requests. Pre-selected from the provider's endpoint format — usually no change needed.",
+ "providerModelEndpointFormatLabel": "Request format (endpoint)",
+ "providerModelEndpointInherit": "Inherit from provider ({{format}})",
+ "providerModelEndpointFormatHint": "Override the wire format for this model only. Use it when one provider serves some models over chat completions and others over Anthropic Messages or OpenAI Responses (e.g. OpenCode Go). Leave on \"Inherit\" unless a specific model needs a different format.",
"providerModelReasoningProtocolDeepseek": "DeepSeek style (reasoning_effort)",
"providerModelReasoningProtocolGlm": "GLM style (thinking)",
"providerModelReasoningProtocolMimo": "Xiaomi MiMo style",
@@ -267,6 +291,9 @@
"modelProviderPresetBadge": "Preset",
"modelProviderCustomBadge": "Custom",
"modelProviderTokenPlanBadge": "Token Plan",
+ "modelProviderPlanBadge": "Plan",
+ "modelProviderGroupPlans": "Subscription plans",
+ "modelProviderGroupApi": "Pay-as-you-go",
"modelProviderTokenPlanRegion": "Plan region",
"modelProviderDraftBadge": "Unsaved",
"modelProviderDraftSection": "Add this provider",
@@ -566,6 +593,8 @@
"kunCompactionSummaryTimeout": "Summary timeout ms",
"kunCompactionSummaryMaxTokens": "Summary max tokens",
"kunCompactionSummaryInputBytes": "Summary input bytes",
+ "kunStreamIdleTimeout": "Stream idle timeout (ms)",
+ "kunStreamIdleTimeoutDesc": "Fail the turn if the model sends no data for this many milliseconds. Raise it for local LLM servers that stay silent while prefilling a large prompt; set 0 to disable the limit. Default 45000.",
"kunToolStorm": "Repeated tool-call protection",
"kunToolStormDesc": "When the model repeats the exact same tool call in one turn, suppress the duplicate and ask it to change approach.",
"kunToolStormLimits": "Repeat detection",
@@ -708,6 +737,31 @@
"writeWorkspaceRoot": "Default writing workspace",
"writeWorkspaceRootDesc": "Writing mode reads Markdown documents from here by default. A welcome.md file is created on first launch.",
"writeWorkspaceRootPlaceholder": "~/.kun/write_workspace",
+ "writeTypography": "Typography & font",
+ "writeFontPreset": "Editor font",
+ "writeFontPresetDesc": "Font for the writing surface — pick a comfortable face for long-form Chinese.",
+ "writeFontSystem": "System default",
+ "writeFontSourceHanSans": "Source Han Sans",
+ "writeFontYahei": "Microsoft YaHei",
+ "writeFontPingfang": "PingFang SC",
+ "writeFontSimhei": "SimHei",
+ "writeFontSimsun": "SimSun (serif)",
+ "writeFontKaiti": "KaiTi (serif)",
+ "writeFontCustom": "Custom…",
+ "writeFontCustomPlaceholder": "CSS font-family, e.g. \"LXGW WenKai\", serif",
+ "writeFontSize": "Font size",
+ "writeFontSizeDesc": "Body text size ({{min}}–{{max}}px). Applies to the editor and preview.",
+ "writeLineHeight": "Line spacing",
+ "writeLineHeightDesc": "Body line height — larger values add more breathing room.",
+ "writeTypographyReset": "Reset typography",
+ "writeTypographyResetDesc": "Restore the default font, size, and spacing.",
+ "writeTypographyResetButton": "Reset",
+ "writeAgentPresets": "Custom writing agent prompts",
+ "writeAgentPresetsDesc": "Configure dedicated agents for long-form writing — each agent is a persona/behavior prompt (e.g. plot coordinator, line editor, foreshadowing tracker, continuity checker). Switch between them from the selection popover in the editor. Off by default; add what you need.",
+ "writeAgentPresetAdd": "Add agent",
+ "writeAgentPresetRemove": "Remove",
+ "writeAgentPresetNamePlaceholder": "Name this agent",
+ "writeAgentPersonaPlaceholder": "Describe this agent's persona, role, and behavior rules…",
"writeInlineCompletion": "Writing suggestions",
"writeInlineCompletionEnabled": "Show suggestions after you pause",
"writeInlineCompletionEnabledDesc": "Show gray continuation suggestions in the Markdown editor that you can accept with Tab.",
@@ -865,6 +919,7 @@
"worktreeSyncTitle": "Sync from main (rebase)",
"worktreeCleanupAll": "Clean up all",
"worktreeForceConfirm": "This worktree has {{count}} uncommitted change(s). Force-reset will discard them. Continue?",
+ "worktreeNotGitRepo": "The current workspace is not a Git repository. Worktrees require a Git project — please select one in General settings.",
"memory": "Memory",
"sectionMemory": "Long-term memory",
"memoryOverview": "Overview",
diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json
index 16e9797fa..e6d939ff3 100644
--- a/src/renderer/src/locales/zh/common.json
+++ b/src/renderer/src/locales/zh/common.json
@@ -904,6 +904,21 @@
"sessionUsageDetailsTitle": "{{tokens}} tokens · {{cost}} · 省 {{saved}} tokens · cache {{cache}} · {{cached}} 命中 / {{miss}} 未命中 · {{turns}} 回合",
"sessionUsageLoading": "正在读取用量",
"sessionUsageUnavailable": "暂无用量",
+ "contextCapacityTitle": "上下文容量",
+ "contextCapacityCat_tools": "系统工具",
+ "contextCapacityCat_system": "系统提示词",
+ "contextCapacityCat_skills": "技能",
+ "contextCapacityCat_messages": "消息",
+ "contextCapacityCat_other": "其他",
+ "contextCapacityCat_free": "空闲",
+ "contextCapacityShareNote": "占窗口比例 · 累加为 100%",
+ "contextCapacityNearLimit": "接近上限",
+ "contextCapacityOverLimit": "已接近阈值 · 将自动压缩",
+ "contextCapacityThresholdLabel": "约 {{percent}} 触发压缩",
+ "contextCapacityChipAria": "上下文容量 {{percent}}",
+ "contextCapacityBarAria": "上下文已用 {{percent}}",
+ "contextCapacityEstimatedBreakdown": "总量为实测值,分类占比为估算。",
+ "contextCapacityEstimatedAll": "尚无回合,数值为估算,发送后按实测刷新。",
"running": "运行中…",
"guiUpdateTopbarAvailable": "更新到 {{version}}",
"guiUpdateTopbarDownloading": "更新中 {{percent}}%",
@@ -1158,6 +1173,9 @@
"sddAssistantResearchSub": "补齐背景和方案",
"sddAssistantResearchPrompt": "请基于当前需求草稿做一次实现前调研,整理可行方案、风险和需要确认的细节。",
"writeToggleAssistant": "切换 AI 面板",
+ "writeFontSizeControl": "字号",
+ "writeFontSizeDecrease": "减小字号",
+ "writeFontSizeIncrease": "增大字号",
"writeModeSource": "源码编辑",
"writeModeLive": "实时编辑",
"writeModeLiveShort": "Live",
@@ -1275,6 +1293,24 @@
"writeInlineEditChanged": "选区附近的内容已经变化,请重新选择后再试。",
"writeInlineEditEmpty": "AI 没有返回改写结果,已保留原文。",
"writeInlineEditApplied": "已应用文本编辑。",
+ "writeInlineEditReview": "已生成修改,请逐行确认接受或拒绝。",
+ "writeInlineEditReviewPending": "请先处理完当前的红绿修改审阅。",
+ "writeDiffReviewing": "审阅 AI 修改",
+ "writeReviewPending": "待审阅",
+ "writeDiffAcceptAll": "全部接受",
+ "writeDiffRejectAll": "全部拒绝",
+ "writeAgentPreset_coordinator_name": "剧情统筹",
+ "writeAgentPreset_coordinator_persona": "你是小说创作的剧情统筹。职责:把握整体故事结构与节奏,梳理主线与支线,评估每一章在全局中的作用,提出结构调整与情节推进建议。回答时先给出全局判断,再给出可执行的下一步;始终与既定世界观和人物设定保持一致。",
+ "writeAgentPreset_editor_name": "润色编辑",
+ "writeAgentPreset_editor_persona": "你是小说润色编辑。职责:在不改变剧情走向与人物声音的前提下打磨文笔——锤炼用词、优化节奏与画面感、删除冗余与重复。给出修改时保留作者风格,必要时附一句简短理由;不擅自改动设定与情节。",
+ "writeAgentPreset_foreshadowing_name": "伏笔回收",
+ "writeAgentPreset_foreshadowing_persona": "你是伏笔回收专员。职责:追踪文中已埋下的伏笔、悬念与承诺,标记尚未回收的线索,提示回收时机与方式,警示自相矛盾或被遗忘的伏笔。回答以清单呈现:伏笔内容、出处、状态(已回收/待回收)、建议。",
+ "writeAgentPreset_continuity_name": "线索核查",
+ "writeAgentPreset_continuity_persona": "你是全文剧情线索核查员。职责:核查人物设定、时间线、地理与关键物品的一致性,发现前后矛盾、时间错位与设定冲突。回答以清单逐条列出疑点:位置、冲突描述、严重度、修订建议;只指出问题,不改写正文。",
+ "writeAgentSwitcherLabel": "自定义写作 Agent",
+ "writeAgentSwitcherNone": "不指定",
+ "writeAgentSwitcherManage": "管理",
+ "writeAgentSwitcherEmptyHint": "去设置添加写作 Agent 提示词",
"writeInfographicGenerate": "生成信息图",
"writeInfographicAlt": "信息图",
"writeInfographicDrawing": "正在绘制信息图",
diff --git a/src/renderer/src/locales/zh/settings.json b/src/renderer/src/locales/zh/settings.json
index 7f65504ac..af64d3eeb 100644
--- a/src/renderer/src/locales/zh/settings.json
+++ b/src/renderer/src/locales/zh/settings.json
@@ -104,6 +104,21 @@
"onboardingPreview": "首次设置向导",
"onboardingPreviewDesc": "重新查看首次启动时的基础配置流程。",
"onboardingPreviewOpen": "打开引导页",
+ "legacyImportTitle": "导入旧版会话",
+ "legacyImportDesc": "把以前 DeepSeek GUI 安装中的会话记录导入到 Kun。",
+ "legacyImportScanning": "正在扫描旧版会话…",
+ "legacyImportFound": "发现 {{count}} 条可导入的会话。",
+ "legacyImportAllPresent": "旧版会话已全部导入。",
+ "legacyImportNoneFound": "本机未发现旧版会话。",
+ "legacyImportSourceCount": "新增 {{newCount}} / 共 {{total}}",
+ "legacyImportButton": "一键导入",
+ "legacyImportPick": "选择文件夹…",
+ "legacyImportRestarting": "正在重启…",
+ "legacyImportResult": "已导入 {{imported}} 条会话,跳过 {{skipped}} 条已存在。",
+ "legacyImportResultNone": "未找到可导入的会话。",
+ "legacyImportRestartTitle": "导入完成",
+ "legacyImportRestartDetail": "已导入 {{count}} 条会话。现在重启运行时以便在侧栏中加载它们?",
+ "legacyImportRestartConfirm": "立即重启",
"fontScale": "字体大小",
"fontScaleDesc": "调整整体界面的文字尺寸。",
"fontScaleSmall": "小",
@@ -195,6 +210,12 @@
"modelProviderVisionBadge": "识图",
"providerModelListDesc": "供应商发布新模型后可在这里手动添加或从 API 拉取。文本、图片、语音、音乐和视频模型都会按能力归类;下方能力区块用于调整协议与接口地址。",
"providerModelEmpty": "还没有模型。点「添加模型」手动配置,或用「从 API 拉取」自动获取。",
+ "providerModelSearchPlaceholder": "搜索模型…",
+ "providerModelSearchEmpty": "没有匹配「{{query}}」的模型。",
+ "providerModelPageIndicator": "{{page}} / {{total}}",
+ "providerModelPageCount": "显示 {{shown}} / {{total}}",
+ "providerModelPagePrev": "上一页",
+ "providerModelPageNext": "下一页",
"providerModelAdd": "添加模型",
"providerModelAddTitle": "添加模型",
"providerModelEditTitle": "配置 {{model}}",
@@ -231,6 +252,9 @@
"providerModelReasoningDefault": "默认强度",
"providerModelReasoningProtocol": "推理请求协议",
"providerModelReasoningProtocolHint": "决定请求里如何携带推理参数,已按该供应商的端点格式预选,一般无需修改。",
+ "providerModelEndpointFormatLabel": "请求格式(端点)",
+ "providerModelEndpointInherit": "继承供应商({{format}})",
+ "providerModelEndpointFormatHint": "仅为该模型单独覆盖请求格式。当同一供应商里部分模型走 chat completions、另一些走 Anthropic Messages 或 OpenAI Responses 时使用(如 OpenCode Go)。除非某个模型确实需要不同格式,否则保持「继承」即可。",
"providerModelReasoningProtocolDeepseek": "DeepSeek 风格(reasoning_effort)",
"providerModelReasoningProtocolGlm": "GLM 风格(thinking)",
"providerModelReasoningProtocolMimo": "小米 MiMo 风格",
@@ -267,6 +291,9 @@
"modelProviderPresetBadge": "预设",
"modelProviderCustomBadge": "自定义",
"modelProviderTokenPlanBadge": "Token Plan",
+ "modelProviderPlanBadge": "套餐",
+ "modelProviderGroupPlans": "套餐订阅",
+ "modelProviderGroupApi": "按量 API",
"modelProviderTokenPlanRegion": "套餐区域",
"modelProviderDraftBadge": "未保存",
"modelProviderDraftSection": "添加此供应商",
@@ -566,6 +593,8 @@
"kunCompactionSummaryTimeout": "摘要超时 ms",
"kunCompactionSummaryMaxTokens": "摘要最大 token",
"kunCompactionSummaryInputBytes": "摘要输入字节",
+ "kunStreamIdleTimeout": "流式空闲超时(毫秒)",
+ "kunStreamIdleTimeoutDesc": "当模型在这么多毫秒内没有返回任何数据时,判定为卡死并结束本轮。本地模型在预处理超大输入时会长时间静默,可适当调大此值;填 0 表示不限制。默认 45000。",
"kunToolStorm": "重复工具调用保护",
"kunToolStormDesc": "当模型在同一轮里反复调用完全相同的工具时,自动拦截重复调用并让它换思路。",
"kunToolStormLimits": "重复调用判断",
@@ -708,6 +737,31 @@
"writeWorkspaceRoot": "默认写作空间",
"writeWorkspaceRootDesc": "写作模式默认读取这里的 Markdown 文档;首次启动会创建 welcome.md。",
"writeWorkspaceRootPlaceholder": "~/.kun/write_workspace",
+ "writeTypography": "排版与字体",
+ "writeFontPreset": "编辑器字体",
+ "writeFontPresetDesc": "写作区正文字体,挑一个适合中文长文阅读的字体。",
+ "writeFontSystem": "系统默认",
+ "writeFontSourceHanSans": "思源黑体",
+ "writeFontYahei": "微软雅黑",
+ "writeFontPingfang": "苹方",
+ "writeFontSimhei": "黑体",
+ "writeFontSimsun": "宋体(衬线)",
+ "writeFontKaiti": "楷体(衬线)",
+ "writeFontCustom": "自定义…",
+ "writeFontCustomPlaceholder": "CSS font-family,如 \"LXGW WenKai\", serif",
+ "writeFontSize": "字号",
+ "writeFontSizeDesc": "正文字号({{min}}–{{max}}px),作用于编辑器与预览。",
+ "writeLineHeight": "行距",
+ "writeLineHeightDesc": "正文行高,数值越大行间越宽。",
+ "writeTypographyReset": "重置排版",
+ "writeTypographyResetDesc": "恢复默认字体、字号与行距。",
+ "writeTypographyResetButton": "重置",
+ "writeAgentPresets": "自定义写作 Agent 提示词",
+ "writeAgentPresetsDesc": "为长篇写作配置专属 Agent:每个 Agent 就是一段人设/行为提示词(如剧情统筹、润色编辑、伏笔回收、线索核查)。配置后可在编辑器里选中文字的浮窗中快速切换。默认不启用,按需添加。",
+ "writeAgentPresetAdd": "新增 Agent",
+ "writeAgentPresetRemove": "删除",
+ "writeAgentPresetNamePlaceholder": "给 Agent 起个名字",
+ "writeAgentPersonaPlaceholder": "描述这个 Agent 的人设、职责与行为规则…",
"writeInlineCompletion": "写作建议",
"writeInlineCompletionEnabled": "输入停顿时显示建议",
"writeInlineCompletionEnabledDesc": "在 Markdown 编辑器里给出可按 Tab 接受的灰色续写建议。",
@@ -865,6 +919,7 @@
"worktreeSyncTitle": "从主分支同步(rebase)",
"worktreeCleanupAll": "全部清理",
"worktreeForceConfirm": "此工作树有 {{count}} 个未提交变更。强制重置将丢弃这些变更。是否继续?",
+ "worktreeNotGitRepo": "当前工作目录不是 Git 仓库,工作树功能不可用。请先在「通用」设置中选择一个 Git 项目目录。",
"memory": "记忆",
"sectionMemory": "长期记忆",
"memoryOverview": "概览",
diff --git a/src/renderer/src/store/chat-store-app-actions.test.ts b/src/renderer/src/store/chat-store-app-actions.test.ts
index e94157a44..7f248ee5c 100644
--- a/src/renderer/src/store/chat-store-app-actions.test.ts
+++ b/src/renderer/src/store/chat-store-app-actions.test.ts
@@ -74,6 +74,7 @@ function buildHarness(fetchModelsResult: FetchModelsResult): {
},
applyTheme: () => undefined,
applyUiFontScale: () => undefined,
+ applyWriteTypography: () => undefined,
applyDocumentLocale: () => undefined,
workspaceLabelFromPath: (workspaceRoot) => workspaceRoot,
normalizeWorkspaceRoot: (workspaceRoot) => workspaceRoot?.trim() ?? ''
diff --git a/src/renderer/src/store/chat-store-app-actions.ts b/src/renderer/src/store/chat-store-app-actions.ts
index 726f26565..9b94bbda3 100644
--- a/src/renderer/src/store/chat-store-app-actions.ts
+++ b/src/renderer/src/store/chat-store-app-actions.ts
@@ -20,6 +20,7 @@ type CreateAppActionsOptions = {
setComposerModelLoadPromise: (promise: Promise | null) => void
applyTheme: (theme: AppSettingsV1['theme']) => void
applyUiFontScale: (scale: AppSettingsV1['uiFontScale']) => void
+ applyWriteTypography: (typography: AppSettingsV1['write']['typography']) => void
applyDocumentLocale: (locale: AppSettingsV1['locale']) => void
workspaceLabelFromPath: (workspaceRoot: string) => string
normalizeWorkspaceRoot: (workspaceRoot?: string | null) => string
@@ -54,6 +55,7 @@ export function createAppActions(options: CreateAppActionsOptions): Pick<
setComposerModelLoadPromise,
applyTheme,
applyUiFontScale,
+ applyWriteTypography,
applyDocumentLocale,
workspaceLabelFromPath,
normalizeWorkspaceRoot
@@ -160,6 +162,7 @@ export function createAppActions(options: CreateAppActionsOptions): Pick<
const workspaceRoot = normalizeWorkspaceRoot(settings.workspaceRoot)
applyTheme(settings.theme)
applyUiFontScale(settings.uiFontScale)
+ if (settings.write?.typography) applyWriteTypography(settings.write.typography)
set({
workspaceRoot,
workspaceLabel: workspaceLabelFromPath(workspaceRoot),
diff --git a/src/renderer/src/store/chat-store-navigation-actions.ts b/src/renderer/src/store/chat-store-navigation-actions.ts
index 8dffd2a71..99037de68 100644
--- a/src/renderer/src/store/chat-store-navigation-actions.ts
+++ b/src/renderer/src/store/chat-store-navigation-actions.ts
@@ -2,7 +2,7 @@ import type { NormalizedThread } from '../agent/types'
import { getProvider } from '../agent/registry'
import { rendererRuntimeClient } from '../agent/runtime-client'
import i18n from '../i18n'
-import { applyTheme, applyUiFontScale } from '../lib/apply-theme'
+import { applyTheme, applyUiFontScale, applyWriteTypography } from '../lib/apply-theme'
import { formatWorkspacePickerError } from '../lib/format-workspace-picker-error'
import { formatRuntimeError, getRuntimeErrorCode } from '../lib/format-runtime-error'
import {
@@ -367,6 +367,7 @@ export function createNavigationActions(
const needsInitialSetup = !getActiveAgentApiKey(settings).trim()
applyTheme(settings.theme)
applyUiFontScale(settings.uiFontScale)
+ if (settings.write?.typography) applyWriteTypography(settings.write.typography)
await get().applyI18nFromSettings(settings.locale)
if (!runtimeStatusUnsubscribe && typeof window.kunGui.onRuntimeStatus === 'function') {
runtimeStatusUnsubscribe = window.kunGui.onRuntimeStatus((status) => {
diff --git a/src/renderer/src/store/chat-store-runtime.ts b/src/renderer/src/store/chat-store-runtime.ts
index 9127f964b..1128327d8 100644
--- a/src/renderer/src/store/chat-store-runtime.ts
+++ b/src/renderer/src/store/chat-store-runtime.ts
@@ -418,7 +418,8 @@ function notifyWriteWorkspaceFileRefresh(
void useWriteWorkspaceStore.getState().syncActiveFileFromDisk(workspaceRoot, {
path: activeFilePath,
animate: true,
- force: true
+ force: true,
+ reviewAsDiff: true
})
}
@@ -1150,9 +1151,12 @@ export function buildThreadEventSink(
// permanently in the busy state.
if (get().busy) armBusyWatchdog(set, get)
},
- onUsage: () => {
+ onUsage: (usage) => {
if (!isCurrentStream()) return
- set((s) => ({ usageRefreshKey: s.usageRefreshKey + 1 }))
+ set((s) => ({
+ usageRefreshKey: s.usageRefreshKey + 1,
+ lastTurnUsage: { threadId: s.activeThreadId ?? '', snapshot: usage }
+ }))
}
}
}
diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts
index edcc97511..863e35502 100644
--- a/src/renderer/src/store/chat-store-types.ts
+++ b/src/renderer/src/store/chat-store-types.ts
@@ -8,6 +8,7 @@ import type {
ThreadGoalStatus,
ThreadTodoList,
ThreadTodoStatus,
+ ThreadUsageSnapshot,
UserInputAnswer
} from '../agent/types'
import type { KunRuntimeStatusPayload } from '@shared/kun-gui-api'
@@ -150,6 +151,12 @@ export type ChatState = {
liveAssistant: string
lastSeq: number
usageRefreshKey: number
+ /**
+ * Latest turn's usage snapshot, tagged with the thread it belongs to. Used by
+ * the context-capacity gauge: the last turn's prompt tokens ≈ what currently
+ * occupies the window. Null until a live turn reports usage.
+ */
+ lastTurnUsage: { threadId: string; snapshot: ThreadUsageSnapshot } | null
busy: boolean
error: string | null
runtimeErrorDetail: string | null
diff --git a/src/renderer/src/store/chat-store.ts b/src/renderer/src/store/chat-store.ts
index be8942d79..662c88634 100644
--- a/src/renderer/src/store/chat-store.ts
+++ b/src/renderer/src/store/chat-store.ts
@@ -3,7 +3,7 @@ import type { NormalizedThread } from '../agent/types'
import { getProvider } from '../agent/registry'
import { rendererRuntimeClient } from '../agent/runtime-client'
import i18n from '../i18n'
-import { applyDocumentLocale, applyTheme, applyUiFontScale } from '../lib/apply-theme'
+import { applyDocumentLocale, applyTheme, applyUiFontScale, applyWriteTypography } from '../lib/apply-theme'
import { formatWorkspacePickerError } from '../lib/format-workspace-picker-error'
import { formatRuntimeError, getRuntimeErrorCode } from '../lib/format-runtime-error'
import {
@@ -140,6 +140,7 @@ export const useChatStore = create((set, get) => ({
liveAssistant: '',
lastSeq: 0,
usageRefreshKey: 0,
+ lastTurnUsage: null,
busy: false,
error: null,
runtimeErrorDetail: null,
@@ -193,6 +194,7 @@ export const useChatStore = create((set, get) => ({
},
applyTheme,
applyUiFontScale,
+ applyWriteTypography,
applyDocumentLocale,
workspaceLabelFromPath,
normalizeWorkspaceRoot: (workspaceRoot) => normalizeWorkspaceRoot(workspaceRoot ?? undefined)
diff --git a/src/renderer/src/styles/write-editor.css b/src/renderer/src/styles/write-editor.css
index 68eda5f14..dd43ab1cf 100644
--- a/src/renderer/src/styles/write-editor.css
+++ b/src/renderer/src/styles/write-editor.css
@@ -111,6 +111,110 @@ summary,
color: color-mix(in srgb, var(--ds-accent) 82%, var(--ds-text));
}
+/* Inline diff review (AI rewrite accept/reject) — top panel inside the editor */
+.cm-write-diff-panel {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background: color-mix(in srgb, var(--ds-accent) 9%, var(--ds-bg-canvas));
+ border-bottom: 1px solid var(--ds-border-muted);
+ font-size: 12.5px;
+}
+
+.cm-write-diff-panel-label {
+ margin-right: auto;
+ font-weight: 600;
+ color: var(--ds-text-muted);
+}
+
+.cm-write-diff-accept-all,
+.cm-write-diff-reject-all {
+ cursor: pointer;
+ border-radius: 8px;
+ padding: 3px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ border: 1px solid transparent;
+ transition: background 0.15s ease;
+}
+
+.cm-write-diff-accept-all {
+ background: #16a34a;
+ color: #fff;
+}
+
+.cm-write-diff-accept-all:hover {
+ background: #15803d;
+}
+
+.cm-write-diff-reject-all {
+ background: transparent;
+ color: var(--ds-text-muted);
+ border-color: var(--ds-border);
+}
+
+.cm-write-diff-reject-all:hover {
+ background: var(--ds-hover);
+}
+
+/* @codemirror/merge unified diff: red deletions / green insertions + per-chunk
+ accept/reject buttons. Scoped under the host so they override the package
+ base theme regardless of stylesheet order. */
+.write-codemirror-host .cm-deletedChunk,
+.write-codemirror-host .cm-deletedLine,
+.write-codemirror-host .cm-deletedText {
+ background-color: color-mix(in srgb, #dc2626 15%, transparent);
+}
+
+.write-codemirror-host .cm-deletedText {
+ text-decoration: line-through;
+ text-decoration-color: color-mix(in srgb, #dc2626 58%, transparent);
+}
+
+.write-codemirror-host .cm-changedLine,
+.write-codemirror-host .cm-changedText,
+.write-codemirror-host .cm-insertedLine {
+ background-color: color-mix(in srgb, #16a34a 15%, transparent);
+}
+
+.write-codemirror-host .cm-chunkButtons {
+ display: inline-flex;
+ gap: 4px;
+}
+
+.write-codemirror-host .cm-chunkButtons button {
+ cursor: pointer;
+ border: 1px solid var(--ds-border);
+ border-radius: 6px;
+ background: var(--ds-card);
+ color: var(--ds-text);
+ font-size: 11px;
+ line-height: 1.6;
+ padding: 0 7px;
+}
+
+.write-codemirror-host .cm-chunkButtons button[name='accept'] {
+ border-color: color-mix(in srgb, #16a34a 45%, transparent);
+ color: #15803d;
+}
+
+.write-codemirror-host .cm-chunkButtons button:hover {
+ background: var(--ds-hover);
+}
+
+[data-theme='dark'] .write-codemirror-host .cm-deletedChunk,
+[data-theme='dark'] .write-codemirror-host .cm-deletedLine,
+[data-theme='dark'] .write-codemirror-host .cm-deletedText {
+ background-color: color-mix(in srgb, #f87171 22%, transparent);
+}
+
+[data-theme='dark'] .write-codemirror-host .cm-changedLine,
+[data-theme='dark'] .write-codemirror-host .cm-changedText,
+[data-theme='dark'] .write-codemirror-host .cm-insertedLine {
+ background-color: color-mix(in srgb, #4ade80 20%, transparent);
+}
+
.write-codemirror-host > .cm-editor {
--write-selection-bg: var(--ds-selection);
--write-selection-text: inherit;
@@ -142,8 +246,9 @@ summary,
width: min(100%, 864px);
margin: 0 auto;
padding: clamp(40px, 7vh, 72px) clamp(24px, 5vw, 72px) 120px;
- font-size: 16px;
- line-height: 1.75;
+ font-family: var(--write-editor-font-family, inherit);
+ font-size: var(--write-editor-font-size, 16px);
+ line-height: var(--write-editor-line-height, 1.75);
color: color-mix(in srgb, var(--ds-text) 94%, var(--ds-text-muted));
}
diff --git a/src/renderer/src/styles/write-rich-editor.css b/src/renderer/src/styles/write-rich-editor.css
index 0b85adb69..3b881dae3 100644
--- a/src/renderer/src/styles/write-rich-editor.css
+++ b/src/renderer/src/styles/write-rich-editor.css
@@ -13,10 +13,10 @@
outline: none;
color: var(--ds-text);
caret-color: var(--ds-text);
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'PingFang SC',
- 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
- font-size: 16px;
- line-height: 1.75;
+ font-family: var(--write-editor-font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'PingFang SC',
+ 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif);
+ font-size: var(--write-editor-font-size, 16px);
+ line-height: var(--write-editor-line-height, 1.75);
word-break: break-word;
}
diff --git a/src/renderer/src/write/agent-presets.ts b/src/renderer/src/write/agent-presets.ts
new file mode 100644
index 000000000..dc8b395dc
--- /dev/null
+++ b/src/renderer/src/write/agent-presets.ts
@@ -0,0 +1,33 @@
+import i18n from '../i18n'
+import { isBuiltinWriteAgentPresetId, type WriteAgentPresetV1 } from '@shared/app-settings'
+
+export type ResolvedWriteAgentPreset = {
+ id: string
+ name: string
+ emoji: string
+ persona: string
+ builtin: boolean
+}
+
+/**
+ * Fills localized defaults for built-in agent presets that the user has not
+ * customized (empty name/persona), mirroring the quick-action resolution
+ * convention. Reads the global i18n instance so it works from any component
+ * regardless of its bound namespace.
+ */
+export function resolveWriteAgentPreset(preset: WriteAgentPresetV1): ResolvedWriteAgentPreset {
+ const builtin = isBuiltinWriteAgentPresetId(preset.id)
+ const name =
+ preset.name.trim() ||
+ (builtin ? i18n.t(`writeAgentPreset_${preset.id}_name`, { ns: 'common' }) : preset.id)
+ const persona =
+ preset.persona.trim() ||
+ (builtin ? i18n.t(`writeAgentPreset_${preset.id}_persona`, { ns: 'common' }) : '')
+ return {
+ id: preset.id,
+ name,
+ emoji: preset.emoji.trim() || '🤖',
+ persona,
+ builtin
+ }
+}
diff --git a/src/renderer/src/write/inline-completion/feedback.test.ts b/src/renderer/src/write/inline-completion/feedback.test.ts
index df84b4b42..cacde3683 100644
--- a/src/renderer/src/write/inline-completion/feedback.test.ts
+++ b/src/renderer/src/write/inline-completion/feedback.test.ts
@@ -58,6 +58,20 @@ describe('evaluateInlineCompletionCandidate', () => {
expect(decision.feedback.reason).toBe('model-returned-action')
})
+ it('suppresses candidates that still carry leaked protocol markers', () => {
+ const decision = evaluateInlineCompletionCandidate(
+ context(),
+ {
+ text: '>>> <<>> <<>> <<>> << {
const decision = evaluateInlineCompletionCandidate(
context({ suffixWindow: 'a focused continuation after the cursor' }),
diff --git a/src/renderer/src/write/inline-completion/feedback.ts b/src/renderer/src/write/inline-completion/feedback.ts
index 62578af33..b332cbcc7 100644
--- a/src/renderer/src/write/inline-completion/feedback.ts
+++ b/src/renderer/src/write/inline-completion/feedback.ts
@@ -20,6 +20,15 @@ function sanitizeText(text = ''): string {
return String(text || '').replace(/\r\n?/g, '\n').replaceAll(String.fromCharCode(0), '')
}
+// Defense-in-depth: the backend strips protocol markers, but if a malformed
+// SHORT/LONG/EDIT/PREFIX/SUFFIX skeleton ever reaches here it must be suppressed
+// rather than rendered as ghost text.
+const MARKER_ARTIFACT_PATTERN = /<<<[ \t]*(?:SHORT|LONG|EDIT|PREFIX|SUFFIX|EDIT_SCOPE)\b/i
+
+function containsMarkerArtifact(text = ''): boolean {
+ return MARKER_ARTIFACT_PATTERN.test(text)
+}
+
function compactText(text = ''): string {
return sanitizeText(text).replace(/\s+/g, ' ').trim()
}
@@ -198,6 +207,9 @@ export function evaluateInlineCompletionCandidate(
if (!compactText(text) && !usefulSingleToken(text, context)) {
return reject('blank-candidate', context, text, 0, mode)
}
+ if (containsMarkerArtifact(text)) {
+ return reject('marker-artifact', context, text, 0, mode)
+ }
if (isEditAction) {
const action = rawAction
diff --git a/src/renderer/src/write/quoted-selection.ts b/src/renderer/src/write/quoted-selection.ts
index 4827013f7..ff2f465a5 100644
--- a/src/renderer/src/write/quoted-selection.ts
+++ b/src/renderer/src/write/quoted-selection.ts
@@ -12,7 +12,8 @@ export const WRITE_RETRIEVAL_HEADING = '[相关文献上下文]'
export const WRITE_RETRIEVAL_END = '[/相关文献上下文]'
const WRITE_ASSISTANT_INTERACTION_RULE =
- '交互限制: 当前 GUI 无法提交 request_user_input 的 HTTP 响应;需要更多信息时,直接用普通文本向用户提问,不要调用 request_user_input。'
+ '交互限制: 当前 GUI 无法提交 request_user_input 的 HTTP 响应;需要更多信息时,直接用普通文本向用户提问,不要调用 request_user_input。\n' +
+ '改稿约定: 当用户要求修改、改写、润色、翻译、续写、扩写或整理“当前文件”所指的文档时,你必须用 edit 或 write 工具把改动直接写入该文件(建议先用 read 取到准确原文,再 edit/write),完成后只用一两句话说明改了什么——绝不要只在回复里贴出修改后的文本却不落盘。用户会在编辑器里以行级红绿 Diff 审阅你的改动、逐行接受或拒绝,所以请放心直接改。仅当用户只是提问、讨论、或处理的是只读引用片段时,才用纯文本回答、不改文件。'
export type WriteQuotedSelection = {
id: string
@@ -110,6 +111,9 @@ type WritePromptContext = {
workspaceRoot?: string
activeFilePath?: string | null
retrieval?: WriteRetrievalContext | null
+ /** Active writing-agent persona; folded into the context block so it frames the
+ * model without showing as raw text in the user's message bubble. */
+ agentPersona?: string
}
export type WritePromptDisplayContext = {
@@ -190,6 +194,9 @@ export function composeWritePrompt(
const body = input.trim()
const contextLines: string[] = []
contextLines.push(WRITE_ASSISTANT_INTERACTION_RULE)
+ if (context.agentPersona?.trim()) {
+ contextLines.push(`当前写作 Agent 人设(请严格遵循):${context.agentPersona.trim()}`)
+ }
if (context.workspaceRoot?.trim()) {
contextLines.push(`工作空间: ${context.workspaceRoot.trim()}`)
}
diff --git a/src/renderer/src/write/write-workspace-file-actions.test.ts b/src/renderer/src/write/write-workspace-file-actions.test.ts
index eefe7e4f7..bd5dc232c 100644
--- a/src/renderer/src/write/write-workspace-file-actions.test.ts
+++ b/src/renderer/src/write/write-workspace-file-actions.test.ts
@@ -11,6 +11,7 @@ function makeBaseState(): WriteWorkspaceState {
inlineCompletion: defaultWriteSettings().inlineCompletion,
inlineCompletionApiReady: false,
selectionAssist: defaultWriteSettings().selectionAssist,
+ agentPresets: defaultWriteSettings().agentPresets,
imageGenReady: false,
prototypeReady: false,
settingsLoading: false,
@@ -20,6 +21,7 @@ function makeBaseState(): WriteWorkspaceState {
assistantOpen: true,
assistantModel: 'auto',
assistantProviderId: '',
+ assistantAgentPresetId: '',
loadWriteSettings: async () => undefined,
selectWriteWorkspace: async () => undefined,
addWriteWorkspace: async () => undefined,
@@ -41,6 +43,9 @@ function makeBaseState(): WriteWorkspaceState {
setPreviewMode: () => undefined,
setAssistantOpen: () => undefined,
setAssistantModel: () => undefined,
+ setAssistantAgentPresetId: () => undefined,
+ setReviewActive: () => undefined,
+ clearPendingAgentReview: () => undefined,
setSelection: () => undefined,
recordRecentEdits: () => undefined,
quoteCurrentSelection: () => undefined,
diff --git a/src/renderer/src/write/write-workspace-settings-actions.ts b/src/renderer/src/write/write-workspace-settings-actions.ts
index 358e7e13c..728a01b15 100644
--- a/src/renderer/src/write/write-workspace-settings-actions.ts
+++ b/src/renderer/src/write/write-workspace-settings-actions.ts
@@ -34,6 +34,7 @@ function applyWriteSettingsState(
workspaceRoots: write.workspaces,
inlineCompletion: write.inlineCompletion,
selectionAssist: write.selectionAssist,
+ agentPresets: write.agentPresets,
inlineCompletionApiReady: Boolean(resolveWriteInlineCompletionApiKey(settings).trim()),
imageGenReady: Boolean(
imageGeneration?.enabled &&
diff --git a/src/renderer/src/write/write-workspace-store-helpers.ts b/src/renderer/src/write/write-workspace-store-helpers.ts
index bcb9a98f2..7f28e7a3c 100644
--- a/src/renderer/src/write/write-workspace-store-helpers.ts
+++ b/src/renderer/src/write/write-workspace-store-helpers.ts
@@ -8,12 +8,14 @@ import {
DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS,
DEFAULT_WRITE_INLINE_LONG_COMPLETION_MIN_ACCEPT_SCORE,
DEFAULT_WRITE_WORKSPACE_ROOT,
+ normalizeWriteAgentPresets,
normalizeWriteInlineCompletionModel,
normalizeWriteSelectionAssistSettings,
resolveWriteInlineCompletionApiKey,
resolveWriteInlineCompletionBaseUrl,
resolveWriteInlineCompletionModel,
type AppSettingsV1,
+ type WriteAgentPresetV1,
type WriteInlineCompletionSettingsV1,
type WriteSelectionAssistSettingsV1,
type WriteSettingsV1
@@ -100,6 +102,7 @@ export function normalizeWriteSettings(settings?: Partial | nul
workspaces: string[]
inlineCompletion: WriteInlineCompletionSettingsV1
selectionAssist: WriteSelectionAssistSettingsV1
+ agentPresets: WriteAgentPresetV1[]
} {
const defaultWorkspaceRoot = normalizePath(settings?.defaultWorkspaceRoot || DEFAULT_WRITE_WORKSPACE_ROOT)
const activeWorkspaceRoot = normalizePath(settings?.activeWorkspaceRoot || defaultWorkspaceRoot)
@@ -154,7 +157,8 @@ export function normalizeWriteSettings(settings?: Partial | nul
? Math.max(64, Math.min(1_024, Math.round(longMaxTokens)))
: DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS
},
- selectionAssist: normalizeWriteSelectionAssistSettings(settings?.selectionAssist)
+ selectionAssist: normalizeWriteSelectionAssistSettings(settings?.selectionAssist),
+ agentPresets: normalizeWriteAgentPresets(settings?.agentPresets)
}
}
@@ -165,6 +169,7 @@ export function withResolvedInlineCompletionSettings(
workspaces: string[]
inlineCompletion: WriteInlineCompletionSettingsV1
selectionAssist: WriteSelectionAssistSettingsV1
+ agentPresets: WriteAgentPresetV1[]
},
settings: Pick
): {
@@ -173,6 +178,7 @@ export function withResolvedInlineCompletionSettings(
workspaces: string[]
inlineCompletion: WriteInlineCompletionSettingsV1
selectionAssist: WriteSelectionAssistSettingsV1
+ agentPresets: WriteAgentPresetV1[]
} {
return {
...write,
@@ -282,6 +288,8 @@ export function initialState(): Pick<
| 'fileError'
| 'fileLoading'
| 'saveStatus'
+ | 'pendingAgentReview'
+ | 'reviewActive'
| 'selection'
| 'quotedSelections'
| 'recentEdits'
@@ -306,6 +314,8 @@ export function initialState(): Pick<
fileError: null,
fileLoading: false,
saveStatus: 'saved',
+ pendingAgentReview: null,
+ reviewActive: false,
selection: emptySelection(),
quotedSelections: [],
recentEdits: []
diff --git a/src/renderer/src/write/write-workspace-store-types.ts b/src/renderer/src/write/write-workspace-store-types.ts
index 7bae8b569..03bb851dc 100644
--- a/src/renderer/src/write/write-workspace-store-types.ts
+++ b/src/renderer/src/write/write-workspace-store-types.ts
@@ -1,4 +1,4 @@
-import type { WriteInlineCompletionSettingsV1, WriteSelectionAssistSettingsV1 } from '@shared/app-settings'
+import type { WriteAgentPresetV1, WriteInlineCompletionSettingsV1, WriteSelectionAssistSettingsV1 } from '@shared/app-settings'
import type { WorkspaceEntry } from '@shared/workspace-file'
import type { WriteEditorSelectionState } from '../components/write/WriteMarkdownEditor'
import type { WriteQuotedSelection } from './quoted-selection'
@@ -15,6 +15,8 @@ export type WriteWorkspaceState = {
inlineCompletionApiReady: boolean
/** Selection toolbar AI assists: quick action prompts + infographic prompt. */
selectionAssist: WriteSelectionAssistSettingsV1
+ /** Named writing-assistant personas for quick switching. */
+ agentPresets: WriteAgentPresetV1[]
/** True when the image generation provider is fully configured (enables 生成信息图). */
imageGenReady: boolean
/** True when the primary chat provider is configured (enables 生成交互原型). */
@@ -40,10 +42,16 @@ export type WriteWorkspaceState = {
fileError: string | null
fileLoading: boolean
saveStatus: WriteSaveStatus
+ /** Set when an agent edited the active file and the change awaits red/green review. */
+ pendingAgentReview: { nextContent: string } | null
+ /** True while an inline diff review (agent edit or AI rewrite) is in progress. */
+ reviewActive: boolean
previewMode: WritePreviewMode
assistantOpen: boolean
assistantModel: string
assistantProviderId: string
+ /** Active writing-agent persona preset id ('' = none); applied to assistant sends. */
+ assistantAgentPresetId: string
selection: WriteEditorSelectionState
quotedSelections: WriteQuotedSelection[]
recentEdits: WriteRecentEdit[]
@@ -67,6 +75,8 @@ export type WriteWorkspaceState = {
message?: string
animate?: boolean
force?: boolean
+ /** When true, surface the change as a red/green diff review instead of applying it. */
+ reviewAsDiff?: boolean
}
) => Promise
syncActiveImageFromDisk: (workspaceRoot: string, path?: string) => Promise
@@ -79,7 +89,10 @@ export type WriteWorkspaceState = {
setPreviewMode: (mode: WritePreviewMode) => void
setAssistantOpen: (open: boolean) => void
setAssistantModel: (model: string, providerId?: string) => void
+ setAssistantAgentPresetId: (id: string) => void
setSelection: (selection: WriteEditorSelectionState) => void
+ setReviewActive: (active: boolean) => void
+ clearPendingAgentReview: () => void
recordRecentEdits: (edits: WriteRecentEdit[]) => void
quoteCurrentSelection: (workspaceRoot: string) => void
removeQuotedSelection: (id: string) => void
diff --git a/src/renderer/src/write/write-workspace-store.ts b/src/renderer/src/write/write-workspace-store.ts
index 1a9268c8c..cd5bc0233 100644
--- a/src/renderer/src/write/write-workspace-store.ts
+++ b/src/renderer/src/write/write-workspace-store.ts
@@ -76,6 +76,7 @@ export const useWriteWorkspaceStore = create((set, get) =>
},
inlineCompletionApiReady: false,
selectionAssist: defaultWriteSelectionAssistSettings(),
+ agentPresets: [],
imageGenReady: false,
prototypeReady: false,
settingsLoading: false,
@@ -85,6 +86,7 @@ export const useWriteWorkspaceStore = create((set, get) =>
assistantOpen: readStoredAssistantOpen(),
assistantModel: readStoredAssistantModel(),
assistantProviderId: readStoredAssistantProviderId(),
+ assistantAgentPresetId: '',
...createWriteSettingsActions({ set, get }),
...createWriteFileActions({
@@ -104,6 +106,10 @@ export const useWriteWorkspaceStore = create((set, get) =>
}))
},
+ setReviewActive: (active) => set({ reviewActive: active === true }),
+
+ clearPendingAgentReview: () => set({ pendingAgentReview: null }),
+
syncActiveFileFromDisk: async (workspaceRoot, options = {}) => {
const snapshot = get()
const force = options.force === true
@@ -174,6 +180,29 @@ export const useWriteWorkspaceStore = create((set, get) =>
}
cancelExternalSyncAnimation()
+
+ // Agent edits surface as a red/green diff review instead of silently
+ // overwriting the editor. The disk already holds `content`, so we record it
+ // as the saved baseline and stash it for review; the review's commit later
+ // reconciles disk to whatever the user accepts or rejects.
+ if (
+ options.reviewAsDiff === true &&
+ !nextTruncated &&
+ content.length <= MAX_ANIMATED_EXTERNAL_SYNC_CHARS &&
+ latest.fileContent !== content
+ ) {
+ lastSavedContent = content
+ set({
+ pendingAgentReview: { nextContent: content },
+ reviewActive: true,
+ fileSize: nextSize,
+ fileTruncated: nextTruncated,
+ fileError: null,
+ fileLoading: false
+ })
+ return true
+ }
+
lastSavedContent = content
if (
@@ -327,6 +356,10 @@ export const useWriteWorkspaceStore = create((set, get) =>
set({ assistantModel: normalized, assistantProviderId: normalizedProviderId })
},
+ setAssistantAgentPresetId: (id) => {
+ set({ assistantAgentPresetId: typeof id === 'string' ? id : '' })
+ },
+
setSelection: (selection) => {
if (writeSelectionStatesEqual(get().selection, selection)) return
set({ selection })
diff --git a/src/shared/app-settings-kun.ts b/src/shared/app-settings-kun.ts
index 731c9a7fb..1a6dcb4bd 100644
--- a/src/shared/app-settings-kun.ts
+++ b/src/shared/app-settings-kun.ts
@@ -13,6 +13,7 @@ import {
DEFAULT_VIDEO_GENERATION_PROTOCOL,
MODEL_REASONING_EFFORTS,
MODEL_REASONING_REQUEST_PROTOCOLS,
+ normalizeModelEndpointFormat,
type AppSettingsV1,
type KunContextCompactionSettingsV1,
type KunHistoryHygieneSettingsV1,
@@ -261,6 +262,7 @@ export function defaultKunContextCompactionSettings(): KunContextCompactionSetti
export function defaultKunRuntimeTuningSettings(): KunRuntimeTuningSettingsV1 {
return {
+ streamIdleTimeoutMs: 45_000,
toolStorm: {
enabled: true,
windowSize: 8,
@@ -361,6 +363,9 @@ export function mergeKunRuntimeSettings(
...currentRuntimeTuning,
...(patch?.runtimeTuning
? {
+ ...(patch.runtimeTuning.streamIdleTimeoutMs !== undefined
+ ? { streamIdleTimeoutMs: patch.runtimeTuning.streamIdleTimeoutMs }
+ : {}),
toolStorm: {
...currentRuntimeTuning.toolStorm,
...(patch.runtimeTuning.toolStorm ?? {})
@@ -576,6 +581,12 @@ function boundedPositiveInt(value: unknown, fallback: number, max = Number.MAX_S
return Math.min(Math.floor(value), max)
}
+/** Like {@link boundedPositiveInt} but accepts `0` (e.g. "disabled"). */
+function boundedNonNegativeInt(value: unknown, fallback: number, max = Number.MAX_SAFE_INTEGER): number {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return fallback
+ return Math.min(Math.floor(value), max)
+}
+
function normalizeKunStorageSettings(
input: Partial | undefined
): KunStorageSettingsV1 {
@@ -611,6 +622,11 @@ function normalizeKunRuntimeTuningSettings(
): KunRuntimeTuningSettingsV1 {
const defaults = defaultKunRuntimeTuningSettings()
return {
+ streamIdleTimeoutMs: boundedNonNegativeInt(
+ input?.streamIdleTimeoutMs,
+ defaults.streamIdleTimeoutMs,
+ 3_600_000
+ ),
toolStorm: {
enabled: input?.toolStorm?.enabled !== false,
windowSize: boundedPositiveInt(input?.toolStorm?.windowSize, defaults.toolStorm.windowSize, 128),
@@ -665,6 +681,9 @@ function normalizeKunModelProfile(
? input.contextWindowTokens
: undefined
const reasoning = normalizeKunReasoningCapability(input?.reasoning)
+ const endpointFormat = typeof input?.endpointFormat === 'string' && input.endpointFormat.trim()
+ ? normalizeModelEndpointFormat(input.endpointFormat)
+ : undefined
return {
...(normalizeKunProfileAliases(input?.aliases).length
? { aliases: normalizeKunProfileAliases(input?.aliases) }
@@ -674,7 +693,8 @@ function normalizeKunModelProfile(
outputModalities: normalizeKunModelInputModalities(input?.outputModalities),
supportsToolCalling: input?.supportsToolCalling !== false,
messageParts: normalizeKunModelMessageParts(input?.messageParts, fallbackMessageParts),
- ...(reasoning ? { reasoning } : {})
+ ...(reasoning ? { reasoning } : {}),
+ ...(endpointFormat ? { endpointFormat } : {})
}
}
diff --git a/src/shared/app-settings-provider.test.ts b/src/shared/app-settings-provider.test.ts
index d643f5f73..db266047b 100644
--- a/src/shared/app-settings-provider.test.ts
+++ b/src/shared/app-settings-provider.test.ts
@@ -1026,4 +1026,30 @@ describe('provider presets', () => {
}))
}
})
+
+ it('keeps per-model endpointFormat overrides on the OpenCode Go preset', () => {
+ const preset = getModelProviderPreset('opencode-go')
+ expect(preset).not.toBeNull()
+ const profile = modelProviderPresetProfile(preset!, 'sk-opencode')
+ // MiniMax / Qwen route over Anthropic Messages...
+ expect(profile.modelProfiles['minimax-m3'].endpointFormat).toBe('messages')
+ expect(profile.modelProfiles['qwen3.7-max'].endpointFormat).toBe('messages')
+ // ...while chat-completions models carry no override (they inherit).
+ expect(profile.modelProfiles['glm-5.1'].endpointFormat).toBeUndefined()
+ expect(profile.modelProfiles['kimi-k2.7'].endpointFormat).toBeUndefined()
+
+ // The override survives the full settings normalization round-trip.
+ const resolved = resolveKunRuntimeSettings({
+ ...settings(),
+ provider: {
+ ...defaultModelProviderSettings(),
+ providers: [...defaultModelProviderSettings().providers, profile]
+ },
+ agents: {
+ kun: { ...defaultKunRuntimeSettings(), providerId: profile.id, model: 'minimax-m3' }
+ }
+ })
+ expect(resolved.modelProfiles['minimax-m3'].endpointFormat).toBe('messages')
+ expect(resolved.modelProfiles['glm-5.1'].endpointFormat).toBeUndefined()
+ })
})
diff --git a/src/shared/app-settings-provider.ts b/src/shared/app-settings-provider.ts
index 64034ca26..cd6b76fe1 100644
--- a/src/shared/app-settings-provider.ts
+++ b/src/shared/app-settings-provider.ts
@@ -47,7 +47,7 @@ import {
type TextToSpeechProtocol,
type VideoGenerationProtocol
} from './app-settings-types'
-import { normalizeModelEndpointFormat } from '../../kun/src/contracts/model-endpoint-format.js'
+import { normalizeModelEndpointFormat, type ModelEndpointFormat } from '../../kun/src/contracts/model-endpoint-format.js'
import { getKunRuntimeSettings } from './app-settings-kun'
import { normalizeDeepseekBaseUrl } from './app-settings-normalizers'
import { DEFAULT_COMPOSER_MODEL_IDS } from './default-composer-models'
@@ -978,6 +978,7 @@ function normalizeModelProviderModelProfile(
: ['text']
const contextWindowTokens = boundedPositiveInteger(input?.contextWindowTokens)
const reasoning = normalizeModelReasoningCapability(input?.reasoning)
+ const endpointFormat = normalizeOptionalModelEndpointFormat(input?.endpointFormat)
return {
...(normalizeProviderModels(input?.aliases).length
? { aliases: normalizeProviderModels(input?.aliases) }
@@ -987,10 +988,25 @@ function normalizeModelProviderModelProfile(
outputModalities: normalizeModelInputModalities(input?.outputModalities),
supportsToolCalling: input?.supportsToolCalling !== false,
messageParts: normalizeModelMessageParts(input?.messageParts, defaultMessageParts),
- ...(reasoning ? { reasoning } : {})
+ ...(reasoning ? { reasoning } : {}),
+ ...(endpointFormat ? { endpointFormat } : {})
}
}
+/**
+ * A per-model wire-format override is only meaningful when explicitly set;
+ * an absent value means "inherit the provider's endpointFormat". Returns
+ * undefined for blank/missing input instead of coercing to the default, so
+ * inheritance is preserved end-to-end.
+ */
+function normalizeOptionalModelEndpointFormat(
+ value: unknown
+): ModelEndpointFormat | undefined {
+ return typeof value === 'string' && value.trim()
+ ? normalizeModelEndpointFormat(value)
+ : undefined
+}
+
function normalizeModelReasoningCapability(
input: ModelProviderModelProfilePatchV1['reasoning'] | undefined
): ModelProviderReasoningCapabilityV1 | undefined {
diff --git a/src/shared/app-settings-types.ts b/src/shared/app-settings-types.ts
index 2fb331fc3..eab310ca8 100644
--- a/src/shared/app-settings-types.ts
+++ b/src/shared/app-settings-types.ts
@@ -105,6 +105,8 @@ export type ModelProviderModelProfileV1 = {
supportsToolCalling: boolean
messageParts: ModelProviderMessagePartSupport[]
reasoning?: ModelProviderReasoningCapabilityV1
+ /** Per-model wire-format override. Omitted means "inherit the provider's endpointFormat". */
+ endpointFormat?: ModelEndpointFormat
}
export type ModelProviderImageCapabilityV1 = {
protocol: ImageGenerationProtocol
@@ -354,6 +356,12 @@ export type KunToolArgumentRepairSettingsV1 = {
}
export type KunRuntimeTuningSettingsV1 = {
+ /**
+ * Max idle gap (ms) between streaming chunks before a turn fails with
+ * `stream_idle_timeout`. `0` disables the guard — useful for local LLM
+ * servers that stay silent while prefilling a very large prompt.
+ */
+ streamIdleTimeoutMs: number
toolStorm: KunToolStormSettingsV1
toolArgumentRepair: KunToolArgumentRepairSettingsV1
}
@@ -371,6 +379,7 @@ export type KunSettingsEnvelopeV1 = {
export type AgentRuntimeSettingsMapV1 = KunSettingsEnvelopeV1
export type KunRuntimeTuningSettingsPatchV1 = {
+ streamIdleTimeoutMs?: number
toolStorm?: Partial
toolArgumentRepair?: Partial
}
@@ -638,12 +647,78 @@ export type WriteSelectionAssistSettingsV1 = {
quickActions: WriteQuickActionV1[]
}
+export type WriteFontPreset =
+ | 'system'
+ | 'sourceHanSans'
+ | 'yahei'
+ | 'pingfang'
+ | 'simhei'
+ | 'simsun'
+ | 'kaiti'
+ | 'custom'
+
+export const WRITE_FONT_PRESETS: readonly WriteFontPreset[] = [
+ 'system',
+ 'sourceHanSans',
+ 'yahei',
+ 'pingfang',
+ 'simhei',
+ 'simsun',
+ 'kaiti',
+ 'custom'
+] as const
+
+export const WRITE_EDITOR_FONT_SIZE_MIN = 12
+export const WRITE_EDITOR_FONT_SIZE_MAX = 28
+export const DEFAULT_WRITE_EDITOR_FONT_SIZE_PX = 16
+export const WRITE_EDITOR_LINE_HEIGHT_MIN = 1.4
+export const WRITE_EDITOR_LINE_HEIGHT_MAX = 2.2
+export const DEFAULT_WRITE_EDITOR_LINE_HEIGHT = 1.75
+
+/**
+ * Typography for the Write editor prose surfaces (rich editor, CodeMirror live
+ * appearance, and the markdown preview). The raw source appearance keeps its
+ * monospace family but still honors the configured size.
+ */
+export type WriteTypographySettingsV1 = {
+ /** Named font preset; 'custom' uses `customFontFamily`. */
+ fontPreset: WriteFontPreset
+ /** CSS font-family stack used when `fontPreset === 'custom'`. */
+ customFontFamily: string
+ /** Base font size in px, clamped to [WRITE_EDITOR_FONT_SIZE_MIN, WRITE_EDITOR_FONT_SIZE_MAX]. */
+ fontSizePx: number
+ /** Unitless line-height, clamped to [WRITE_EDITOR_LINE_HEIGHT_MIN, WRITE_EDITOR_LINE_HEIGHT_MAX]. */
+ lineHeight: number
+}
+
+export const WRITE_AGENT_PRESET_MAX_COUNT = 12
+export const WRITE_AGENT_PRESET_NAME_MAX_CHARS = 40
+export const WRITE_AGENT_PERSONA_MAX_CHARS = 4000
+
+/**
+ * A named, reusable writing-assistant persona (plot coordinator, line editor,
+ * foreshadowing tracker, continuity checker…). The persona text frames the
+ * assistant for a specific creative role and can be switched per conversation.
+ */
+export type WriteAgentPresetV1 = {
+ /** Stable id; built-in ids ('coordinator' | 'editor' | 'foreshadowing' | 'continuity') get localized name/persona fallbacks. */
+ id: string
+ /** Display name; empty = localized default for built-in ids. */
+ name: string
+ /** Short emoji/glyph badge shown in the switcher. */
+ emoji: string
+ /** Persona + behavior rules used to frame the assistant. Empty = localized default for built-in ids. */
+ persona: string
+}
+
export type WriteSettingsV1 = {
defaultWorkspaceRoot: string
activeWorkspaceRoot: string
workspaces: string[]
inlineCompletion: WriteInlineCompletionSettingsV1
selectionAssist: WriteSelectionAssistSettingsV1
+ typography: WriteTypographySettingsV1
+ agentPresets: WriteAgentPresetV1[]
}
export type ClawSettingsPatchV1 = Partial> & {
@@ -661,12 +736,15 @@ export type ScheduleSettingsPatchV1 = Partial<
tasks?: Array>
}
-export type WriteSettingsPatchV1 = Partial> & {
+export type WriteSettingsPatchV1 = Partial> & {
inlineCompletion?: Partial
selectionAssist?: Partial> & {
/** Replaced wholesale when present. */
quickActions?: Array>
}
+ typography?: Partial
+ /** Replaced wholesale when present. */
+ agentPresets?: Array>
}
export type ClawGeneratedFileV1 = {
@@ -676,7 +754,21 @@ export type ClawGeneratedFileV1 = {
}
export type ClawRunResult =
- | { ok: true; threadId: string; turnId?: string; text?: string; message?: string; files?: ClawGeneratedFileV1[] }
+ | {
+ ok: true
+ threadId: string
+ turnId?: string
+ text?: string
+ message?: string
+ files?: ClawGeneratedFileV1[]
+ /**
+ * Whether the watched turn finished within the response window.
+ * `false` means it outran the IM timeout and is still running —
+ * the caller should ack now and push the result when it finishes.
+ * Absent on the fire-and-forget (no `waitForResult`) path.
+ */
+ completed?: boolean
+ }
| { ok: false; message: string }
export type ScheduleRunResult = ClawRunResult
diff --git a/src/shared/app-settings-write.ts b/src/shared/app-settings-write.ts
index ad6959c6f..ad3067bf6 100644
--- a/src/shared/app-settings-write.ts
+++ b/src/shared/app-settings-write.ts
@@ -8,17 +8,30 @@ import {
DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS,
DEFAULT_WRITE_INLINE_LONG_COMPLETION_MIN_ACCEPT_SCORE,
DEFAULT_WRITE_WORKSPACE_ROOT,
+ DEFAULT_WRITE_EDITOR_FONT_SIZE_PX,
+ DEFAULT_WRITE_EDITOR_LINE_HEIGHT,
+ WRITE_EDITOR_FONT_SIZE_MAX,
+ WRITE_EDITOR_FONT_SIZE_MIN,
+ WRITE_EDITOR_LINE_HEIGHT_MAX,
+ WRITE_EDITOR_LINE_HEIGHT_MIN,
+ WRITE_FONT_PRESETS,
+ WRITE_AGENT_PERSONA_MAX_CHARS,
+ WRITE_AGENT_PRESET_MAX_COUNT,
+ WRITE_AGENT_PRESET_NAME_MAX_CHARS,
DEFAULT_MODEL_ENDPOINT_FORMAT,
DEFAULT_MODEL_PROVIDER_ID,
type AppSettingsV1,
type ModelEndpointFormat,
type ModelProviderProfileV1,
+ type WriteAgentPresetV1,
+ type WriteFontPreset,
type WriteInlineCompletionSettingsV1,
type WriteQuickActionMode,
type WriteQuickActionV1,
type WriteSelectionAssistSettingsV1,
type WriteSettingsPatchV1,
- type WriteSettingsV1
+ type WriteSettingsV1,
+ type WriteTypographySettingsV1
} from './app-settings-types'
import { getActiveAgentApiKey, getKunRuntimeSettings } from './app-settings-kun'
import { getModelProviderProfile, resolveModelProviderBaseUrl } from './app-settings-provider'
@@ -35,9 +48,11 @@ export const WRITE_QUICK_ACTION_MAX_COUNT = 12
export const WRITE_QUICK_ACTION_LABEL_MAX_CHARS = 64
export const WRITE_QUICK_ACTION_PROMPT_MAX_CHARS = 4_000
-// Built-in default modes: polish/explain answer through the sidebar assistant
-// (the inline rewrite pipeline proved too lossy for prose), reformat rewrites
-// the selection in place.
+// Built-in default modes: polish/explain answer through the sidebar assistant —
+// reliable, high-quality prose (the in-place inline-edit pipeline proved too
+// lossy/slow for whole-paragraph rewrites). reformat rewrites in place and lands
+// as an inline red/green diff review, as does any instruction typed into the
+// floating "AI edit" box.
const WRITE_QUICK_ACTION_BUILTIN_MODES: Record = {
polish: 'chat',
explain: 'chat',
@@ -114,13 +129,13 @@ export function normalizeWriteSelectionAssistSettings(
if (pristineBuiltin && WRITE_QUICK_ACTION_RETIRED_IDS.has(id)) continue
const storedMode: WriteQuickActionMode | null =
raw?.mode === 'edit' || raw?.mode === 'chat' ? raw.mode : null
- // One-shot migration: pristine 'polish' rows persisted the old in-place
- // default; they follow the new sidebar default. Customized rows keep the
- // user's explicit mode choice.
+ // Pristine 'polish' rows follow the current built-in default (sidebar chat),
+ // migrating away from any older in-place value. Customized rows (with a label
+ // or prompt) keep the user's explicit mode choice.
const mode: WriteQuickActionMode = storedMode === null
? builtinWriteQuickActionMode(id)
- : pristineBuiltin && id === 'polish' && storedMode === 'edit'
- ? 'chat'
+ : pristineBuiltin && id === 'polish'
+ ? builtinWriteQuickActionMode(id)
: storedMode
seen.add(id)
quickActions.push({ id, label, prompt, mode })
@@ -129,6 +144,120 @@ export function normalizeWriteSelectionAssistSettings(
return { infographicPrompt, designDraftPrompt, prototypePrompt, quickActions }
}
+// Concrete CSS font-family stacks per preset. Every stack ends with a generic
+// family and chains CJK fallbacks so a missing primary face still renders a
+// sensible Chinese font across macOS/Windows.
+const WRITE_FONT_STACKS: Record, string> = {
+ system:
+ "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif",
+ sourceHanSans: "'Source Han Sans SC', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
+ yahei: "'Microsoft YaHei', '微软雅黑', 'PingFang SC', 'Noto Sans SC', sans-serif",
+ pingfang: "'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif",
+ simhei: "'SimHei', '黑体', 'PingFang SC', 'Noto Sans SC', sans-serif",
+ simsun: "'SimSun', '宋体', 'Songti SC', serif",
+ kaiti: "'KaiTi', '楷体', 'STKaiti', 'Songti SC', serif"
+}
+
+export const DEFAULT_WRITE_FONT_PRESET: WriteFontPreset = 'system'
+
+export function defaultWriteTypography(): WriteTypographySettingsV1 {
+ return {
+ fontPreset: DEFAULT_WRITE_FONT_PRESET,
+ customFontFamily: '',
+ fontSizePx: DEFAULT_WRITE_EDITOR_FONT_SIZE_PX,
+ lineHeight: DEFAULT_WRITE_EDITOR_LINE_HEIGHT
+ }
+}
+
+/** Resolves a typography preset to a concrete CSS `font-family` stack. */
+export function writeFontStackFor(preset: WriteFontPreset, customFontFamily: string): string {
+ if (preset === 'custom') {
+ const trimmed = customFontFamily.trim()
+ return trimmed || WRITE_FONT_STACKS.system
+ }
+ return WRITE_FONT_STACKS[preset] ?? WRITE_FONT_STACKS.system
+}
+
+function clampWriteNumber(value: unknown, min: number, max: number, fallback: number): number {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return fallback
+ return Math.max(min, Math.min(max, n))
+}
+
+export function normalizeWriteTypography(
+ input: Partial | undefined
+): WriteTypographySettingsV1 {
+ const defaults = defaultWriteTypography()
+ const fontPreset =
+ typeof input?.fontPreset === 'string' &&
+ (WRITE_FONT_PRESETS as readonly string[]).includes(input.fontPreset)
+ ? (input.fontPreset as WriteFontPreset)
+ : defaults.fontPreset
+ const customFontFamily =
+ typeof input?.customFontFamily === 'string' ? input.customFontFamily.slice(0, 200) : defaults.customFontFamily
+ const fontSizePx = Math.round(
+ clampWriteNumber(input?.fontSizePx, WRITE_EDITOR_FONT_SIZE_MIN, WRITE_EDITOR_FONT_SIZE_MAX, defaults.fontSizePx)
+ )
+ // Snap line-height to one decimal so the slider produces clean values.
+ const lineHeight =
+ Math.round(
+ clampWriteNumber(
+ input?.lineHeight,
+ WRITE_EDITOR_LINE_HEIGHT_MIN,
+ WRITE_EDITOR_LINE_HEIGHT_MAX,
+ defaults.lineHeight
+ ) * 10
+ ) / 10
+ return { fontPreset, customFontFamily, fontSizePx, lineHeight }
+}
+
+export const WRITE_AGENT_PRESET_BUILTIN_IDS = ['coordinator', 'editor', 'foreshadowing', 'continuity'] as const
+
+const WRITE_AGENT_PRESET_BUILTIN_EMOJI: Record = {
+ coordinator: '🧭',
+ editor: '✒️',
+ foreshadowing: '🪤',
+ continuity: '🔍'
+}
+
+export function isBuiltinWriteAgentPresetId(id: string): boolean {
+ return (WRITE_AGENT_PRESET_BUILTIN_IDS as readonly string[]).includes(id)
+}
+
+// Writing agents are fully user-defined and opt-in: default to none and ship no
+// preset templates. Pristine built-in ids left over from older builds are
+// dropped in normalizeWriteAgentPresets so the list always starts clean.
+export function defaultWriteAgentPresets(): WriteAgentPresetV1[] {
+ return []
+}
+
+export function normalizeWriteAgentPresets(
+ input: Array> | undefined
+): WriteAgentPresetV1[] {
+ if (!Array.isArray(input)) return defaultWriteAgentPresets()
+ const seen = new Set()
+ const presets: WriteAgentPresetV1[] = []
+ for (const raw of input) {
+ const id = typeof raw?.id === 'string' ? raw.id.trim().slice(0, 64) : ''
+ if (!id || seen.has(id)) continue
+ const name = typeof raw?.name === 'string' ? raw.name.slice(0, WRITE_AGENT_PRESET_NAME_MAX_CHARS) : ''
+ const persona = typeof raw?.persona === 'string' ? raw.persona.slice(0, WRITE_AGENT_PERSONA_MAX_CHARS) : ''
+ // Drop un-customized built-in templates left over from older builds: writing
+ // agents ship no presets, so a pristine 'coordinator'/'editor'/… row (empty
+ // name AND persona) is cleared instead of resurrected with a default.
+ if (isBuiltinWriteAgentPresetId(id) && !name.trim() && !persona.trim()) continue
+ seen.add(id)
+ presets.push({
+ id,
+ name,
+ emoji: typeof raw?.emoji === 'string' ? raw.emoji.slice(0, 8) : (WRITE_AGENT_PRESET_BUILTIN_EMOJI[id] ?? ''),
+ persona
+ })
+ if (presets.length >= WRITE_AGENT_PRESET_MAX_COUNT) break
+ }
+ return presets
+}
+
export function defaultWriteSettings(): WriteSettingsV1 {
return {
defaultWorkspaceRoot: DEFAULT_WRITE_WORKSPACE_ROOT,
@@ -151,7 +280,9 @@ export function defaultWriteSettings(): WriteSettingsV1 {
maxTokens: DEFAULT_WRITE_INLINE_COMPLETION_MAX_TOKENS,
longMaxTokens: DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS
},
- selectionAssist: defaultWriteSelectionAssistSettings()
+ selectionAssist: defaultWriteSelectionAssistSettings(),
+ typography: defaultWriteTypography(),
+ agentPresets: defaultWriteAgentPresets()
}
}
@@ -304,7 +435,9 @@ export function normalizeWriteSettings(input: WriteSettingsPatchV1 | undefined):
activeWorkspaceRoot,
workspaces: workspaces.length > 0 ? workspaces : [defaultWorkspaceRoot],
inlineCompletion: normalizeWriteInlineCompletionSettings(source.inlineCompletion),
- selectionAssist: normalizeWriteSelectionAssistSettings(source.selectionAssist)
+ selectionAssist: normalizeWriteSelectionAssistSettings(source.selectionAssist),
+ typography: normalizeWriteTypography(source.typography),
+ agentPresets: normalizeWriteAgentPresets(source.agentPresets)
}
}
@@ -331,10 +464,17 @@ export function mergeWriteSettings(
...selectionAssistPatch
}
+ const typographyPatch = patch?.typography ?? {}
+ const nextTypography: Partial = {
+ ...current.typography,
+ ...typographyPatch
+ }
+
return normalizeWriteSettings({
...current,
...(patch ?? {}),
inlineCompletion: nextInlineCompletion,
- selectionAssist: nextSelectionAssist
+ selectionAssist: nextSelectionAssist,
+ typography: nextTypography
})
}
diff --git a/src/shared/app-settings.test.ts b/src/shared/app-settings.test.ts
index 70368263f..2459b8b8c 100644
--- a/src/shared/app-settings.test.ts
+++ b/src/shared/app-settings.test.ts
@@ -23,6 +23,7 @@ import {
modelProviderPresetProfile,
mergeWriteSettings,
normalizeWriteSettings,
+ normalizeWriteAgentPresets,
isKunRuntimeInsecure,
migrateLegacyAppSettings,
normalizeAppSettings,
@@ -206,6 +207,7 @@ describe('kun defaults', () => {
summaryInputMaxBytes: 98304
},
runtimeTuning: {
+ streamIdleTimeoutMs: 45000,
toolStorm: {
enabled: true,
windowSize: 8,
@@ -443,6 +445,35 @@ describe('mergeKunRuntimeSettings', () => {
expect(next.runtimeTuning.toolStorm.windowSize).toBe(current.runtimeTuning.toolStorm.windowSize)
expect(next.runtimeTuning.toolStorm.threshold).toBe(5)
expect(next.runtimeTuning.toolArgumentRepair).toEqual(current.runtimeTuning.toolArgumentRepair)
+ expect(next.runtimeTuning.streamIdleTimeoutMs).toBe(current.runtimeTuning.streamIdleTimeoutMs)
+ })
+
+ it('normalizes the stream idle timeout (0 disables, out-of-range clamps)', () => {
+ const current = defaultKunRuntimeSettings()
+ expect(current.runtimeTuning.streamIdleTimeoutMs).toBe(45000)
+
+ const set = mergeKunRuntimeSettings(current, {
+ runtimeTuning: { streamIdleTimeoutMs: 300000 }
+ })
+ expect(set.runtimeTuning.streamIdleTimeoutMs).toBe(300000)
+ // Other knobs are untouched by a timeout-only patch.
+ expect(set.runtimeTuning.toolStorm).toEqual(current.runtimeTuning.toolStorm)
+
+ // 0 means "disabled" and is preserved rather than coerced to the default.
+ expect(
+ mergeKunRuntimeSettings(current, { runtimeTuning: { streamIdleTimeoutMs: 0 } })
+ .runtimeTuning.streamIdleTimeoutMs
+ ).toBe(0)
+
+ // Negative falls back to the default; absurdly large clamps to the cap.
+ expect(
+ mergeKunRuntimeSettings(current, { runtimeTuning: { streamIdleTimeoutMs: -5 } })
+ .runtimeTuning.streamIdleTimeoutMs
+ ).toBe(45000)
+ expect(
+ mergeKunRuntimeSettings(current, { runtimeTuning: { streamIdleTimeoutMs: 999_999_999 } })
+ .runtimeTuning.streamIdleTimeoutMs
+ ).toBe(3_600_000)
})
it('deep-merges image generation settings and normalizes invalid values', () => {
@@ -1071,13 +1102,13 @@ describe('write selection assist settings', () => {
const write = normalizeWriteSettings({
selectionAssist: {
quickActions: [
- { id: 'polish', label: '', prompt: '', mode: 'chat' },
+ { id: 'polish', label: '保留', prompt: '保留', mode: 'chat' },
{ id: 'custom-1', label: 'x', prompt: 'y' }
]
}
})
expect(write.selectionAssist.quickActions).toEqual([
- { id: 'polish', label: '', prompt: '', mode: 'chat' },
+ { id: 'polish', label: '保留', prompt: '保留', mode: 'chat' },
{ id: 'custom-1', label: 'x', prompt: 'y', mode: 'chat' }
])
})
@@ -1177,3 +1208,30 @@ describe('write selection assist settings', () => {
])
})
})
+
+describe('write agent presets', () => {
+ it('defaults to no agents (opt-in, ships no preset templates)', () => {
+ expect(defaultWriteSettings().agentPresets).toEqual([])
+ })
+
+ it('drops pristine built-in templates left over from older builds', () => {
+ expect(
+ normalizeWriteAgentPresets([
+ { id: 'coordinator', name: '', emoji: '🧭', persona: '' },
+ { id: 'editor', name: '', emoji: '✒️', persona: '' }
+ ])
+ ).toEqual([])
+ })
+
+ it('keeps customized built-ins and user-defined agents', () => {
+ expect(
+ normalizeWriteAgentPresets([
+ { id: 'coordinator', name: '我的统筹', emoji: '🧭', persona: '' },
+ { id: 'custom-1', name: '', emoji: '🤖', persona: '专属人设' }
+ ])
+ ).toEqual([
+ { id: 'coordinator', name: '我的统筹', emoji: '🧭', persona: '' },
+ { id: 'custom-1', name: '', emoji: '🤖', persona: '专属人设' }
+ ])
+ })
+})
diff --git a/src/shared/kun-gui-api.ts b/src/shared/kun-gui-api.ts
index 3e005ae00..94bc89154 100644
--- a/src/shared/kun-gui-api.ts
+++ b/src/shared/kun-gui-api.ts
@@ -196,6 +196,42 @@ export type ConfirmDialogOptions = {
confirmLabel?: string
cancelLabel?: string
}
+/** Which legacy install a set of importable conversations came from. */
+export type LegacySessionSourceKind = 'kun' | 'coreagent' | 'custom'
+export type LegacySessionDetectedSource = {
+ id: string
+ kind: LegacySessionSourceKind
+ /** Absolute path to the legacy threads directory. */
+ path: string
+ /** Conversation folders found in this source. */
+ threadCount: number
+ /** Folders not already present in the destination (would be newly imported). */
+ newCount: number
+}
+export type LegacySessionDetectResult = {
+ /** Destination threads directory (current Kun data dir + /threads). */
+ destDir: string
+ sources: LegacySessionDetectedSource[]
+}
+export type LegacySessionImportSourceSummary = {
+ path: string
+ total: number
+ imported: number
+ skipped: number
+}
+export type LegacySessionImportSummary = {
+ destDir: string
+ /** Conversation folders seen across all sources. */
+ total: number
+ /** Folders copied into the destination this run. */
+ imported: number
+ /** Folders skipped because they already existed (or failed to copy). */
+ skipped: number
+ sources: LegacySessionImportSourceSummary[]
+}
+export type LegacySessionImportResult =
+ | ({ ok: true } & LegacySessionImportSummary)
+ | { ok: false; message: string }
/** One IPC message carries every SSE event parsed from a network chunk. */
export type SseEventPayload = { streamId: string; events: unknown[] }
export type SseEndPayload = { streamId: string }
@@ -224,6 +260,12 @@ export type KunGuiApi = {
) => Promise
pickWorkspaceDirectory: (defaultPath?: string) => Promise
confirmDialog: (options: ConfirmDialogOptions) => Promise
+ /** Detect importable conversations from a previous DeepSeek GUI install. */
+ detectLegacySessions: () => Promise
+ /** Import legacy conversations; omit sourceDir to import all auto-detected sources. */
+ importLegacySessions: (sourceDir?: string) => Promise
+ /** Open a directory picker for choosing a legacy conversations folder. */
+ pickLegacySessionDir: () => Promise
listSkills: (workspaceRoot?: string) => Promise
listSkillRoots: (workspaceRoot?: string) => Promise
saveSkillFile: (rootPath: string, skillName: string, content: string) => Promise
diff --git a/src/shared/model-provider-presets.ts b/src/shared/model-provider-presets.ts
index 72688136e..218df49e6 100644
--- a/src/shared/model-provider-presets.ts
+++ b/src/shared/model-provider-presets.ts
@@ -20,10 +20,14 @@ export type ModelProviderPresetId =
| 'zhipu-coding-plan'
| 'zai-coding-plan'
| 'kimi-code'
+ | 'volcengine-coding-plan'
+ | 'opencode-go'
| 'moonshot-cn'
| 'moonshot-global'
| 'xiaomi'
| 'minimax'
+ | 'aliyun'
+ | 'tencentcloud'
export const TOKEN_PLAN_PROVIDER_ID_SUFFIX = '-token-plan'
@@ -80,6 +84,11 @@ export type ModelProviderTokenPlanPreset = {
export type ModelProviderPreset = {
id: ModelProviderPresetId
name: string
+ /**
+ * 计费/接入大类。'subscription' = 固定费用套餐(Coding Plan、Token Plan 这类),
+ * 'api'(默认) = 按量付费。仅用于设置页把套餐类供应商收拢成一组,不写入存储的 profile。
+ */
+ category?: 'api' | 'subscription'
baseUrl: string
endpointFormat: ModelEndpointFormat
models: string[]
@@ -140,6 +149,26 @@ const GLM_REASONING: ModelProviderReasoningCapabilityV1 = {
requestProtocol: 'glm-chat-completions'
}
+// 通义千问 / 混元 / 豆包的「思考」开关各家用私有 body 字段,无法用现有 requestProtocol 精确映射,
+// 这里统一按「内置推理」建模(requestProtocol: 'none'):只展示 effort 开关、不向上游发送特定协议字段,避免请求被拒。
+const QWEN_REASONING: ModelProviderReasoningCapabilityV1 = {
+ supportedEfforts: ['auto', 'off'],
+ defaultEffort: 'auto',
+ requestProtocol: 'none'
+}
+
+const HUNYUAN_REASONING: ModelProviderReasoningCapabilityV1 = {
+ supportedEfforts: ['auto', 'off'],
+ defaultEffort: 'auto',
+ requestProtocol: 'none'
+}
+
+const DOUBAO_REASONING: ModelProviderReasoningCapabilityV1 = {
+ supportedEfforts: ['auto', 'off'],
+ defaultEffort: 'auto',
+ requestProtocol: 'none'
+}
+
const ZHIPU_CODING_PLAN_MODELS = [
'glm-5.2',
'glm-5.1',
@@ -178,6 +207,7 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [
{
id: 'zhipu-coding-plan',
name: 'Zhipu Coding Plan',
+ category: 'subscription',
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
endpointFormat: 'chat_completions',
models: [...ZHIPU_CODING_PLAN_MODELS],
@@ -194,6 +224,7 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [
{
id: 'zai-coding-plan',
name: 'Z.ai Coding Plan',
+ category: 'subscription',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
endpointFormat: 'chat_completions',
models: [...ZAI_CODING_PLAN_MODELS],
@@ -210,6 +241,7 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [
{
id: 'kimi-code',
name: 'Kimi Code',
+ category: 'subscription',
baseUrl: 'https://api.kimi.com/coding/v1',
endpointFormat: 'chat_completions',
models: ['kimi-for-coding'],
@@ -219,6 +251,74 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [
docsUrl: 'https://www.kimi.com/code/docs/en/',
apiKeyUrl: 'https://www.kimi.com/code'
},
+ {
+ id: 'volcengine-coding-plan',
+ name: 'Volcano Ark Coding Plan',
+ category: 'subscription',
+ // 火山方舟 Coding Plan 与按量付费共用同一个 API Key,但套餐额度只在 /api/coding 网关上消费;
+ // 用按量 base(/api/v3)调用会按量计费。官方注明套餐额度仅限编程工具(Claude Code / Cursor 等)使用。
+ baseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
+ endpointFormat: 'chat_completions',
+ models: ['doubao-seed-1-6-250615', 'doubao-seed-1-6-flash-250828'],
+ modelProfiles: {
+ 'doubao-seed-1-6-250615': visionChatProfile(256_000, DOUBAO_REASONING),
+ 'doubao-seed-1-6-flash-250828': textChatProfile(256_000, DOUBAO_REASONING)
+ },
+ docsUrl: 'https://www.volcengine.com/docs/82379/1928262',
+ apiKeyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey'
+ },
+ {
+ id: 'opencode-go',
+ name: 'OpenCode Go',
+ category: 'subscription',
+ // 网关默认走 chat_completions;MiniMax / Qwen 系列在 OpenCode Go 上以
+ // Anthropic Messages 格式提供,故按模型用 endpointFormat:'messages' 覆盖
+ // (请求改打 …/zen/go/v1/messages)。
+ baseUrl: 'https://opencode.ai/zen/go/v1',
+ endpointFormat: 'chat_completions',
+ models: [
+ 'glm-5.1',
+ 'glm-5',
+ 'kimi-k2.7',
+ 'kimi-k2.7-code',
+ 'kimi-k2.6',
+ 'deepseek-v4-pro',
+ 'deepseek-v4-flash',
+ 'mimo-v2.5',
+ 'mimo-v2.5-pro',
+ 'mimo-v2-pro',
+ 'mimo-v2-omni',
+ 'minimax-m3',
+ 'minimax-m2.7',
+ 'minimax-m2.5',
+ 'qwen3.7-max',
+ 'qwen3.7-plus',
+ 'qwen3.6-plus',
+ 'qwen3.5-plus'
+ ],
+ modelProfiles: {
+ 'glm-5.1': visionChatProfile(131_072),
+ 'glm-5': visionChatProfile(131_072),
+ 'kimi-k2.7': textChatProfile(131_072),
+ 'kimi-k2.7-code': textChatProfile(131_072),
+ 'kimi-k2.6': textChatProfile(131_072),
+ 'deepseek-v4-pro': textChatProfile(131_072),
+ 'deepseek-v4-flash': textChatProfile(131_072),
+ 'mimo-v2.5': textChatProfile(131_072),
+ 'mimo-v2.5-pro': textChatProfile(131_072),
+ 'mimo-v2-pro': textChatProfile(131_072),
+ 'mimo-v2-omni': visionChatProfile(131_072),
+ 'minimax-m3': textChatProfile(256_000, undefined, 'messages'),
+ 'minimax-m2.7': textChatProfile(256_000, undefined, 'messages'),
+ 'minimax-m2.5': textChatProfile(256_000, undefined, 'messages'),
+ 'qwen3.7-max': textChatProfile(262_144, undefined, 'messages'),
+ 'qwen3.7-plus': textChatProfile(262_144, undefined, 'messages'),
+ 'qwen3.6-plus': textChatProfile(262_144, undefined, 'messages'),
+ 'qwen3.5-plus': textChatProfile(262_144, undefined, 'messages')
+ },
+ docsUrl: 'https://opencode.ai/docs/go/',
+ apiKeyUrl: 'https://opencode.ai/auth'
+ },
{
id: 'moonshot-cn',
name: 'Moonshot CN',
@@ -418,6 +518,87 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [
},
docsUrl: 'https://platform.minimax.io/docs/api-reference/text-anthropic-api',
apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key'
+ },
+ {
+ id: 'aliyun',
+ name: 'Aliyun',
+ baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+ endpointFormat: 'chat_completions',
+ models: [
+ 'qwen-max',
+ 'qwen-plus',
+ 'qwen-flash',
+ 'qwen3-coder-plus',
+ 'qwq-plus',
+ 'qwen-vl-max',
+ 'qwen3-vl-plus'
+ ],
+ modelProfiles: {
+ 'qwen-max': textChatProfile(262_144),
+ 'qwen-plus': textChatProfile(1_000_000),
+ 'qwen-flash': textChatProfile(1_000_000),
+ 'qwen3-coder-plus': textChatProfile(1_000_000),
+ 'qwq-plus': textChatProfile(131_072, QWEN_REASONING),
+ 'qwen-vl-max': visionChatProfile(131_072),
+ 'qwen3-vl-plus': visionChatProfile(262_144, QWEN_REASONING)
+ },
+ tokenPlan: {
+ // 通义千问 Token Plan(团队版):独立 Key + 独立 base URL,与按量 sk- Key 不互通。
+ baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
+ regions: [
+ { id: 'cn', baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1' },
+ { id: 'sgp', baseUrl: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1' }
+ ],
+ endpointFormat: 'chat_completions',
+ models: [
+ 'qwen-max',
+ 'qwen-plus',
+ 'qwen-flash',
+ 'qwen3-coder-plus',
+ 'qwq-plus',
+ 'qwen-vl-max',
+ 'qwen3-vl-plus'
+ ],
+ modelProfiles: {
+ 'qwen-max': textChatProfile(262_144),
+ 'qwen-plus': textChatProfile(1_000_000),
+ 'qwen-flash': textChatProfile(1_000_000),
+ 'qwen3-coder-plus': textChatProfile(1_000_000),
+ 'qwq-plus': textChatProfile(131_072, QWEN_REASONING),
+ 'qwen-vl-max': visionChatProfile(131_072),
+ 'qwen3-vl-plus': visionChatProfile(262_144, QWEN_REASONING)
+ },
+ apiKeyUrl: 'https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key'
+ },
+ docsUrl: 'https://help.aliyun.com/zh/model-studio/',
+ apiKeyUrl: 'https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key'
+ },
+ {
+ id: 'tencentcloud',
+ name: 'Tencent Cloud',
+ baseUrl: 'https://api.hunyuan.cloud.tencent.com/v1',
+ endpointFormat: 'chat_completions',
+ models: ['hunyuan-turbos-latest', 'hunyuan-t1-latest', 'hunyuan-lite'],
+ modelProfiles: {
+ 'hunyuan-turbos-latest': textChatProfile(32_768),
+ 'hunyuan-t1-latest': textChatProfile(32_768, HUNYUAN_REASONING),
+ 'hunyuan-lite': textChatProfile(256_000)
+ },
+ tokenPlan: {
+ // 腾讯混元 Token Plan(TokenHub):独立 sk-tp- Key + 独立 base URL,与按量 sk- Key 不互通。
+ baseUrl: 'https://api.lkeap.cloud.tencent.com/plan/v3',
+ endpointFormat: 'chat_completions',
+ models: ['hunyuan-turbos-latest', 'hunyuan-t1-latest', 'hunyuan-lite'],
+ modelProfiles: {
+ 'hunyuan-turbos-latest': textChatProfile(32_768),
+ 'hunyuan-t1-latest': textChatProfile(32_768, HUNYUAN_REASONING),
+ 'hunyuan-lite': textChatProfile(256_000)
+ },
+ keyPrefix: 'sk-tp-',
+ apiKeyUrl: 'https://console.cloud.tencent.com/tokenhub/tokenplan'
+ },
+ docsUrl: 'https://cloud.tencent.com/document/product/1729/111006',
+ apiKeyUrl: 'https://console.cloud.tencent.com/hunyuan/start'
}
]
@@ -570,7 +751,8 @@ function minimaxM2ChatProfile(): ModelProviderModelProfileV1 {
function textChatProfile(
contextWindowTokens?: number,
- reasoning?: ModelProviderReasoningCapabilityV1
+ reasoning?: ModelProviderReasoningCapabilityV1,
+ endpointFormat?: ModelEndpointFormat
): ModelProviderModelProfileV1 {
return {
...(contextWindowTokens ? { contextWindowTokens } : {}),
@@ -578,13 +760,15 @@ function textChatProfile(
outputModalities: ['text'],
supportsToolCalling: true,
messageParts: ['text'],
- ...(reasoning ? { reasoning } : {})
+ ...(reasoning ? { reasoning } : {}),
+ ...(endpointFormat ? { endpointFormat } : {})
}
}
function visionChatProfile(
contextWindowTokens?: number,
- reasoning?: ModelProviderReasoningCapabilityV1
+ reasoning?: ModelProviderReasoningCapabilityV1,
+ endpointFormat?: ModelEndpointFormat
): ModelProviderModelProfileV1 {
return {
...(contextWindowTokens ? { contextWindowTokens } : {}),
@@ -592,7 +776,8 @@ function visionChatProfile(
outputModalities: ['text'],
supportsToolCalling: true,
messageParts: ['text', 'image_url'],
- ...(reasoning ? { reasoning } : {})
+ ...(reasoning ? { reasoning } : {}),
+ ...(endpointFormat ? { endpointFormat } : {})
}
}
diff --git a/src/shared/worktree.ts b/src/shared/worktree.ts
index c44786096..4887cf151 100644
--- a/src/shared/worktree.ts
+++ b/src/shared/worktree.ts
@@ -26,6 +26,7 @@ export interface WorktreePoolStatus {
headCommit: string
worktrees: WorktreeInfo[]
inUseCount: number
+ isGitRepo: boolean
}
export interface WorktreeChanges {
From 847f8730be0580f194e937f4267978ea1808cb78 Mon Sep 17 00:00:00 2001
From: musnow
Date: Tue, 16 Jun 2026 00:59:45 +0800
Subject: [PATCH 4/9] feat(write): add document character count in write
toolbar (#305)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(write): add document character count in write toolbar
Fixes #224
-- Commit By Codex --
* fix(write): compact changed-files card in assistant sidebar
- make write assistant use compact timeline cards
- reduce changed-files diff preview height and spacing
Fixes #224
-- Commit By Codex --
* perf(write): memoize document character count
Wrap computeWriteDocumentStats in useMemo so the markdown parse only
re-runs when the document content (or text/markdown flags) changes,
not on every unrelated re-render — cursor/selection moves, save-status
updates, preview-mode toggles, scroll-sync state, etc.
Co-Authored-By: Claude Opus 4.8 (1M context)
---------
Co-authored-by: XingYu-Zhong <1736101137@qq.com>
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../src/components/chat/MessageTimeline.tsx | 13 +++--
.../chat/message-timeline-cards.tsx | 47 +++++++++++++------
.../components/write/WriteAssistantPanel.tsx | 1 +
.../write/WriteWorkspaceToolbar.tsx | 11 ++++-
.../components/write/WriteWorkspaceView.tsx | 9 +++-
.../write/write-workspace-view-utils.test.ts | 16 +++++++
.../write/write-workspace-view-utils.ts | 32 +++++++++++++
src/renderer/src/locales/en/common.json | 1 +
src/renderer/src/locales/zh/common.json | 1 +
9 files changed, 111 insertions(+), 20 deletions(-)
create mode 100644 src/renderer/src/components/write/write-workspace-view-utils.test.ts
diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx
index f2aa82f33..2889e74f7 100644
--- a/src/renderer/src/components/chat/MessageTimeline.tsx
+++ b/src/renderer/src/components/chat/MessageTimeline.tsx
@@ -50,6 +50,7 @@ type Props = {
onBuildPlan?: () => void
/** Opens/focuses the Plan panel (Open button on the inline card). */
onOpenPlan?: () => void
+ compactCards?: boolean
}
const TURN_PAGE_SIZE = 18
@@ -118,7 +119,8 @@ export function MessageTimeline({
devPreviewCard,
planActionsBusy,
onBuildPlan,
- onOpenPlan
+ onOpenPlan,
+ compactCards = false
}: Props): ReactElement {
const { t } = useTranslation('common')
const {
@@ -322,6 +324,7 @@ export function MessageTimeline({
onBuildPlan={onBuildPlan}
onOpenPlan={onOpenPlan}
viewportRef={containerRef}
+ compactCards={compactCards}
/>
)
@@ -355,6 +358,7 @@ export function MessageTimeline({
live={live}
devPreviewCard={devPreviewCard}
viewportRef={containerRef}
+ compactCards={compactCards}
durationMs={
currentTurnUserId && typeof turnStartedAtByUserId[currentTurnUserId] === 'number'
? Math.max(0, tickNow - turnStartedAtByUserId[currentTurnUserId])
@@ -386,7 +390,8 @@ function MessageTurn({
planActionsBusy,
onBuildPlan,
onOpenPlan,
- viewportRef
+ viewportRef,
+ compactCards = false
}: {
turn: Turn
isProcessing: boolean
@@ -399,6 +404,7 @@ function MessageTurn({
onBuildPlan?: () => void
onOpenPlan?: () => void
viewportRef: RefObject
+ compactCards?: boolean
}): ReactElement {
const workspaceRoot = useChatStore((s) => s.workspaceRoot)
const activeThreadGoal = useChatStore((s) => s.activeThreadGoal)
@@ -513,7 +519,7 @@ function MessageTurn({
) : null}
{!isProcessing && turnFileChanges.length > 0 ? (
-
+
) : null}
)
@@ -560,5 +566,6 @@ const MemoMessageTurn = memo(MessageTurn, (prev, next) => (
prev.planActionsBusy === next.planActionsBusy &&
prev.onBuildPlan === next.onBuildPlan &&
prev.onOpenPlan === next.onOpenPlan &&
+ prev.compactCards === next.compactCards &&
prev.viewportRef === next.viewportRef
))
diff --git a/src/renderer/src/components/chat/message-timeline-cards.tsx b/src/renderer/src/components/chat/message-timeline-cards.tsx
index 1957416e7..0a61c3354 100644
--- a/src/renderer/src/components/chat/message-timeline-cards.tsx
+++ b/src/renderer/src/components/chat/message-timeline-cards.tsx
@@ -175,10 +175,12 @@ export function ReviewSummaryCard({ review }: { review: ReviewBlock }): ReactEle
export function TurnChangeSummary({
changes,
- viewportRef
+ viewportRef,
+ compact = false
}: {
changes: ToolBlock[]
viewportRef: RefObject
+ compact?: boolean
}): ReactElement {
const { t } = useTranslation('common')
const [expanded, setExpanded] = useState(false)
@@ -211,22 +213,36 @@ export function TurnChangeSummary({
})
return (
-
+
setExpanded((value) => !value)}
aria-expanded={expanded}
- className="flex w-full items-center gap-4 px-5 py-4 text-left transition hover:bg-ds-hover/40"
+ className={`flex w-full items-center text-left transition hover:bg-ds-hover/40 ${
+ compact ? 'gap-3 px-4 py-3' : 'gap-4 px-5 py-4'
+ }`}
>
-
-
+
+
-
+
{title}
{totals ? (
-
+
+{totals.added}
·
-{totals.removed}
@@ -244,7 +260,7 @@ export function TurnChangeSummary({
{shouldRenderBody
? changes.map((change) => {
@@ -258,17 +274,20 @@ export function TurnChangeSummary({
type="button"
onClick={() => setActiveId(open ? null : change.id)}
aria-expanded={open}
- className={`flex w-full items-start gap-3 px-5 py-3 text-left transition ${
+ className={`flex w-full items-start text-left transition ${
open ? 'bg-ds-hover/45' : 'hover:bg-ds-hover/35'
- }`}
+ } ${compact ? 'gap-2.5 px-4 py-2.5' : 'gap-3 px-5 py-3'}`}
>
-
+
{primary}
{stats ? (
-
+
+{stats.added}
-{stats.removed}
@@ -281,11 +300,11 @@ export function TurnChangeSummary({
{open && change.detail ? (
-
+
diff --git a/src/renderer/src/components/write/WriteAssistantPanel.tsx b/src/renderer/src/components/write/WriteAssistantPanel.tsx
index f566821ca..b671629d9 100644
--- a/src/renderer/src/components/write/WriteAssistantPanel.tsx
+++ b/src/renderer/src/components/write/WriteAssistantPanel.tsx
@@ -196,6 +196,7 @@ export function WriteAssistantPanel({
onRetryConnection={onRetryConnection}
onOpenSettings={onOpenSettings}
onSelectSuggestion={(text) => setInput(text)}
+ compactCards
/>
) : (
diff --git a/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx b/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx
index adea9f487..df6fd04f2 100644
--- a/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx
+++ b/src/renderer/src/components/write/WriteWorkspaceToolbar.tsx
@@ -32,6 +32,7 @@ type Props = {
activeFileLabel: string
activeFileName: string
activeFilePath: string
+ documentStatsLabel: string | null
assistantOpen: boolean
exportInFlight: boolean
exportMenuOpen: boolean
@@ -63,6 +64,7 @@ export function WriteWorkspaceToolbar({
activeFileLabel,
activeFileName,
activeFilePath,
+ documentStatsLabel,
assistantOpen,
exportInFlight,
exportMenuOpen,
@@ -160,8 +162,13 @@ export function WriteWorkspaceToolbar({
{activeFileName}
-
- {activeFileLabel}
+
+ {activeFileLabel}
+ {documentStatsLabel ? (
+
+ {documentStatsLabel}
+
+ ) : null}
diff --git a/src/renderer/src/components/write/WriteWorkspaceView.tsx b/src/renderer/src/components/write/WriteWorkspaceView.tsx
index 0420ac4b2..7f7bc4951 100644
--- a/src/renderer/src/components/write/WriteWorkspaceView.tsx
+++ b/src/renderer/src/components/write/WriteWorkspaceView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, type ReactElement } from 'react'
+import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
Columns2,
@@ -55,6 +55,7 @@ import {
formatSaveLabel,
inlineAgentPosition,
isMarkdownFile,
+ computeWriteDocumentStats,
useDebouncedValue,
type WriteNotice
} from './write-workspace-view-utils'
@@ -214,6 +215,11 @@ export function WriteWorkspaceView({
? writeRelativeToWorkspace(workspaceRoot, activeFilePath)
: t('writeNoFileOpen')
const activeFileName = activeFilePath ? writeBasenameFromPath(activeFilePath) : t('writeStudio')
+ const documentStats = useMemo(
+ () => (activeFileIsText ? computeWriteDocumentStats(fileContent, isMarkdown) : null),
+ [activeFileIsText, fileContent, isMarkdown],
+ )
+ const documentStatsLabel = documentStats ? t('writeCharacterCount', { count: documentStats.characterCount }) : null
const workspacePathLabel = rootDirectory || workspaceRoot
const workspaceName = workspacePathLabel ? writeBasenameFromPath(workspacePathLabel) : t('writeWorkspace')
const exportInFlight = exportingFormat !== null
@@ -946,6 +952,7 @@ export function WriteWorkspaceView({
activeFileLabel={activeFileLabel}
activeFileName={activeFileName}
activeFilePath={activeFilePath ?? ''}
+ documentStatsLabel={documentStatsLabel}
assistantOpen={assistantOpen}
exportInFlight={exportInFlight}
exportMenuOpen={exportMenuOpen}
diff --git a/src/renderer/src/components/write/write-workspace-view-utils.test.ts b/src/renderer/src/components/write/write-workspace-view-utils.test.ts
new file mode 100644
index 000000000..bdf6f0f33
--- /dev/null
+++ b/src/renderer/src/components/write/write-workspace-view-utils.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { computeWriteDocumentStats } from './write-workspace-view-utils'
+
+describe('computeWriteDocumentStats', () => {
+ it('counts visible markdown text instead of syntax markers', () => {
+ const stats = computeWriteDocumentStats('# 标题\n\n- 第一项\n- 第二项 **加粗**\n', true)
+
+ expect(stats).toEqual({ characterCount: 10 })
+ })
+
+ it('counts non-whitespace characters for plain text files', () => {
+ const stats = computeWriteDocumentStats('Hello world\n 2026 ', false)
+
+ expect(stats).toEqual({ characterCount: 14 })
+ })
+})
diff --git a/src/renderer/src/components/write/write-workspace-view-utils.ts b/src/renderer/src/components/write/write-workspace-view-utils.ts
index f4c797299..e8c6beb8d 100644
--- a/src/renderer/src/components/write/write-workspace-view-utils.ts
+++ b/src/renderer/src/components/write/write-workspace-view-utils.ts
@@ -1,6 +1,7 @@
import { useEffect, useState, type ReactElement } from 'react'
import type { WriteExportFormat } from '@shared/write-export'
import type { WritePreviewMode, WriteSaveStatus } from '../../write/write-workspace-store'
+import { parseWriteMarkdown } from '../../write/tiptap/markdown-manager'
export const WRITE_AUTOSAVE_MS = 900
export const WRITE_PREVIEW_DEBOUNCE_MS = 60
@@ -29,6 +30,10 @@ export type WriteNotice = {
message: string
}
+export type WriteDocumentStats = {
+ characterCount: number
+}
+
export type WriteModeMenuItem = {
mode: WritePreviewMode
label: string
@@ -57,6 +62,33 @@ export function formatSaveLabel(status: WriteSaveStatus, t: (key: string) => str
return t('writeSaved')
}
+function collectVisibleText(node: { type?: string; text?: string; content?: unknown[] } | undefined, acc: string[]): string[] {
+ if (!node) return acc
+ if (node.type === 'text' && typeof node.text === 'string') acc.push(node.text)
+ if (Array.isArray(node.content)) {
+ for (const child of node.content) {
+ if (child && typeof child === 'object') {
+ collectVisibleText(child as { type?: string; text?: string; content?: unknown[] }, acc)
+ }
+ }
+ }
+ return acc
+}
+
+function visibleTextFromMarkdown(markdown: string): string {
+ try {
+ return collectVisibleText(parseWriteMarkdown(markdown), []).join('')
+ } catch {
+ return markdown
+ }
+}
+
+export function computeWriteDocumentStats(content: string, isMarkdown: boolean): WriteDocumentStats {
+ const visibleText = isMarkdown ? visibleTextFromMarkdown(content) : content
+ const characterCount = Array.from(visibleText.replace(/\s+/g, '')).length
+ return { characterCount }
+}
+
export function clamp(value: number, min: number, max: number): number {
if (max < min) return min
return Math.min(Math.max(value, min), max)
diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json
index d79e35976..8f145281f 100644
--- a/src/renderer/src/locales/en/common.json
+++ b/src/renderer/src/locales/en/common.json
@@ -1183,6 +1183,7 @@
"writeModeEdit": "Editor only",
"writeModeSplit": "Split preview",
"writeModePreview": "Preview only",
+ "writeCharacterCount": "{{count}} chars",
"writePreviewErrorFallback": "Markdown preview failed, showing source text instead.",
"writeUnsupportedFileType": "Write currently opens only Markdown, TXT, PDF, and common image files.",
"writeImagePreview": "Image preview",
diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json
index e6d939ff3..f69bbc80b 100644
--- a/src/renderer/src/locales/zh/common.json
+++ b/src/renderer/src/locales/zh/common.json
@@ -1183,6 +1183,7 @@
"writeModeEdit": "仅编辑",
"writeModeSplit": "分栏预览",
"writeModePreview": "仅预览",
+ "writeCharacterCount": "字数 {{count}}",
"writePreviewErrorFallback": "Markdown 预览渲染失败,已临时显示源码文本。",
"writeUnsupportedFileType": "Write 模式目前只打开 Markdown、TXT、PDF 和常见图片文件。",
"writeImagePreview": "图片预览",
From 3609ace15d7eff4895b04a4e8ac136426cb39f21 Mon Sep 17 00:00:00 2001
From: musnow
Date: Tue, 16 Jun 2026 01:15:19 +0800
Subject: [PATCH 5/9] feat: add embedded terminal panel (#309)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: add embedded terminal panel
* feat:支持skill软链接
* ci(release): install build toolchain for node-pty Linux build
node-pty ships no Linux prebuild, so the Linux release job must compile
it from source against Electron's ABI. Add build-essential + python3 to
the Linux packaging deps so `npm ci` / electron-builder rebuild succeed;
without them the Linux AppImage step fails, which fails the whole release
(publish verifies all platform artifacts).
Co-Authored-By: Claude Opus 4.8 (1M context)
---------
Co-authored-by: XingYu-Zhong <1736101137@qq.com>
Co-authored-by: Claude Opus 4.8 (1M context)
---
.github/workflows/release.yml | 4 +-
electron-builder.config.cjs | 1 +
kun/src/skills/skill-runtime.ts | 28 +-
kun/tests/skill-runtime.test.ts | 30 +-
package-lock.json | 75 +-
package.json | 4 +
scripts/after-pack.cjs | 25 +-
scripts/postinstall.cjs | 45 ++
src/main/index.ts | 2 +
src/main/ipc/app-ipc-schemas.ts | 35 +
src/main/services/skill-service.test.ts | 39 +-
src/main/services/skill-service.ts | 36 +-
src/main/terminal/terminal-pty-ipc.ts | 287 +++++++
src/preload/index.ts | 22 +-
src/renderer/src/components/Workbench.tsx | 31 +-
.../src/components/chat/WorkbenchTopBar.tsx | 63 +-
.../src/components/terminal/TerminalPanel.tsx | 734 ++++++++++++++++++
.../src/components/workbench-layout.ts | 60 +-
src/renderer/src/locales/en/common.json | 13 +
src/renderer/src/locales/zh/common.json | 13 +
src/renderer/src/styles/base-shell.css | 5 +-
src/shared/kun-gui-api.ts | 14 +
src/shared/terminal.ts | 57 ++
23 files changed, 1576 insertions(+), 47 deletions(-)
create mode 100644 src/main/terminal/terminal-pty-ipc.ts
create mode 100644 src/renderer/src/components/terminal/TerminalPanel.tsx
create mode 100644 src/shared/terminal.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 134884c98..ee971e410 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -202,7 +202,9 @@ jobs:
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
- sudo apt-get install -y --no-install-recommends libarchive-tools rpm
+ # build-essential + python3: node-pty ships no Linux prebuild, so it
+ # must be compiled from source against Electron's ABI during dist.
+ sudo apt-get install -y --no-install-recommends libarchive-tools rpm build-essential python3
- name: Install dependencies
run: npm ci
diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs
index 66506e7e7..510a758a8 100644
--- a/electron-builder.config.cjs
+++ b/electron-builder.config.cjs
@@ -96,6 +96,7 @@ module.exports = {
'**/kun/package*.json',
'**/kun/node_modules/**/*',
'**/node_modules/better-sqlite3/**/*',
+ '**/node_modules/node-pty/**/*',
'**/node_modules/bindings/**/*',
'**/node_modules/file-uri-to-path/**/*'
],
diff --git a/kun/src/skills/skill-runtime.ts b/kun/src/skills/skill-runtime.ts
index 29e43c1bf..70a04338e 100644
--- a/kun/src/skills/skill-runtime.ts
+++ b/kun/src/skills/skill-runtime.ts
@@ -1,3 +1,4 @@
+import { type Dirent } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { basename, extname, join, resolve } from 'node:path'
import { z } from 'zod'
@@ -333,16 +334,33 @@ async function packageCandidates(root: string): Promise {
}
const entries = await readdir(root, { withFileTypes: true })
for (const entry of entries) {
- if (entry.isDirectory()) {
- const dir = join(root, entry.name)
- if (await exists(join(dir, 'skill.json')) || await exists(join(dir, 'SKILL.md'))) {
- candidates.add(dir)
- }
+ const dir = join(root, entry.name)
+ if (!(await entryIsDirectory(entry, dir))) continue
+ if (await exists(join(dir, 'skill.json')) || await exists(join(dir, 'SKILL.md'))) {
+ candidates.add(dir)
}
}
return [...candidates]
}
+/**
+ * Whether a directory entry is — or resolves to — a directory. `readdir` with
+ * `withFileTypes` describes the link itself, so a symlinked skill package (e.g.
+ * the per-skill links `cc switch` drops into `.claude/skills`) reports
+ * `isDirectory() === false` and would be skipped. Follow such links via `stat`
+ * so those packages are still discovered. Also covers filesystems that report
+ * an unknown `d_type`. (#320)
+ */
+async function entryIsDirectory(entry: Dirent, path: string): Promise {
+ if (entry.isDirectory()) return true
+ if (entry.isFile()) return false
+ try {
+ return (await stat(path)).isDirectory()
+ } catch {
+ return false
+ }
+}
+
async function loadSkillPackage(root: string, allowLegacy: boolean): Promise {
const manifestPath = join(root, 'skill.json')
if (await exists(manifestPath)) {
diff --git a/kun/tests/skill-runtime.test.ts b/kun/tests/skill-runtime.test.ts
index ddef8a63f..110dd49e8 100644
--- a/kun/tests/skill-runtime.test.ts
+++ b/kun/tests/skill-runtime.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -87,6 +87,34 @@ describe('SkillRuntime', () => {
expect(diagnostics.validationErrors).toEqual([])
})
+ it('discovers a skill package symlinked into a root (e.g. cc switch)', async (ctx) => {
+ // cc switch keeps the real skill files in its own config dir and symlinks
+ // the skill directory into the scanned root; the link must still load. (#320)
+ const realDir = await mkdtemp(join(tmpdir(), 'kun-skill-real-'))
+ try {
+ await writeFile(join(realDir, 'skill.json'), JSON.stringify({
+ id: 'linked',
+ name: 'Linked',
+ triggers: { commands: ['/linked'] }
+ }), 'utf8')
+ await writeFile(join(realDir, 'SKILL.md'), 'linked body', 'utf8')
+ try {
+ await symlink(realDir, join(root, 'linked'), 'dir')
+ } catch {
+ // Symlink creation can be unprivileged (e.g. Windows) — skip there.
+ ctx.skip()
+ return
+ }
+
+ const runtime = await createRuntime()
+
+ expect(runtime.diagnostics().skills.map((skill) => skill.id)).toContain('linked')
+ expect(runtime.resolveTurn({ prompt: '/linked go', workspace: root }).activeSkillIds).toEqual(['linked'])
+ } finally {
+ await rm(realDir, { recursive: true, force: true })
+ }
+ })
+
it('matches triggers deterministically and respects injection budgets', async () => {
await writeSkill('big', {
id: 'big',
diff --git a/package-lock.json b/package-lock.json
index 2ff9e76dc..b8e8f3269 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,12 +29,16 @@
"@tiptap/pm": "^3.26.0",
"@tiptap/react": "^3.26.0",
"@tiptap/starter-kit": "^3.26.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
"better-sqlite3": "^12.10.0",
"electron-store": "^10.1.0",
"electron-updater": "^6.8.3",
"html-to-docx": "^1.8.0",
"i18next": "^25.4.2",
"lucide-react": "^0.544.0",
+ "node-pty": "^1.1.0",
"openclaw": "file:vendor/openclaw-shim",
"pdfjs-dist": "^5.4.394",
"qrcode.react": "^4.2.0",
@@ -622,6 +626,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1915,17 +1920,6 @@
"@floating-ui/utils": "^0.2.11"
}
},
- "node_modules/@floating-ui/dom": {
- "version": "1.7.6",
- "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
- "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@floating-ui/core": "^1.7.5",
- "@floating-ui/utils": "^0.2.11"
- }
- },
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
@@ -3123,6 +3117,7 @@
"resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.26.0.tgz",
"integrity": "sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -3358,6 +3353,7 @@
"resolved": "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.26.0.tgz",
"integrity": "sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -3477,6 +3473,7 @@
"resolved": "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.26.0.tgz",
"integrity": "sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -3508,6 +3505,7 @@
"resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.26.0.tgz",
"integrity": "sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
@@ -4039,6 +4037,7 @@
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4048,6 +4047,7 @@
"resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4137,6 +4137,7 @@
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
@@ -4485,6 +4486,27 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
+ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/addon-web-links": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
+ "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz",
+ "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
@@ -4529,6 +4551,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4894,6 +4917,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5406,6 +5430,7 @@
"resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz",
"integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10"
}
@@ -5815,6 +5840,7 @@
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -6589,6 +6615,7 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -7758,6 +7785,7 @@
"resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.21.tgz",
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7918,6 +7946,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -8241,6 +8270,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -9747,6 +9777,12 @@
"node": ">=10"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -9767,6 +9803,16 @@
}
}
},
+ "node_modules/node-pty": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz",
+ "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^7.1.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz",
@@ -10184,6 +10230,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10744,6 +10791,7 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11922,6 +11970,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -12121,6 +12170,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12419,6 +12469,7 @@
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -12512,6 +12563,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -12854,6 +12906,7 @@
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index ece9bda58..d68268909 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,9 @@
"@larksuiteoapi/node-sdk": "^1.64.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tencent-weixin/openclaw-weixin": "2.4.3",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
"@tiptap/core": "^3.26.0",
"@tiptap/extension-image": "^3.26.0",
"@tiptap/extension-list": "^3.26.0",
@@ -60,6 +63,7 @@
"html-to-docx": "^1.8.0",
"i18next": "^25.4.2",
"lucide-react": "^0.544.0",
+ "node-pty": "^1.1.0",
"openclaw": "file:vendor/openclaw-shim",
"pdfjs-dist": "^5.4.394",
"qrcode.react": "^4.2.0",
diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs
index cf1a4f558..7fd2eeb62 100644
--- a/scripts/after-pack.cjs
+++ b/scripts/after-pack.cjs
@@ -1,5 +1,5 @@
const { execFileSync } = require('node:child_process')
-const { existsSync, rmSync } = require('node:fs')
+const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
const { join } = require('node:path')
const KUN_RUNTIME_REQUIRED_PATHS = [
@@ -112,9 +112,29 @@ function maybeAdhocSignMacApp(context) {
)
}
+// node-pty execs a bundled `spawn-helper` binary to fork the child shell.
+// asar unpacking can drop the executable bit, which makes every PTY spawn
+// fail with `posix_spawnp`. Re-chmod every bundled helper after packing so
+// the built-in terminal works in the shipped app. Non-fatal: best effort.
+function ensureNodePtyHelpersExecutable(context) {
+ const root = unpackedAppRoot(context)
+ const prebuildsDir = join(root, 'node_modules', 'node-pty', 'prebuilds')
+ if (!existsSync(prebuildsDir)) return
+ for (const folder of readdirSync(prebuildsDir)) {
+ const helper = join(prebuildsDir, folder, 'spawn-helper')
+ if (!existsSync(helper)) continue
+ try {
+ chmodSync(helper, 0o755)
+ } catch (error) {
+ console.warn(`[after-pack] could not chmod node-pty spawn-helper (${folder}):`, error.message)
+ }
+ }
+}
+
async function afterPack(context) {
prunePackedKunDependencies(context)
validateBundledKunRuntime(context)
+ ensureNodePtyHelpersExecutable(context)
maybeAdhocSignMacApp(context)
}
@@ -125,6 +145,7 @@ exports._internals = {
unpackedAppRoot,
npmCommand,
prunePackedKunDependencies,
- validateBundledKunRuntime
+ validateBundledKunRuntime,
+ ensureNodePtyHelpersExecutable
}
exports.default = afterPack
diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs
index 5e0f29716..cc5f229db 100644
--- a/scripts/postinstall.cjs
+++ b/scripts/postinstall.cjs
@@ -35,3 +35,48 @@ try {
} catch (error) {
console.warn('[postinstall] skipped better-sqlite3 electron prebuild:', error.message)
}
+
+// node-pty is a native module used by the built-in terminal and is always
+// loaded inside the Electron main process. It ships its own prebuilt
+// `pty.node` + `spawn-helper` binaries under prebuilds/-/, but
+// npm does not always preserve the executable bit on `spawn-helper`, which
+// node-pty execs to fork the child — without it `posix_spawnp` fails. Best
+// effort: re-chmod the helper for every bundled platform so the terminal
+// works out of the box. A failure is non-fatal.
+try {
+ const { existsSync, readdirSync, chmodSync } = require('node:fs')
+ const prebuildsDir = join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds')
+ if (existsSync(prebuildsDir)) {
+ for (const folder of readdirSync(prebuildsDir)) {
+ const helper = join(prebuildsDir, folder, 'spawn-helper')
+ if (existsSync(helper)) {
+ try {
+ chmodSync(helper, 0o755)
+ } catch (error) {
+ console.warn(`[postinstall] could not chmod node-pty spawn-helper (${folder}):`, error.message)
+ }
+ }
+ }
+ }
+} catch (error) {
+ console.warn('[postinstall] skipped node-pty spawn-helper chmod:', error.message)
+}
+
+// Some environments also need an Electron-ABI rebuild (no Node prebuild
+// matches). This is best-effort; the bundled prebuilds already target an
+// ABI-compatible Node build for current Electron versions, so a failure here
+// is usually harmless and leaves the terminal working.
+try {
+ const electronVersion = require('electron/package.json').version
+ const result = run('npx', [
+ '--yes',
+ 'prebuild-install',
+ `--runtime=electron`,
+ `--target=${electronVersion}`
+ ], { cwd: join(__dirname, '..', 'node_modules', 'node-pty') })
+ if (result.status !== 0) {
+ console.warn('[postinstall] node-pty electron prebuild fell back to bundled binaries')
+ }
+} catch (error) {
+ console.warn('[postinstall] skipped node-pty electron prebuild:', error.message)
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 55f23cdb1..f2047f0c4 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -65,6 +65,7 @@ import {
startWeixinInstallQrcode
} from './claw-platform-install'
import { registerRuntimeSseIpc } from './runtime-sse-ipc'
+import { registerTerminalPtyIpc } from './terminal/terminal-pty-ipc'
import {
configureWeixinBridgeRuntimeContextProvider,
ensureWeixinBridgeRpcUrl,
@@ -1394,6 +1395,7 @@ app.whenReady().then(async () => {
})
registerRuntimeSseIpc({ ipcMain, store, ensureRuntime, logError })
+ registerTerminalPtyIpc({ ipcMain, getMainWindow: () => mainWindow, logError })
traceStartup('ipc registration:done')
createWindow({ suppressInitialShow: shouldStartHidden(initial) })
diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts
index 127871390..ea5073fb7 100644
--- a/src/main/ipc/app-ipc-schemas.ts
+++ b/src/main/ipc/app-ipc-schemas.ts
@@ -48,6 +48,15 @@ import { KEYBOARD_SHORTCUT_COMMANDS } from '../../shared/keyboard-shortcuts'
import { WRITE_EXPORT_FORMATS } from '../../shared/write-export'
import { WRITE_INFOGRAPHIC_MAX_TEXT_CHARS } from '../../shared/write-infographic'
import { SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS, SPEECH_TRANSCRIPTION_MAX_DURATION_MS } from '../../shared/speech-to-text'
+import {
+ TERMINAL_DEFAULT_COLS,
+ TERMINAL_DEFAULT_ROWS,
+ TERMINAL_MAX_COLS,
+ TERMINAL_MAX_CWD_LENGTH,
+ TERMINAL_MAX_DATA_WRITE_BYTES,
+ TERMINAL_MAX_ROWS,
+ TERMINAL_MAX_SESSION_ID_LENGTH
+} from '../../shared/terminal'
const MAX_BODY_BYTES = 2_000_000
const MAX_PATH_LENGTH = 4_096
@@ -1099,3 +1108,29 @@ export const uiPluginIdPayloadSchema = z
id: z.string().trim().regex(/^[a-z0-9][a-z0-9-]{1,39}$/)
})
.strict()
+
+export const terminalSessionIdSchema = trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH)
+
+export const terminalCreatePayloadSchema = z
+ .object({
+ sessionId: trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH),
+ cwd: optionalTrimmedString(TERMINAL_MAX_CWD_LENGTH),
+ cols: z.number().int().min(1).max(TERMINAL_MAX_COLS).optional(),
+ rows: z.number().int().min(1).max(TERMINAL_MAX_ROWS).optional()
+ })
+ .strict()
+
+export const terminalWritePayloadSchema = z
+ .object({
+ sessionId: trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH),
+ data: z.string().min(1).max(TERMINAL_MAX_DATA_WRITE_BYTES)
+ })
+ .strict()
+
+export const terminalResizePayloadSchema = z
+ .object({
+ sessionId: trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH),
+ cols: z.number().int().min(1).max(TERMINAL_MAX_COLS).default(TERMINAL_DEFAULT_COLS),
+ rows: z.number().int().min(1).max(TERMINAL_MAX_ROWS).default(TERMINAL_DEFAULT_ROWS)
+ })
+ .strict()
diff --git a/src/main/services/skill-service.test.ts b/src/main/services/skill-service.test.ts
index bdfc5a4b3..94aec2068 100644
--- a/src/main/services/skill-service.test.ts
+++ b/src/main/services/skill-service.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -121,6 +121,43 @@ describe('skill-service', () => {
expect(comparable(claude?.path ?? '')).toBe(comparable(join(workspaceRoot, '.claude', 'skills')))
})
+ it('discovers and counts skills symlinked into .claude/skills (e.g. cc switch)', async (ctx) => {
+ const workspaceRoot = join(tempRoot, 'ws-symlink')
+ // cc switch stores the real skill files in its own config dir...
+ const realSkill = join(tempRoot, 'cc-config', 'skills', 'linked-skill')
+ await mkdir(realSkill, { recursive: true })
+ await writeFile(join(realSkill, 'SKILL.md'), [
+ '---', 'name: linked-skill', 'description: Linked via symlink.', '---', '', 'Body.'
+ ].join('\n'), 'utf8')
+ // ...and symlinks the per-skill directory into .claude/skills.
+ const claudeSkills = join(workspaceRoot, '.claude', 'skills')
+ await mkdir(claudeSkills, { recursive: true })
+ try {
+ await symlink(realSkill, join(claudeSkills, 'linked-skill'), 'dir')
+ } catch {
+ // Symlink creation can be unprivileged (e.g. Windows) — skip there.
+ ctx.skip()
+ return
+ }
+
+ const settings = createSettings(workspaceRoot)
+ const result = await listGuiSkills(settings, workspaceRoot)
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.skills).toContainEqual(expect.objectContaining({
+ id: 'linked-skill',
+ name: 'Linked Skill',
+ description: 'Linked via symlink.',
+ scope: 'project'
+ }))
+
+ const roots = await listGuiSkillRoots(settings, workspaceRoot)
+ expect(roots.ok).toBe(true)
+ if (!roots.ok) return
+ const claude = roots.roots.find((root) => root.labelKey === 'pluginSkillRootWorkspaceClaude')
+ expect(claude?.skillCount).toBe(1)
+ })
+
it('omits a directory disabled via disabledDirs from runtime roots but still lists it', async () => {
const workspaceRoot = join(tempRoot, 'ws-toggle')
const claudeSkill = join(workspaceRoot, '.claude', 'skills', 'demo')
diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts
index d6e951e2c..dccee29e3 100644
--- a/src/main/services/skill-service.ts
+++ b/src/main/services/skill-service.ts
@@ -1,5 +1,5 @@
-import { existsSync, readdirSync } from 'node:fs'
-import { readdir, readFile } from 'node:fs/promises'
+import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs'
+import { readdir, readFile, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import type { AppSettingsV1 } from '../../shared/app-settings'
@@ -316,7 +316,7 @@ function skillRootHasPackages(root: string): boolean {
if (existsSync(join(root, 'SKILL.md')) || existsSync(join(root, 'skill.json'))) return true
try {
return readdirSync(root, { withFileTypes: true }).some((entry) =>
- entry.isDirectory() &&
+ entryIsDirectorySync(entry, join(root, entry.name)) &&
(existsSync(join(root, entry.name, 'SKILL.md')) || existsSync(join(root, entry.name, 'skill.json')))
)
} catch {
@@ -331,8 +331,8 @@ async function packageCandidates(root: string): Promise {
}
const entries = await readdir(root, { withFileTypes: true })
for (const entry of entries) {
- if (!entry.isDirectory()) continue
const dir = join(root, entry.name)
+ if (!(await entryIsDirectory(entry, dir))) continue
if (existsSync(join(dir, 'skill.json')) || existsSync(join(dir, 'SKILL.md'))) {
candidates.add(dir)
}
@@ -340,6 +340,34 @@ async function packageCandidates(root: string): Promise {
return [...candidates]
}
+/**
+ * Whether a directory entry is — or resolves to — a directory. `readdir`/
+ * `readdirSync` with `withFileTypes` describe the link itself, so a symlinked
+ * skill package (e.g. the per-skill links `cc switch` drops into
+ * `.claude/skills`) reports `isDirectory() === false` and would be skipped.
+ * Follow such links via `stat` so those packages are still discovered. Also
+ * covers filesystems that report an unknown `d_type`. (#320)
+ */
+async function entryIsDirectory(entry: Dirent, path: string): Promise {
+ if (entry.isDirectory()) return true
+ if (entry.isFile()) return false
+ try {
+ return (await stat(path)).isDirectory()
+ } catch {
+ return false
+ }
+}
+
+function entryIsDirectorySync(entry: Dirent, path: string): boolean {
+ if (entry.isDirectory()) return true
+ if (entry.isFile()) return false
+ try {
+ return statSync(path).isDirectory()
+ } catch {
+ return false
+ }
+}
+
async function loadSkillSummary(root: string, scope: GuiSkillScope): Promise {
const manifestPath = join(root, 'skill.json')
if (existsSync(manifestPath)) {
diff --git a/src/main/terminal/terminal-pty-ipc.ts b/src/main/terminal/terminal-pty-ipc.ts
new file mode 100644
index 000000000..7536d382a
--- /dev/null
+++ b/src/main/terminal/terminal-pty-ipc.ts
@@ -0,0 +1,287 @@
+/**
+ * Main-process PTY lifecycle for the built-in terminal.
+ *
+ * Architecture mirrors `runtime-sse-ipc.ts`: the main process owns the real
+ * resource (a node-pty pseudo-terminal), streams chunks to the renderer over
+ * `terminal:data`, and reports exit via `terminal:exit`. node-pty is loaded
+ * lazily so a missing/broken native build disables the terminal gracefully
+ * instead of crashing app startup.
+ *
+ * Cross-platform notes:
+ * - macOS / Linux: node-pty uses forkpty; the `$SHELL` env var (fallback
+ * /bin/zsh on mac, /bin/bash on linux) selects the program.
+ * - Windows: node-pty uses ConPTY (`useConpty: true`); we prefer PowerShell
+ * 7 (pwsh.exe), then Windows PowerShell, then cmd.exe.
+ * - `useConpty` is a no-op on non-Windows, so we always pass it.
+ */
+import { existsSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import type { BrowserWindow, IpcMain, WebContents } from 'electron'
+import type { IPty } from 'node-pty'
+import {
+ TERMINAL_DEFAULT_COLS,
+ TERMINAL_DEFAULT_ROWS,
+ TERMINAL_MAX_SESSIONS,
+ TERMINAL_RING_BUFFER_BYTES
+} from '../../shared/terminal'
+import {
+ terminalCreatePayloadSchema,
+ terminalResizePayloadSchema,
+ terminalSessionIdSchema,
+ terminalWritePayloadSchema
+} from '../ipc/app-ipc-schemas'
+
+type TerminalSession = {
+ pty: IPty
+ sender: WebContents
+ /** Last ~64KB of output, replayed when a panel re-attaches. */
+ ringBuffer: string
+ exited: boolean
+}
+
+let nodePty: typeof import('node-pty') | null | undefined
+
+async function loadNodePty(): Promise {
+ if (nodePty !== undefined) return nodePty
+ try {
+ // Dynamic import keeps the main bundle compiling even if the native
+ // prebuild is missing on the current platform; failure surfaces as a
+ // friendly message in the panel instead of a hard crash.
+ nodePty = await import('node-pty')
+ } catch (error) {
+ console.warn('[terminal] node-pty failed to load; built-in terminal disabled:', error)
+ nodePty = null
+ }
+ return nodePty
+}
+
+/**
+ * Pick a default shell for the current platform.
+ *
+ * macOS: respects $SHELL (set by the OS for the user's default terminal),
+ * falling back to zsh which has shipped as the system default since
+ * Catalina.
+ * Linux: respects $SHELL, falling back to bash (the de-facto standard).
+ * Windows: PowerShell 7 (pwsh.exe) if installed, else Windows PowerShell,
+ * else the COMSPEC command interpreter (usually cmd.exe).
+ */
+function resolveDefaultShell(): { file: string; args: string[] } {
+ if (process.platform === 'win32') {
+ const programFiles = process.env.PROGRAMFILES ?? 'C:\\Program Files'
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows'
+ const pwsh7 = join(programFiles, 'PowerShell', '7', 'pwsh.exe')
+ if (existsSync(pwsh7)) return { file: pwsh7, args: ['-NoLogo'] }
+ const windowsPwsh = join(
+ systemRoot,
+ 'System32',
+ 'WindowsPowerShell',
+ 'v1.0',
+ 'powershell.exe'
+ )
+ if (existsSync(windowsPwsh)) return { file: windowsPwsh, args: ['-NoLogo'] }
+ return { file: process.env.COMSPEC ?? 'cmd.exe', args: [] }
+ }
+ const fallback = process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
+ return { file: process.env.SHELL || fallback, args: [] }
+}
+
+function buildShellEnv(): NodeJS.ProcessEnv {
+ // xterm-256color matches what xterm.js advertises and keeps color-capable
+ // programs (ls, git, etc.) emitting escape codes.
+ return { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }
+}
+
+function pushToRingBuffer(session: TerminalSession, chunk: string): void {
+ session.ringBuffer += chunk
+ if (session.ringBuffer.length > TERMINAL_RING_BUFFER_BYTES) {
+ session.ringBuffer = session.ringBuffer.slice(-TERMINAL_RING_BUFFER_BYTES)
+ }
+}
+
+function sendToSender(sender: WebContents, channel: string, payload: unknown): void {
+ if (sender.isDestroyed()) return
+ sender.send(channel, payload)
+}
+
+export type RegisterTerminalPtyIpcOptions = {
+ ipcMain: IpcMain
+ getMainWindow: () => BrowserWindow | null
+ logError: (category: string, message: string, detail?: unknown) => void
+}
+
+export function registerTerminalPtyIpc(options: RegisterTerminalPtyIpcOptions): void {
+ const { ipcMain, getMainWindow, logError } = options
+ const sessions = new Map()
+
+ const disposeSession = (sessionId: string, killedByClient: boolean): boolean => {
+ const session = sessions.get(sessionId)
+ if (!session) return false
+ try {
+ session.pty.kill()
+ } catch (error) {
+ logError('terminal', 'Failed to kill PTY process', {
+ sessionId,
+ message: error instanceof Error ? error.message : String(error)
+ })
+ }
+ sessions.delete(sessionId)
+ if (!killedByClient && !session.sender.isDestroyed()) {
+ sendToSender(session.sender, 'terminal:exit', { sessionId, exitCode: null })
+ }
+ return true
+ }
+
+ const disposeForSender = (sender: WebContents): void => {
+ for (const [sessionId, session] of sessions) {
+ if (session.sender === sender) disposeSession(sessionId, true)
+ }
+ }
+
+ // When the renderer window closes, tear down any PTY it owned. Listening
+ // on the main window's webContents covers the normal single-window case.
+ const attachSenderCleanup = (sender: WebContents): void => {
+ if (sender.isDestroyed()) {
+ disposeForSender(sender)
+ return
+ }
+ sender.once('destroyed', () => disposeForSender(sender))
+ }
+
+ ipcMain.handle('terminal:create', async (event, args: unknown) => {
+ const request = terminalCreatePayloadSchema.parse(args)
+
+ // Re-attach to an existing session: replay the ring buffer so reopening
+ // the panel shows recent output instead of a blank screen.
+ const existing = sessions.get(request.sessionId)
+ if (existing && !existing.exited) {
+ if (existing.ringBuffer) {
+ sendToSender(event.sender, 'terminal:data', {
+ sessionId: request.sessionId,
+ data: existing.ringBuffer
+ })
+ }
+ // Rebind to the current sender in case the window was recreated.
+ existing.sender = event.sender
+ attachSenderCleanup(event.sender)
+ return { ok: true as const, sessionId: request.sessionId, replayed: true }
+ }
+ if (existing && existing.exited) {
+ disposeSession(request.sessionId, true)
+ }
+
+ if (sessions.size >= TERMINAL_MAX_SESSIONS) {
+ return {
+ ok: false as const,
+ message: `Too many terminal sessions (limit ${TERMINAL_MAX_SESSIONS}).`
+ }
+ }
+
+ const ptyModule = await loadNodePty()
+ if (!ptyModule) {
+ return {
+ ok: false as const,
+ message: 'The terminal backend (node-pty) is not available on this system.'
+ }
+ }
+
+ const { file, args: shellArgs } = resolveDefaultShell()
+ const cols = request.cols ?? TERMINAL_DEFAULT_COLS
+ const rows = request.rows ?? TERMINAL_DEFAULT_ROWS
+ const cwd = request.cwd && request.cwd.trim() ? request.cwd.trim() : homedir()
+
+ try {
+ const pty = ptyModule.spawn(file, shellArgs, {
+ name: 'xterm-256color',
+ cols,
+ rows,
+ cwd,
+ env: buildShellEnv(),
+ // ConPTY on Windows, ignored elsewhere.
+ useConpty: true
+ })
+
+ const session: TerminalSession = {
+ pty,
+ sender: event.sender,
+ ringBuffer: '',
+ exited: false
+ }
+ sessions.set(request.sessionId, session)
+ attachSenderCleanup(event.sender)
+
+ pty.onData((data) => {
+ if (session.exited) return
+ pushToRingBuffer(session, data)
+ sendToSender(session.sender, 'terminal:data', { sessionId: request.sessionId, data })
+ })
+
+ pty.onExit(({ exitCode }) => {
+ session.exited = true
+ sendToSender(session.sender, 'terminal:exit', { sessionId: request.sessionId, exitCode })
+ // Keep the entry briefly so a slow re-attach can still replay; the
+ // next create disposes it. Full cleanup also happens on app quit.
+ })
+
+ return { ok: true as const, sessionId: request.sessionId }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ logError('terminal', 'Failed to spawn PTY', { sessionId: request.sessionId, message })
+ return { ok: false as const, message }
+ }
+ })
+
+ ipcMain.handle('terminal:write', async (event, args: unknown) => {
+ const request = terminalWritePayloadSchema.parse(args)
+ const session = sessions.get(request.sessionId)
+ if (!session || session.exited) return false
+ try {
+ session.pty.write(request.data)
+ return true
+ } catch (error) {
+ logError('terminal', 'Failed to write to PTY', {
+ sessionId: request.sessionId,
+ message: error instanceof Error ? error.message : String(error)
+ })
+ return false
+ }
+ })
+
+ ipcMain.handle('terminal:resize', async (event, args: unknown) => {
+ const request = terminalResizePayloadSchema.parse(args)
+ const session = sessions.get(request.sessionId)
+ if (!session || session.exited) return false
+ try {
+ session.pty.resize(request.cols, request.rows)
+ return true
+ } catch (error) {
+ logError('terminal', 'Failed to resize PTY', {
+ sessionId: request.sessionId,
+ message: error instanceof Error ? error.message : String(error)
+ })
+ return false
+ }
+ })
+
+ ipcMain.handle('terminal:dispose', async (_event, sessionId: unknown) => {
+ const normalized = terminalSessionIdSchema.parse(sessionId)
+ return disposeSession(normalized, true)
+ })
+
+ // App-wide teardown so no orphaned shell survives a normal quit. Lazily
+ // importing `electron` here keeps the module side-effect-free for tests.
+ void import('electron').then(({ app }) => {
+ app.on('before-quit', () => {
+ for (const sessionId of Array.from(sessions.keys())) {
+ disposeSession(sessionId, true)
+ }
+ })
+ })
+
+ // If the main window is recreated (e.g. on macOS reactivation), make sure
+ // stale sessions bound to a destroyed window are torn down.
+ const mainWindow = getMainWindow()
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ attachSenderCleanup(mainWindow.webContents)
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a6d717336..1b1a4bb6e 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -238,7 +238,27 @@ const api = {
logError: (category, message, detail) =>
ipcRenderer.invoke('log:error', { category, message, detail }),
getLogPath: () => ipcRenderer.invoke('log:get-path'),
- openLogDir: () => ipcRenderer.invoke('log:open-dir')
+ openLogDir: () => ipcRenderer.invoke('log:open-dir'),
+ createTerminal: (payload) => ipcRenderer.invoke('terminal:create', payload),
+ writeToTerminal: (payload) => ipcRenderer.invoke('terminal:write', payload),
+ resizeTerminal: (payload) => ipcRenderer.invoke('terminal:resize', payload),
+ disposeTerminal: (sessionId) => ipcRenderer.invoke('terminal:dispose', sessionId),
+ onTerminalData: (handler) => {
+ const wrapped = (
+ _: Electron.IpcRendererEvent,
+ payload: Parameters[0]
+ ) => handler(payload)
+ ipcRenderer.on('terminal:data', wrapped)
+ return () => ipcRenderer.removeListener('terminal:data', wrapped)
+ },
+ onTerminalExit: (handler) => {
+ const wrapped = (
+ _: Electron.IpcRendererEvent,
+ payload: Parameters[0]
+ ) => handler(payload)
+ ipcRenderer.on('terminal:exit', wrapped)
+ return () => ipcRenderer.removeListener('terminal:exit', wrapped)
+ }
} satisfies KunGuiApi
contextBridge.exposeInMainWorld('kunGui', api)
diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx
index 1a0127d03..4d0d74616 100644
--- a/src/renderer/src/components/Workbench.tsx
+++ b/src/renderer/src/components/Workbench.tsx
@@ -120,6 +120,9 @@ const PlanPanel = lazy(() =>
const TodoPanel = lazy(() =>
import('./todo/TodoPanel').then((module) => ({ default: module.TodoPanel }))
)
+const TerminalPanel = lazy(() =>
+ import('./terminal/TerminalPanel').then((module) => ({ default: module.TerminalPanel }))
+)
const ScheduleTasksView = lazy(() =>
import('./schedule/ScheduleTasksView').then((module) => ({ default: module.ScheduleTasksView }))
)
@@ -527,6 +530,7 @@ export function Workbench(): ReactElement {
const {
beginLeftResize,
beginRightResize,
+ beginTerminalResize,
filePreviewTarget,
leftSidebarCollapsed,
leftSidebarWidth,
@@ -538,8 +542,11 @@ export function Workbench(): ReactElement {
setRightPanelMode,
setRightSidebarWidth,
shellRef,
+ terminalHeight,
+ terminalOpen,
toggleLeftSidebar,
toggleRightPanelMode,
+ toggleTerminal,
} = useWorkbenchLayout({
activeThreadId,
latestAutoOpenDevPreviewUrl,
@@ -2279,7 +2286,7 @@ export function Workbench(): ReactElement {
{error && !(runtimeConnection !== 'ready' && !activeThreadId) ? renderRuntimeBanner(error, runtimeErrorDetail) : null}
-
+
{activeSddDraft ? (
) : (
+
+ {terminalOpen ? (
+
+ ) : null}
)}
diff --git a/src/renderer/src/components/chat/WorkbenchTopBar.tsx b/src/renderer/src/components/chat/WorkbenchTopBar.tsx
index ac959412e..27893b03d 100644
--- a/src/renderer/src/components/chat/WorkbenchTopBar.tsx
+++ b/src/renderer/src/components/chat/WorkbenchTopBar.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import type { EditorInfo } from '@shared/editor'
import type { GuiUpdateState } from '@shared/gui-update'
import {
@@ -22,12 +22,21 @@ import {
import { useTranslation } from 'react-i18next'
import { readPreferredEditorId, writePreferredEditorId } from '../../lib/editor-preferences'
-export type RightPanelMode = 'todo' | 'changes' | 'browser' | 'file' | 'plan' | 'sdd-ai' | null
+export type RightPanelMode =
+ | 'todo'
+ | 'changes'
+ | 'browser'
+ | 'file'
+ | 'plan'
+ | 'sdd-ai'
+ | null
type Props = {
rightPanelMode: RightPanelMode
onToggleRightPanelMode: (mode: Exclude
) => void
planPanelEnabled?: boolean
+ terminalOpen?: boolean
+ onToggleTerminal?: () => void
sideChatCount?: number
sideChatRunningCount?: number
sideChatOpen?: boolean
@@ -39,6 +48,8 @@ export function WorkbenchTopBar({
rightPanelMode,
onToggleRightPanelMode,
planPanelEnabled = false,
+ terminalOpen = false,
+ onToggleTerminal,
sideChatCount = 0,
sideChatRunningCount = 0,
sideChatOpen = false,
@@ -341,22 +352,40 @@ export function WorkbenchTopBar({
{items.map((item) => {
const active = rightPanelMode === item.mode
const Icon = item.icon
+ const isChanges = item.mode === 'changes'
return (
- onToggleRightPanelMode(item.mode)}
- className={`rounded-full border px-2.5 py-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)] transition dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] ${
- active
- ? 'border-ds-border-strong bg-white/70 text-ds-ink dark:bg-white/10'
- : 'border-transparent bg-white/38 text-ds-faint opacity-90 hover:border-ds-border-muted hover:bg-white/55 hover:text-ds-ink hover:opacity-100 dark:bg-white/4 dark:hover:bg-white/8'
- }`}
- aria-label={item.label}
- aria-pressed={active}
- title={item.label}
- >
-
-
+
+ onToggleRightPanelMode(item.mode)}
+ className={`rounded-full border px-2.5 py-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)] transition dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] ${
+ active
+ ? 'border-ds-border-strong bg-white/70 text-ds-ink dark:bg-white/10'
+ : 'border-transparent bg-white/38 text-ds-faint opacity-90 hover:border-ds-border-muted hover:bg-white/55 hover:text-ds-ink hover:opacity-100 dark:bg-white/4 dark:hover:bg-white/8'
+ }`}
+ aria-label={item.label}
+ aria-pressed={active}
+ title={item.label}
+ >
+
+
+ {isChanges && onToggleTerminal ? (
+
+
+
+ ) : null}
+
)
})}
diff --git a/src/renderer/src/components/terminal/TerminalPanel.tsx b/src/renderer/src/components/terminal/TerminalPanel.tsx
new file mode 100644
index 000000000..41764057b
--- /dev/null
+++ b/src/renderer/src/components/terminal/TerminalPanel.tsx
@@ -0,0 +1,734 @@
+import type { ReactElement, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import {
+ TerminalSquare,
+ Plus,
+ RotateCw,
+ X,
+ PencilLine,
+ PanelRightClose,
+ PanelsTopLeft
+} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import '@xterm/xterm/css/xterm.css'
+import { Terminal } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import { WebLinksAddon } from '@xterm/addon-web-links'
+import {
+ TERMINAL_DEFAULT_COLS,
+ TERMINAL_DEFAULT_ROWS
+} from '@shared/terminal'
+
+type Props = {
+ className?: string
+ workspaceRoot: string
+ onCollapse: () => void
+ /** Fixed pixel height for the bottom-drawer layout. */
+ height?: number
+}
+
+type TerminalTab = {
+ id: string
+ index: number
+ title?: string
+}
+
+type TerminalTabContextMenu = {
+ tabId: string
+ x: number
+ y: number
+}
+
+type RgbaColor = {
+ r: number
+ g: number
+ b: number
+ a: number
+}
+
+// Monospace stack matches the editor's preference and falls back to a
+// platform-appropriate default (Menlo on macOS, Consolas on Windows).
+const TERMINAL_FONT_FAMILY =
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace'
+const TERMINAL_FONT_SIZE = 13
+const TERMINAL_SCROLLBACK = 5000
+const FIT_DEBOUNCE_MS = 80
+const INITIAL_TAB_ID = 'main'
+const MAX_RENDERER_TABS = 8
+
+const DARK_THEME = {
+ background: '#151d31',
+ foreground: '#e6e9ef',
+ cursor: '#e6e9ef',
+ cursorAccent: '#151d31',
+ selectionBackground: '#264f78aa',
+ black: '#000000',
+ red: '#ff6b6b',
+ green: '#7ee787',
+ yellow: '#f0c674',
+ blue: '#6cb6ff',
+ magenta: '#d2a8ff',
+ cyan: '#56d4dd',
+ white: '#e6e9ef',
+ brightBlack: '#6b7280',
+ brightRed: '#ffa198',
+ brightGreen: '#9ee787',
+ brightYellow: '#f9d57e',
+ brightBlue: '#8cb6ff',
+ brightMagenta: '#e0b3ff',
+ brightCyan: '#7ce4ec',
+ brightWhite: '#ffffff'
+} as const
+
+const LIGHT_THEME = {
+ background: '#f3f5fc',
+ foreground: '#1f2328',
+ cursor: '#1f2328',
+ cursorAccent: '#f3f5fc',
+ selectionBackground: '#264f78aa',
+ black: '#1f2328',
+ red: '#cf222e',
+ green: '#1a7f37',
+ yellow: '#9a6700',
+ blue: '#0969da',
+ magenta: '#8250df',
+ cyan: '#1b7c83',
+ white: '#57606a',
+ brightBlack: '#6e7781',
+ brightRed: '#a40e26',
+ brightGreen: '#2da44e',
+ brightYellow: '#bf8700',
+ brightBlue: '#218bff',
+ brightMagenta: '#a475f9',
+ brightCyan: '#3192aa',
+ brightWhite: '#8c959f'
+} as const
+
+function resolveThemeMode(): 'dark' | 'light' {
+ return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
+}
+
+function isTransparentColor(color: string): boolean {
+ return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)'
+}
+
+function parseCssColor(color: string): RgbaColor | null {
+ if (isTransparentColor(color)) return { r: 0, g: 0, b: 0, a: 0 }
+ const match = color.match(/^rgba?\((.+)\)$/)
+ if (!match) return null
+ const normalized = match[1].replace(/\s*\/\s*/, ', ')
+ const parts = normalized.includes(',')
+ ? normalized.split(',').map((part) => part.trim())
+ : normalized.trim().split(/\s+/)
+ const [r, g, b] = parts.slice(0, 3).map((part) => Number.parseFloat(part))
+ const alpha = parts[3] === undefined ? 1 : Number.parseFloat(parts[3])
+ if (![r, g, b, alpha].every(Number.isFinite)) return null
+ return {
+ r: Math.min(255, Math.max(0, r)),
+ g: Math.min(255, Math.max(0, g)),
+ b: Math.min(255, Math.max(0, b)),
+ a: Math.min(1, Math.max(0, alpha))
+ }
+}
+
+function compositeColor(foreground: RgbaColor, background: RgbaColor): RgbaColor {
+ const alpha = foreground.a + background.a * (1 - foreground.a)
+ if (alpha <= 0) return { r: 0, g: 0, b: 0, a: 0 }
+ return {
+ r: (foreground.r * foreground.a + background.r * background.a * (1 - foreground.a)) / alpha,
+ g: (foreground.g * foreground.a + background.g * background.a * (1 - foreground.a)) / alpha,
+ b: (foreground.b * foreground.a + background.b * background.a * (1 - foreground.a)) / alpha,
+ a: alpha
+ }
+}
+
+function toOpaqueRgb(color: RgbaColor): string {
+ return `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`
+}
+
+function resolveTerminalSurfaceColor(container: HTMLElement | null): string {
+ const layers: RgbaColor[] = []
+ let node: HTMLElement | null = container
+ while (node) {
+ const color = parseCssColor(getComputedStyle(node).backgroundColor)
+ if (color && color.a > 0) layers.push(color)
+ if (color && color.a >= 1) break
+ node = node.parentElement
+ }
+ const fallback = parseCssColor(resolveThemeMode() === 'light' ? LIGHT_THEME.background : DARK_THEME.background) ?? {
+ r: 255,
+ g: 255,
+ b: 255,
+ a: 1
+ }
+ const resolved = layers.reduceRight((background, foreground) => compositeColor(foreground, background), fallback)
+ return toOpaqueRgb(resolved)
+}
+
+function resolveTerminalTheme(container: HTMLElement | null) {
+ const surfaceColor = resolveTerminalSurfaceColor(container)
+ const baseTheme = resolveThemeMode() === 'light' ? LIGHT_THEME : DARK_THEME
+ return {
+ ...baseTheme,
+ background: surfaceColor,
+ cursorAccent: surfaceColor
+ }
+}
+
+export function TerminalPanel({ className = '', workspaceRoot, onCollapse, height }: Props): ReactElement {
+ const { t } = useTranslation('common')
+ const containerRef = useRef
(null)
+ const termRef = useRef(null)
+ const fitRef = useRef(null)
+ // Guards against stale async after unmount or re-attach.
+ const aliveRef = useRef(true)
+ const attachTokenRef = useRef(0)
+ const [error, setError] = useState(null)
+ const [exited, setExited] = useState(false)
+ const [tabs, setTabs] = useState([{ id: INITIAL_TAB_ID, index: 1 }])
+ const [activeTabId, setActiveTabId] = useState(INITIAL_TAB_ID)
+ const [contextMenu, setContextMenu] = useState(null)
+ const [renamingTabId, setRenamingTabId] = useState(null)
+ const [renameValue, setRenameValue] = useState('')
+ const renameInputRef = useRef(null)
+ const tabButtonRefs = useRef>({})
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]
+
+ const getTabTitle = useCallback((tab: TerminalTab): string => {
+ return tab.title?.trim() || t('terminalTabTitle', { index: tab.index })
+ }, [t])
+
+ const disposeRenderer = useCallback(() => {
+ const term = termRef.current
+ const disposer = (term as Terminal & { __dispose?: () => void } | null)?.__dispose
+ disposer?.()
+ term?.dispose()
+ termRef.current = null
+ fitRef.current = null
+ const container = containerRef.current
+ if (container) container.replaceChildren()
+ }, [])
+
+ // (Re)create the xterm instance and wire it to a persistent PTY session.
+ // On unmount we dispose only the xterm renderer; the underlying PTY stays
+ // alive in the main process so toggling the panel preserves shell state
+ // and replays recent output from the ring buffer on re-attach.
+ const attachTerminal = useCallback(async (sessionId: string) => {
+ const attachToken = ++attachTokenRef.current
+ const isCurrentAttach = (): boolean => aliveRef.current && attachTokenRef.current === attachToken
+ const container = containerRef.current
+ if (!container || !isCurrentAttach()) return
+ container.replaceChildren()
+ setError(null)
+ setExited(false)
+
+ const cols = fitRef.current?.proposeDimensions()?.cols ?? TERMINAL_DEFAULT_COLS
+ const rows = fitRef.current?.proposeDimensions()?.rows ?? TERMINAL_DEFAULT_ROWS
+
+ const term = new Terminal({
+ fontFamily: TERMINAL_FONT_FAMILY,
+ fontSize: TERMINAL_FONT_SIZE,
+ cursorBlink: true,
+ scrollback: TERMINAL_SCROLLBACK,
+ allowProposedApi: true,
+ theme: resolveTerminalTheme(container),
+ cols,
+ rows
+ })
+ const fit = new FitAddon()
+ term.loadAddon(fit)
+ term.loadAddon(new WebLinksAddon())
+ term.open(container)
+ termRef.current = term
+ fitRef.current = fit
+ // The container may still be settling (lazy Suspense); defer the first
+ // fit to the next frame so clientWidth is correct.
+ requestAnimationFrame(() => {
+ if (!isCurrentAttach()) return
+ try {
+ fit.fit()
+ } catch {
+ /* ignore until the element has a measurable size */
+ }
+ })
+
+ // Stream PTY output → xterm.
+ const offData = window.kunGui.onTerminalData((payload) => {
+ if (payload.sessionId !== sessionId) return
+ term.write(payload.data)
+ })
+ const offExit = window.kunGui.onTerminalExit((payload) => {
+ if (payload.sessionId !== sessionId) return
+ setExited(true)
+ })
+
+ // xterm input → PTY.
+ const disposable = term.onData((data) => {
+ void window.kunGui.writeToTerminal({
+ sessionId,
+ data
+ })
+ })
+
+ // Keep cols/rows in sync with the panel width.
+ let resizeTimer: ReturnType | null = null
+ const triggerFit = (): void => {
+ if (resizeTimer) clearTimeout(resizeTimer)
+ resizeTimer = setTimeout(() => {
+ if (!isCurrentAttach()) return
+ try {
+ fit.fit()
+ } catch {
+ /* ignore */
+ }
+ }, FIT_DEBOUNCE_MS)
+ }
+ const resizeObserver = new ResizeObserver(triggerFit)
+ resizeObserver.observe(container)
+ const onDimensionChange = (dim: { cols: number; rows: number }): void => {
+ void window.kunGui.resizeTerminal({
+ sessionId,
+ cols: dim.cols,
+ rows: dim.rows
+ })
+ }
+ const fitDisposable = term.onResize(onDimensionChange)
+
+ // Create (or re-attach to) the PTY session. On re-attach the main process
+ // replays the ring buffer before new output arrives.
+ try {
+ const result = await window.kunGui.createTerminal({
+ sessionId,
+ cwd: workspaceRoot || undefined,
+ cols,
+ rows
+ })
+ if (!isCurrentAttach()) return
+ if (!result.ok) {
+ setError(result.message)
+ return
+ }
+ // After a successful (re)attach, reflect the latest fit so the PTY
+ // matches the visible grid.
+ const dims = fit.proposeDimensions()
+ if (dims) {
+ void window.kunGui.resizeTerminal({
+ sessionId,
+ cols: dims.cols,
+ rows: dims.rows
+ })
+ }
+ setExited(false)
+ } catch (e) {
+ if (!isCurrentAttach()) return
+ setError(e instanceof Error ? e.message : String(e))
+ }
+
+ // Stash disposers on the instance for teardown.
+ ;(term as Terminal & { __dispose?: () => void }).__dispose = () => {
+ offData()
+ offExit()
+ disposable.dispose()
+ fitDisposable.dispose()
+ resizeObserver.disconnect()
+ if (resizeTimer) clearTimeout(resizeTimer)
+ }
+ }, [workspaceRoot])
+
+ useEffect(() => {
+ aliveRef.current = true
+ if (activeTab) void attachTerminal(activeTab.id)
+ return () => {
+ aliveRef.current = false
+ attachTokenRef.current += 1
+ disposeRenderer()
+ }
+ }, [activeTab, attachTerminal, disposeRenderer])
+
+ // React to system/app theme changes so the terminal follows light/dark.
+ useEffect(() => {
+ const observer = new MutationObserver(() => {
+ const term = termRef.current
+ if (!term) return
+ term.options.theme = resolveTerminalTheme(containerRef.current)
+ })
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
+ return () => observer.disconnect()
+ }, [])
+
+ useEffect(() => {
+ if (!contextMenu) return
+ const close = (): void => setContextMenu(null)
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Escape') close()
+ }
+ window.addEventListener('pointerdown', close)
+ window.addEventListener('keydown', onKeyDown)
+ return () => {
+ window.removeEventListener('pointerdown', close)
+ window.removeEventListener('keydown', onKeyDown)
+ }
+ }, [contextMenu])
+
+ useEffect(() => {
+ if (!renamingTabId) return
+ requestAnimationFrame(() => {
+ renameInputRef.current?.focus()
+ renameInputRef.current?.select()
+ })
+ }, [renamingTabId])
+
+ const handleNewTab = useCallback(() => {
+ if (tabs.length >= MAX_RENDERER_TABS) return
+ const nextIndex = tabs.length + 1
+ const tab: TerminalTab = {
+ id: `tab-${Date.now().toString(36)}-${nextIndex}`,
+ index: nextIndex
+ }
+ setTabs((current) => [...current, tab])
+ setActiveTabId(tab.id)
+ }, [tabs.length])
+
+ const handleCloseTab = useCallback((tabId: string) => {
+ const closingIndex = tabs.findIndex((tab) => tab.id === tabId)
+ if (closingIndex === -1) return
+ void window.kunGui.disposeTerminal(tabId)
+ setTabs((current) => {
+ if (current.length <= 1) return current
+ return current.filter((tab) => tab.id !== tabId)
+ })
+ if (activeTabId === tabId) {
+ const nextTab = tabs[closingIndex + 1] ?? tabs[closingIndex - 1] ?? tabs[0]
+ if (nextTab && nextTab.id !== tabId) setActiveTabId(nextTab.id)
+ }
+ }, [activeTabId, tabs])
+
+ const openTabContextMenu = useCallback((event: ReactMouseEvent | ReactPointerEvent, tabId: string) => {
+ event.preventDefault()
+ event.stopPropagation()
+ const tabButton = tabButtonRefs.current[tabId]
+ const tabRect = tabButton?.getBoundingClientRect()
+ const pointerX = event.clientX > 0 ? event.clientX : (tabRect?.left ?? 0)
+ const pointerY = event.clientY > 0 ? event.clientY : (tabRect?.bottom ?? 0)
+ setActiveTabId(tabId)
+ setContextMenu({
+ tabId,
+ x: Math.min(Math.max(pointerX, 8), window.innerWidth - 220),
+ y: Math.min(Math.max(pointerY, 8), window.innerHeight - 132)
+ })
+ }, [])
+
+ const openActiveTabContextMenu = useCallback((event: ReactMouseEvent) => {
+ if (!activeTab) return
+ openTabContextMenu(event, activeTab.id)
+ }, [activeTab, openTabContextMenu])
+
+ const openTabContextMenuOnSecondaryPointer = useCallback((event: ReactPointerEvent, tabId: string) => {
+ if (event.button !== 2) return
+ openTabContextMenu(event, tabId)
+ }, [openTabContextMenu])
+
+ const openActiveTabContextMenuOnSecondaryPointer = useCallback((event: ReactPointerEvent) => {
+ if (!activeTab || event.button !== 2) return
+ openTabContextMenu(event, activeTab.id)
+ }, [activeTab, openTabContextMenu])
+
+ const startRenameTab = useCallback((tabId: string) => {
+ const tab = tabs.find((item) => item.id === tabId)
+ if (!tab) return
+ setContextMenu(null)
+ setRenamingTabId(tabId)
+ setRenameValue(getTabTitle(tab))
+ }, [getTabTitle, tabs])
+
+ const commitRenameTab = useCallback(() => {
+ if (!renamingTabId) return
+ const nextTitle = renameValue.trim()
+ setTabs((current) =>
+ current.map((tab) => (tab.id === renamingTabId ? { ...tab, title: nextTitle || undefined } : tab))
+ )
+ setRenamingTabId(null)
+ setRenameValue('')
+ }, [renameValue, renamingTabId])
+
+ const cancelRenameTab = useCallback(() => {
+ setRenamingTabId(null)
+ setRenameValue('')
+ }, [])
+
+ const handleCloseOtherTabs = useCallback((tabId: string) => {
+ const keptTab = tabs.find((tab) => tab.id === tabId)
+ if (!keptTab) return
+ for (const tab of tabs) {
+ if (tab.id !== tabId) void window.kunGui.disposeTerminal(tab.id)
+ }
+ setTabs([keptTab])
+ setActiveTabId(tabId)
+ setContextMenu(null)
+ if (renamingTabId && renamingTabId !== tabId) cancelRenameTab()
+ }, [cancelRenameTab, renamingTabId, tabs])
+
+ const handleCloseAllTabs = useCallback(() => {
+ for (const tab of tabs) {
+ void window.kunGui.disposeTerminal(tab.id)
+ }
+ setContextMenu(null)
+ cancelRenameTab()
+ setTabs([{ id: INITIAL_TAB_ID, index: 1 }])
+ setActiveTabId(INITIAL_TAB_ID)
+ onCollapse()
+ }, [cancelRenameTab, onCollapse, tabs])
+
+ const handleRestart = useCallback(async () => {
+ if (!activeTab) return
+ // Dispose the old shell then re-attach so a fresh one spawns.
+ try {
+ await window.kunGui.disposeTerminal(activeTab.id)
+ } catch {
+ /* ignore */
+ }
+ setError(null)
+ setExited(false)
+ disposeRenderer()
+ aliveRef.current = true
+ void attachTerminal(activeTab.id)
+ }, [activeTab, attachTerminal, disposeRenderer])
+
+ return (
+
+
+
+ {tabs.map((tab) => {
+ const active = tab.id === activeTabId
+ return (
+
openTabContextMenu(event, tab.id)}
+ >
+ {renamingTabId === tab.id ? (
+ setRenameValue(event.target.value)}
+ onBlur={commitRenameTab}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ commitRenameTab()
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ cancelRenameTab()
+ }
+ }}
+ className="mx-2 min-w-0 flex-1 rounded-md border border-ds-border-muted bg-ds-card px-2 py-1 text-[12px] text-ds-ink outline-none focus:border-ds-accent"
+ aria-label={t('terminalRenameTab')}
+ />
+ ) : (
+ {
+ tabButtonRefs.current[tab.id] = node
+ }}
+ aria-selected={active}
+ onClick={() => setActiveTabId(tab.id)}
+ onPointerDownCapture={(event) => openTabContextMenuOnSecondaryPointer(event, tab.id)}
+ onContextMenu={(event) => openTabContextMenu(event, tab.id)}
+ className="flex min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left"
+ >
+
+ {getTabTitle(tab)}
+
+ )}
+ {tabs.length > 1 ? (
+ {
+ event.stopPropagation()
+ handleCloseTab(tab.id)
+ }}
+ className="mr-2 rounded-full p-0.5 text-ds-faint opacity-0 transition hover:bg-ds-hover hover:text-ds-ink group-hover:opacity-100"
+ >
+
+
+ ) : null}
+
+ )
+ })}
+
= MAX_RENDERER_TABS}
+ className="mb-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-ds-faint transition hover:bg-ds-hover hover:text-ds-ink disabled:cursor-not-allowed disabled:opacity-40"
+ aria-label={t('terminalNewTab')}
+ title={t('terminalNewTab')}
+ >
+
+
+
+
+ void handleRestart()}
+ className="rounded-full p-1.5 text-ds-faint transition hover:bg-ds-hover hover:text-ds-ink"
+ aria-label={t('terminalRestart')}
+ title={t('terminalRestart')}
+ >
+
+
+
+
+
+
+ {contextMenu ? (
+ createPortal(
+
startRenameTab(contextMenu.tabId)}
+ onCloseOthers={() => handleCloseOtherTabs(contextMenu.tabId)}
+ onCloseAll={handleCloseAllTabs}
+ t={t}
+ />,
+ document.body
+ )
+ ) : null}
+
+
+
+
+ {error ? (
+
+
+
{t('terminalUnavailable')}
+
{error}
+
void handleRestart()}
+ className="mt-4 rounded-full bg-white/10 px-3 py-1.5 text-[12px] font-semibold text-white transition hover:bg-white/20"
+ >
+ {t('terminalRestart')}
+
+
+
+ ) : null}
+ {exited && !error ? (
+
+ void handleRestart()}
+ className="pointer-events-auto rounded-full bg-white/10 px-3 py-1.5 text-[12px] font-semibold text-white shadow-lg backdrop-blur transition hover:bg-white/20"
+ >
+ {t('terminalExitMessage')}
+
+
+ ) : null}
+
+
+ )
+}
+
+function TerminalTabContextMenu({
+ state,
+ tabCount,
+ onRename,
+ onCloseOthers,
+ onCloseAll,
+ t
+}: {
+ state: TerminalTabContextMenu
+ tabCount: number
+ onRename: () => void
+ onCloseOthers: () => void
+ onCloseAll: () => void
+ t: (key: string, options?: Record) => string
+}): ReactElement {
+ const run = (action: () => void): void => {
+ action()
+ }
+
+ return (
+ event.stopPropagation()}
+ onContextMenu={(event) => event.preventDefault()}
+ >
+
}
+ label={t('terminalRenameTab')}
+ onClick={() => run(onRename)}
+ />
+
+
}
+ label={t('terminalCloseOtherTabs')}
+ disabled={tabCount <= 1}
+ onClick={() => run(onCloseOthers)}
+ />
+
}
+ label={t('terminalCloseAllTabs')}
+ danger
+ onClick={() => run(onCloseAll)}
+ />
+
+ )
+}
+
+function TerminalTabContextMenuItem({
+ icon,
+ label,
+ disabled = false,
+ danger = false,
+ onClick
+}: {
+ icon: ReactElement
+ label: string
+ disabled?: boolean
+ danger?: boolean
+ onClick: () => void
+}): ReactElement {
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
diff --git a/src/renderer/src/components/workbench-layout.ts b/src/renderer/src/components/workbench-layout.ts
index 10dc048d2..7c71826a4 100644
--- a/src/renderer/src/components/workbench-layout.ts
+++ b/src/renderer/src/components/workbench-layout.ts
@@ -14,6 +14,8 @@ const LEFT_PANEL_WIDTH_KEY = 'kun.layout.leftSidebarWidth'
const LEFT_PANEL_COLLAPSED_KEY = 'kun.layout.leftSidebarCollapsed'
const RIGHT_PANEL_WIDTH_KEY = 'kun.layout.rightInspectorWidth'
const RIGHT_PANEL_MODE_KEY = 'kun.layout.rightPanelMode'
+const TERMINAL_OPEN_KEY = 'kun.layout.terminalOpen'
+const TERMINAL_HEIGHT_KEY = 'kun.layout.terminalHeight'
const LEFT_PANEL_DEFAULT = 304
const RIGHT_PANEL_DEFAULT = 360
export const CODE_PANEL_PREFERRED = 560
@@ -24,6 +26,11 @@ const RIGHT_PANEL_MAX = 760
const SIDEBAR_HARD_MIN = 180
const MAIN_MIN_WIDTH = 560
const PANEL_RESIZE_HANDLE_WIDTH = 5
+// Bottom terminal drawer sizing. The drawer lives below the chat stage and
+// resizes vertically, so it has its own clamps instead of the column widths.
+const TERMINAL_HEIGHT_DEFAULT = 360
+const TERMINAL_HEIGHT_MIN = 220
+const TERMINAL_HEIGHT_MAX = 760
function clampWidth(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
@@ -169,6 +176,10 @@ export function useWorkbenchLayout({
const [rightSidebarWidth, setRightSidebarWidth] = useState(() =>
readStoredWidth(RIGHT_PANEL_WIDTH_KEY, RIGHT_PANEL_DEFAULT)
)
+ const [terminalOpen, setTerminalOpen] = useState(false)
+ const [terminalHeight, setTerminalHeight] = useState(() =>
+ readStoredWidth(TERMINAL_HEIGHT_KEY, TERMINAL_HEIGHT_DEFAULT)
+ )
const shellRef = useRef(null)
const previewThreadId = useRef(activeThreadId)
const autoOpenedPreviewUrlRef = useRef(null)
@@ -190,6 +201,14 @@ export function useWorkbenchLayout({
persistRightPanelMode(rightPanelMode)
}, [rightPanelMode])
+ useEffect(() => {
+ removeBrowserStorageItem(TERMINAL_OPEN_KEY)
+ }, [])
+
+ useEffect(() => {
+ persistWidth(TERMINAL_HEIGHT_KEY, terminalHeight)
+ }, [terminalHeight])
+
useEffect(() => {
const onPreview = (event: Event): void => {
const detail = (event as CustomEvent).detail
@@ -340,9 +359,45 @@ export function useWorkbenchLayout({
window.addEventListener('pointerup', onUp)
}
+ // Bottom terminal drawer: dragging the top edge up grows the panel. The
+ // clamps keep enough chat stage visible above it.
+ const beginTerminalResize = (event: ReactPointerEvent): void => {
+ if (event.button !== 0 || !terminalOpen) return
+ event.preventDefault()
+ const startY = event.clientY
+ const startHeight = terminalHeight
+ const prevCursor = document.body.style.cursor
+ const prevUserSelect = document.body.style.userSelect
+ document.body.style.cursor = 'row-resize'
+ document.body.style.userSelect = 'none'
+
+ const onMove = (moveEvent: PointerEvent): void => {
+ const containerHeight = shellRef.current?.clientHeight ?? window.innerHeight
+ const delta = startY - moveEvent.clientY
+ const maxHeight = Math.max(TERMINAL_HEIGHT_MIN, Math.min(TERMINAL_HEIGHT_MAX, containerHeight - 260))
+ const nextHeight = Math.min(Math.max(startHeight + delta, TERMINAL_HEIGHT_MIN), maxHeight)
+ setTerminalHeight(nextHeight)
+ }
+
+ const onUp = (): void => {
+ document.body.style.cursor = prevCursor
+ document.body.style.userSelect = prevUserSelect
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerup', onUp)
+ }
+
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp)
+ }
+
+ const toggleTerminal = (): void => {
+ setTerminalOpen((current) => !current)
+ }
+
return {
beginLeftResize,
beginRightResize,
+ beginTerminalResize,
filePreviewTarget,
leftSidebarCollapsed,
leftSidebarWidth,
@@ -354,7 +409,10 @@ export function useWorkbenchLayout({
setRightPanelMode,
setRightSidebarWidth,
shellRef,
+ terminalHeight,
+ terminalOpen,
toggleLeftSidebar,
- toggleRightPanelMode
+ toggleRightPanelMode,
+ toggleTerminal
}
}
diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json
index 8f145281f..2868e6688 100644
--- a/src/renderer/src/locales/en/common.json
+++ b/src/renderer/src/locales/en/common.json
@@ -826,6 +826,19 @@
"rightPanelRuntime": "Runtime",
"rightPanelPlan": "Plan",
"rightPanelTodo": "Todo",
+ "rightPanelTerminal": "Terminal",
+ "terminalPanelTitle": "Terminal",
+ "terminalClear": "Clear terminal",
+ "terminalRestart": "Restart terminal",
+ "terminalNewTab": "New terminal tab",
+ "terminalCloseTab": "Close terminal tab",
+ "terminalTabMenuTitle": "Terminal tab actions",
+ "terminalRenameTab": "Rename terminal tab",
+ "terminalCloseOtherTabs": "Close other terminal tabs",
+ "terminalCloseAllTabs": "Close all terminal tabs",
+ "terminalTabTitle": "Terminal {{index}}",
+ "terminalExitMessage": "Process exited — click to restart",
+ "terminalUnavailable": "Terminal unavailable",
"rightPanelCollapse": "Collapse right sidebar",
"editorPickerTitle": "Choose default editor",
"editorPickerTitleWithEditor": "Default editor: {{editor}}",
diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json
index f69bbc80b..fbecfb3e1 100644
--- a/src/renderer/src/locales/zh/common.json
+++ b/src/renderer/src/locales/zh/common.json
@@ -826,6 +826,19 @@
"rightPanelRuntime": "运行时",
"rightPanelPlan": "计划",
"rightPanelTodo": "Todo",
+ "rightPanelTerminal": "终端",
+ "terminalPanelTitle": "终端",
+ "terminalClear": "清空终端",
+ "terminalRestart": "重启终端",
+ "terminalNewTab": "新建终端标签",
+ "terminalCloseTab": "关闭终端标签",
+ "terminalTabMenuTitle": "终端标签操作",
+ "terminalRenameTab": "重命名终端标签",
+ "terminalCloseOtherTabs": "关闭其他终端标签",
+ "terminalCloseAllTabs": "关闭全部终端标签",
+ "terminalTabTitle": "终端 {{index}}",
+ "terminalExitMessage": "进程已退出 — 点击重启",
+ "terminalUnavailable": "终端不可用",
"rightPanelCollapse": "收起右侧栏",
"editorPickerTitle": "选择默认编辑器",
"editorPickerTitleWithEditor": "默认编辑器:{{editor}}",
diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css
index 53988db69..90e76c3d1 100644
--- a/src/renderer/src/styles/base-shell.css
+++ b/src/renderer/src/styles/base-shell.css
@@ -2636,7 +2636,8 @@ pre {
}
.ds-stage-inset {
- padding-inline: clamp(0.75rem, calc((100% - 56rem) / 2 + 1.25rem), 4rem);
+ --ds-stage-inset-x: clamp(0.75rem, calc((100% - 56rem) / 2 + 1.25rem), 4rem);
+ padding-inline: var(--ds-stage-inset-x);
}
.ds-chat-column-inset {
@@ -2827,7 +2828,7 @@ pre {
@media (max-height: 820px) {
.ds-stage-inset {
- padding-inline: clamp(0.5rem, calc((100% - 54rem) / 2 + 0.75rem), 2.5rem);
+ --ds-stage-inset-x: clamp(0.5rem, calc((100% - 54rem) / 2 + 0.75rem), 2.5rem);
}
.ds-chat-column-inset {
diff --git a/src/shared/kun-gui-api.ts b/src/shared/kun-gui-api.ts
index 94bc89154..5eec178bf 100644
--- a/src/shared/kun-gui-api.ts
+++ b/src/shared/kun-gui-api.ts
@@ -81,6 +81,14 @@ import type {
WriteRichClipboardPayload,
WriteRichClipboardResult
} from './write-export'
+import type {
+ TerminalCreatePayload,
+ TerminalCreateResult,
+ TerminalDataPayload,
+ TerminalExitPayload,
+ TerminalResizePayload,
+ TerminalWritePayload
+} from './terminal'
export type KunRuntimeStatusPayload = {
state: 'starting' | 'running' | 'restarting' | 'crashed' | 'failed' | 'stopped'
@@ -409,4 +417,10 @@ export type KunGuiApi = {
logError: (category: string, message: string, detail?: unknown) => Promise
getLogPath: () => Promise
openLogDir: () => Promise<{ ok: boolean; message?: string }>
+ createTerminal: (payload: TerminalCreatePayload) => Promise
+ writeToTerminal: (payload: TerminalWritePayload) => Promise
+ resizeTerminal: (payload: TerminalResizePayload) => Promise
+ disposeTerminal: (sessionId: string) => Promise
+ onTerminalData: (handler: (payload: TerminalDataPayload) => void) => () => void
+ onTerminalExit: (handler: (payload: TerminalExitPayload) => void) => () => void
}
diff --git a/src/shared/terminal.ts b/src/shared/terminal.ts
new file mode 100644
index 000000000..406954bc2
--- /dev/null
+++ b/src/shared/terminal.ts
@@ -0,0 +1,57 @@
+/**
+ * Shared types and constants for the built-in terminal.
+ *
+ * The terminal is a real pseudo-terminal spawned in the Electron main
+ * process via node-pty. Output is streamed to the renderer over IPC and
+ * rendered with xterm.js. These types live in `src/shared` so both the main
+ * process (IPC handlers), the preload bridge, and the renderer all share one
+ * contract — mirroring how the workspace/SSE types are structured.
+ */
+
+export const TERMINAL_MAX_SESSIONS = 8
+export const TERMINAL_MAX_DATA_WRITE_BYTES = 1_000_000
+export const TERMINAL_RING_BUFFER_BYTES = 64 * 1024
+export const TERMINAL_MAX_SESSION_ID_LENGTH = 256
+export const TERMINAL_MAX_CWD_LENGTH = 4_096
+export const TERMINAL_DEFAULT_COLS = 80
+export const TERMINAL_DEFAULT_ROWS = 24
+export const TERMINAL_MAX_COLS = 500
+export const TERMINAL_MAX_ROWS = 200
+export const TERMINAL_MAIN_SESSION_ID = 'main'
+
+export type TerminalCreatePayload = {
+ /** Stable session identifier. The workbench uses a single `main` session. */
+ sessionId: string
+ /** Working directory for the spawned shell. Defaults to the OS home dir. */
+ cwd?: string
+ cols?: number
+ rows?: number
+}
+
+export type TerminalWritePayload = {
+ sessionId: string
+ /** Raw bytes typed by the user (UTF-8 string). */
+ data: string
+}
+
+export type TerminalResizePayload = {
+ sessionId: string
+ cols: number
+ rows: number
+}
+
+/** Main → renderer output stream, one IPC message per PTY data chunk. */
+export type TerminalDataPayload = {
+ sessionId: string
+ data: string
+}
+
+export type TerminalExitPayload = {
+ sessionId: string
+ /** Process exit code; null when the shell did not exit cleanly. */
+ exitCode: number | null
+}
+
+export type TerminalCreateResult =
+ | { ok: true; sessionId: string; replayed?: boolean }
+ | { ok: false; message: string }
From 77b2cf4e98d066b343e991b0d5687263e34c17c9 Mon Sep 17 00:00:00 2001
From: XingYu-Zhong <1736101137@qq.com>
Date: Tue, 16 Jun 2026 09:17:15 +0800
Subject: [PATCH 6/9] feat(skill-root): add skill root options and update
related components
---
.../components/PluginMarketplaceView.test.ts | 56 +++++++-
.../src/components/PluginMarketplaceView.tsx | 120 +++++++++++-------
src/renderer/src/lib/skill-root-preference.ts | 35 ++---
src/renderer/src/locales/en/common.json | 1 +
src/renderer/src/locales/zh/common.json | 1 +
5 files changed, 140 insertions(+), 73 deletions(-)
diff --git a/src/renderer/src/components/PluginMarketplaceView.test.ts b/src/renderer/src/components/PluginMarketplaceView.test.ts
index 293f79f54..065470a50 100644
--- a/src/renderer/src/components/PluginMarketplaceView.test.ts
+++ b/src/renderer/src/components/PluginMarketplaceView.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
+import type { SkillRootListItem } from '@shared/kun-gui-api'
import {
buildMcpConfig,
customMcpConfigFragment,
@@ -7,7 +8,8 @@ import {
mergeMcpJsonConfig,
recommendedMarketplaceItemIds,
setMcpServerEnabled,
- skillMarketplaceItemsFromDiscoveredSkills
+ skillMarketplaceItemsFromDiscoveredSkills,
+ skillRootOptionsFromRoots
} from './PluginMarketplaceView'
describe('PluginMarketplaceView MCP config helpers', () => {
@@ -267,3 +269,55 @@ describe('skillMarketplaceItemsFromDiscoveredSkills', () => {
])
})
})
+
+describe('skillRootOptionsFromRoots', () => {
+ const roots: SkillRootListItem[] = [
+ {
+ id: 'workspace-claude',
+ disableKey: 'workspace-claude',
+ path: '/ws/.claude/skills',
+ scope: 'project',
+ source: 'common',
+ labelKey: 'pluginSkillRootWorkspaceClaude',
+ exists: true,
+ enabled: true,
+ skillCount: 2
+ },
+ {
+ id: 'global-codex',
+ disableKey: 'global-codex',
+ path: '/home/me/.codex/skills',
+ scope: 'global',
+ source: 'common',
+ labelKey: 'pluginSkillRootGlobalCodex',
+ exists: false,
+ enabled: false,
+ skillCount: 0
+ },
+ {
+ id: '/opt/team/skills',
+ disableKey: '/opt/team/skills',
+ path: '/opt/team/skills',
+ scope: 'global',
+ source: 'extra',
+ exists: true,
+ enabled: true,
+ skillCount: 5
+ }
+ ]
+
+ it('maps backend roots — common (.claude/.codex) and custom dirs — into picker options synced with settings', () => {
+ const options = skillRootOptionsFromRoots(roots, (key) => `t:${key}`)
+
+ expect(options).toEqual([
+ { id: 'workspace-claude', label: 't:pluginSkillRootWorkspaceClaude', path: '/ws/.claude/skills', scope: 'project', enabled: true, exists: true, skillCount: 2 },
+ { id: 'global-codex', label: 't:pluginSkillRootGlobalCodex', path: '/home/me/.codex/skills', scope: 'global', enabled: false, exists: false, skillCount: 0 },
+ // Custom extra dir has no i18n labelKey, so it falls back to a short path label.
+ { id: '/opt/team/skills', label: 'team/skills', path: '/opt/team/skills', scope: 'global', enabled: true, exists: true, skillCount: 5 }
+ ])
+ })
+
+ it('returns an empty list when the backend reports no roots', () => {
+ expect(skillRootOptionsFromRoots([], (key) => key)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/PluginMarketplaceView.tsx b/src/renderer/src/components/PluginMarketplaceView.tsx
index 4d429436d..9cfad07c3 100644
--- a/src/renderer/src/components/PluginMarketplaceView.tsx
+++ b/src/renderer/src/components/PluginMarketplaceView.tsx
@@ -14,7 +14,6 @@ import {
} from 'lucide-react'
import { rendererRuntimeClient } from '../agent/runtime-client'
import {
- joinFsPath,
loadPreferredSkillRootId,
savePreferredSkillRootId,
type SkillRootId
@@ -22,7 +21,7 @@ import {
import { readBrowserStorageItem, writeBrowserStorageItem } from '../lib/browser-storage'
import { normalizeWorkspaceRoot } from '../lib/workspace-path'
import { getProvider } from '../agent/registry'
-import type { SkillListItem } from '@shared/kun-gui-api'
+import type { SkillListItem, SkillRootListItem } from '@shared/kun-gui-api'
import type {
CoreRuntimeInfoJson,
CoreRuntimeToolDiagnosticsJson
@@ -63,7 +62,10 @@ type SkillRootOption = {
id: SkillRootId
label: string
path: string
- available: boolean
+ scope: 'project' | 'global'
+ enabled: boolean
+ exists: boolean
+ skillCount: number
}
const INSTALLED_STORAGE_KEY = 'kun.installedPlugins'
@@ -348,6 +350,34 @@ export function skillMarketplaceItemsFromDiscoveredSkills(
}))
}
+/** Last two path segments, e.g. `/Users/me/.claude/skills` → `.claude/skills`. */
+export function skillRootShortLabel(path: string): string {
+ const parts = path.split(/[\\/]+/).filter(Boolean)
+ return parts.slice(-2).join('/') || path
+}
+
+/**
+ * Builds the skill-root picker options from the backend's detected roots
+ * (`skill:list-roots`) — the same source the settings page renders — so the
+ * marketplace stays in sync instead of hardcoding a fixed subset of dirs.
+ * Common dirs use their i18n label; user-added extra dirs fall back to a short
+ * path label. (#321)
+ */
+export function skillRootOptionsFromRoots(
+ roots: SkillRootListItem[],
+ t: (key: string) => string
+): SkillRootOption[] {
+ return roots.map((root) => ({
+ id: root.id,
+ label: root.labelKey ? t(root.labelKey) : skillRootShortLabel(root.path),
+ path: root.path,
+ scope: root.scope,
+ enabled: root.enabled,
+ exists: root.exists,
+ skillCount: root.skillCount
+ }))
+}
+
export function mcpMarketplaceItemsFromConfigAndDiagnostics(
configText: string,
diagnostics: CoreRuntimeToolDiagnosticsJson | null,
@@ -536,50 +566,27 @@ export function PluginMarketplaceView(): ReactElement {
const [discoveredSkills, setDiscoveredSkills] = useState([])
const [skillListLoading, setSkillListLoading] = useState(false)
const [skillListError, setSkillListError] = useState('')
+ const [skillRoots, setSkillRoots] = useState([])
const [disabledSkillIds, setDisabledSkillIds] = useState([])
const [skillToggleBusyId, setSkillToggleBusyId] = useState(null)
- const skillRootOptions = useMemo(() => {
- const hasWorkspace = !!workspaceRoot
- return [
- {
- id: 'workspace-agents',
- label: t('pluginSkillRootWorkspaceAgents'),
- path: workspaceRoot ? joinFsPath(workspaceRoot, '.agents/skills') : '',
- available: hasWorkspace
- },
- {
- id: 'workspace-skills',
- label: t('pluginSkillRootWorkspaceSkills'),
- path: workspaceRoot ? joinFsPath(workspaceRoot, 'skills') : '',
- available: hasWorkspace
- },
- {
- id: 'global-agents',
- label: t('pluginSkillRootGlobalAgents'),
- path: '~/.agents/skills',
- available: true
- },
- {
- id: 'global-deepseek',
- label: t('pluginSkillRootGlobalDeepseek'),
- path: '~/.kun/skills',
- available: true
- }
- ]
- }, [t, workspaceRoot])
+ const skillRootOptions = useMemo(
+ () => skillRootOptionsFromRoots(skillRoots, t),
+ [skillRoots, t]
+ )
const selectedSkillRoot =
- skillRootOptions.find((option) => option.id === skillRootId && option.available) ??
- skillRootOptions.find((option) => option.available)
+ skillRootOptions.find((option) => option.id === skillRootId) ??
+ skillRootOptions.find((option) => option.enabled) ??
+ skillRootOptions[0]
useEffect(() => {
- const selectedOption = skillRootOptions.find((option) => option.id === skillRootId && option.available)
- if (selectedOption) {
+ if (skillRootOptions.length === 0) return
+ if (skillRootOptions.some((option) => option.id === skillRootId)) {
savePreferredSkillRootId(skillRootId)
return
}
- const fallback = skillRootOptions.find((option) => option.available)
+ const fallback = skillRootOptions.find((option) => option.enabled) ?? skillRootOptions[0]
if (fallback && fallback.id !== skillRootId) {
setSkillRootId(fallback.id)
}
@@ -666,10 +673,24 @@ export function PluginMarketplaceView(): ReactElement {
}
}, [t, workspaceRoot])
+ const refreshSkillRoots = useCallback(async (): Promise => {
+ if (typeof window.kunGui?.listSkillRoots !== 'function') {
+ setSkillRoots([])
+ return
+ }
+ try {
+ const result = await window.kunGui.listSkillRoots(workspaceRoot || undefined)
+ setSkillRoots(result.ok ? result.roots : [])
+ } catch {
+ setSkillRoots([])
+ }
+ }, [workspaceRoot])
+
useEffect(() => {
if (activeKind !== 'skill') return
void refreshSkillList()
- }, [activeKind, refreshSkillList])
+ void refreshSkillRoots()
+ }, [activeKind, refreshSkillList, refreshSkillRoots])
useEffect(() => {
if (activeKind !== 'skill') return
@@ -820,7 +841,7 @@ export function PluginMarketplaceView(): ReactElement {
return
}
markInstalled(storageKey('skill', item.id))
- await refreshSkillList()
+ await Promise.all([refreshSkillList(), refreshSkillRoots()])
setNotice({ tone: 'success', message: t('pluginSkillAdded', { path: result.path }) })
} catch (e) {
setNotice({ tone: 'error', message: e instanceof Error ? e.message : String(e) })
@@ -862,7 +883,7 @@ export function PluginMarketplaceView(): ReactElement {
return
}
markInstalled(storageKey('skill', id))
- await refreshSkillList()
+ await Promise.all([refreshSkillList(), refreshSkillRoots()])
setNotice({ tone: 'success', message: t('pluginSkillAdded', { path: result.path }) })
}
setCustomName('')
@@ -1009,13 +1030,18 @@ export function PluginMarketplaceView(): ReactElement {
setSkillRootId(event.target.value as SkillRootId)}
- className="h-10 rounded-xl border border-ds-border bg-ds-card px-3 text-[13px] text-ds-ink shadow-sm outline-none focus:border-accent/40 focus:ring-1 focus:ring-accent/30"
+ disabled={skillRootOptions.length === 0}
+ className="h-10 rounded-xl border border-ds-border bg-ds-card px-3 text-[13px] text-ds-ink shadow-sm outline-none focus:border-accent/40 focus:ring-1 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
>
- {skillRootOptions.map((option) => (
-
- {option.available ? option.label : `${option.label} · ${t('pluginSkillRootNeedsWorkspace')}`}
-
- ))}
+ {skillRootOptions.length === 0 ? (
+ {t('pluginSkillRootNone')}
+ ) : (
+ skillRootOptions.map((option) => (
+
+ {option.enabled ? option.label : `${option.label} · ${t('pluginSkillStatusDisabled')}`}
+
+ ))
+ )}
void refreshSkillList()}
+ onClick={() => void Promise.all([refreshSkillList(), refreshSkillRoots()])}
disabled={skillListLoading}
className="inline-flex h-10 items-center gap-2 rounded-xl border border-ds-border bg-ds-card px-3 text-[13px] font-medium text-ds-ink shadow-sm transition hover:bg-ds-hover disabled:cursor-not-allowed disabled:opacity-60"
>
diff --git a/src/renderer/src/lib/skill-root-preference.ts b/src/renderer/src/lib/skill-root-preference.ts
index b82e964db..6f238c53e 100644
--- a/src/renderer/src/lib/skill-root-preference.ts
+++ b/src/renderer/src/lib/skill-root-preference.ts
@@ -1,36 +1,21 @@
import { readBrowserStorageItem, writeBrowserStorageItem } from './browser-storage'
-export type SkillRootId =
- | 'workspace-agents'
- | 'workspace-skills'
- | 'global-agents'
- | 'global-deepseek'
+/**
+ * A skill-root identifier: a common-directory id (e.g. `workspace-claude`,
+ * `global-codex`) or, for user-configured extra dirs, the absolute path itself.
+ * Kept as a plain string so the marketplace picker stays in sync with whatever
+ * roots the backend (`skill:list-roots`) and the settings page report, rather
+ * than a hardcoded subset.
+ */
+export type SkillRootId = string
-const DEFAULT_SKILL_ROOT_ID: SkillRootId = 'workspace-agents'
const SKILL_ROOT_PREFERENCE_KEY = 'kun.skillRootPreference'
-function isSkillRootId(value: string): value is SkillRootId {
- return (
- value === 'workspace-agents' ||
- value === 'workspace-skills' ||
- value === 'global-agents' ||
- value === 'global-deepseek'
- )
-}
-
+/** The skill root the user last picked in the marketplace, or '' when unset. */
export function loadPreferredSkillRootId(): SkillRootId {
- const raw = readBrowserStorageItem(SKILL_ROOT_PREFERENCE_KEY)?.trim() ?? ''
- return isSkillRootId(raw) ? raw : DEFAULT_SKILL_ROOT_ID
+ return readBrowserStorageItem(SKILL_ROOT_PREFERENCE_KEY)?.trim() ?? ''
}
export function savePreferredSkillRootId(id: SkillRootId): void {
writeBrowserStorageItem(SKILL_ROOT_PREFERENCE_KEY, id)
}
-
-export function joinFsPath(base: string, suffix: string): string {
- const root = base.trim().replace(/[\\/]+$/, '')
- const tail = suffix.replace(/^[\\/]+/, '')
- if (!root) return tail
- const separator = root.includes('\\') && !root.includes('/') ? '\\' : '/'
- return `${root}${separator}${tail.replace(/[\\/]+/g, separator)}`
-}
diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json
index 2868e6688..e83734b66 100644
--- a/src/renderer/src/locales/en/common.json
+++ b/src/renderer/src/locales/en/common.json
@@ -504,6 +504,7 @@
"pluginSkillRootGlobalCodex": "Global · ~/.codex/skills",
"pluginSkillRootGlobalDeepseek": "Global · ~/.kun/skills",
"pluginSkillRootNeedsWorkspace": "workspace required",
+ "pluginSkillRootNone": "No skill directory detected",
"pluginSkillRootMissing": "The current Skill directory is unavailable. Choose a workspace or switch to a global directory.",
"pluginSkillRefresh": "Refresh",
"pluginSkillDiscoveredCount": "{{count}} discovered",
diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json
index fbecfb3e1..7055069c3 100644
--- a/src/renderer/src/locales/zh/common.json
+++ b/src/renderer/src/locales/zh/common.json
@@ -504,6 +504,7 @@
"pluginSkillRootGlobalCodex": "全局 · ~/.codex/skills",
"pluginSkillRootGlobalDeepseek": "全局 · ~/.kun/skills",
"pluginSkillRootNeedsWorkspace": "需先设置工作目录",
+ "pluginSkillRootNone": "未检测到技能目录",
"pluginSkillRootMissing": "当前 Skill 目录不可用,请先选择工作目录或切换到全局目录。",
"pluginSkillRefresh": "刷新",
"pluginSkillDiscoveredCount": "已扫描到 {{count}} 个",
From 71a57084cba2ec52e861d097952ece1ba94a74bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?=
<100058663+luoye520ww@users.noreply.github.com>
Date: Tue, 16 Jun 2026 09:26:16 +0800
Subject: [PATCH 7/9] fix provider cache telemetry (#318)
---
kun/src/adapters/model/compat-model-client.ts | 13 ++++--
kun/tests/model-client.test.ts | 44 +++++++++++++++++++
2 files changed, 53 insertions(+), 4 deletions(-)
diff --git a/kun/src/adapters/model/compat-model-client.ts b/kun/src/adapters/model/compat-model-client.ts
index 548e07ccc..e711e9950 100644
--- a/kun/src/adapters/model/compat-model-client.ts
+++ b/kun/src/adapters/model/compat-model-client.ts
@@ -1396,18 +1396,23 @@ export class CompatModelClient implements ModelClient {
const promptDetails = usage.prompt_tokens_details as
| { cached_tokens?: number }
| undefined
+ const inputDetails = usage.input_tokens_details as
+ | { cached_tokens?: number }
+ | undefined
const nativeHit = Number(usage.prompt_cache_hit_tokens ?? 0) || 0
const nativeMiss = Number(usage.prompt_cache_miss_tokens ?? 0) || 0
const hasNativeCache = nativeHit > 0 || nativeMiss > 0
- const cachedTokens = Number(promptDetails?.cached_tokens ?? 0) || 0
+ const cachedTokens = Number(promptDetails?.cached_tokens ?? inputDetails?.cached_tokens ?? 0) || 0
const cacheRead = Number(usage.cache_read_input_tokens ?? 0) || 0
const cacheCreation = Number(usage.cache_creation_input_tokens ?? 0) || 0
// Anthropic-protocol usage (MiniMax et al.) reports input_tokens
// EXCLUDING cache reads/writes; OpenAI-style prompt_tokens includes
- // everything and marks the cached subset in prompt_tokens_details.
+ // everything and marks the cached subset in prompt_tokens_details or
+ // Responses API input_tokens_details.
const anthropicUsage = usage.prompt_tokens === undefined &&
usage.prompt_eval_count === undefined &&
- usage.input_tokens !== undefined
+ usage.input_tokens !== undefined &&
+ inputDetails?.cached_tokens === undefined
const reportedPromptTokens = Number(usage.prompt_tokens ?? usage.prompt_eval_count ?? usage.input_tokens ?? 0) || 0
const promptTokens = anthropicUsage
? reportedPromptTokens + cacheRead + cacheCreation
@@ -1937,7 +1942,7 @@ function applyReasoningEffort(
}
switch (normalized) {
case 'off':
- if (nativeDeepSeek) body.thinking = { type: 'disabled' }
+ if (includeThinking) body.thinking = { type: 'disabled' }
break
case 'low':
case 'medium':
diff --git a/kun/tests/model-client.test.ts b/kun/tests/model-client.test.ts
index 508bdbd4d..7372ea2de 100644
--- a/kun/tests/model-client.test.ts
+++ b/kun/tests/model-client.test.ts
@@ -187,6 +187,50 @@ describe('CompatModelClient', () => {
])
})
+ it('maps Responses API cached input token details into cache telemetry', async () => {
+ const fetchImpl: typeof fetch = async () =>
+ new Response(JSON.stringify({
+ id: 'resp_cache',
+ status: 'completed',
+ output_text: 'cached',
+ usage: {
+ input_tokens: 400,
+ output_tokens: 20,
+ total_tokens: 420,
+ input_tokens_details: { cached_tokens: 300 }
+ }
+ }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ })
+ const client = new CompatModelClient({
+ baseUrl: 'https://example.com/api/v1',
+ apiKey: 'k',
+ model: 'gpt-5-mini',
+ endpointFormat: 'responses',
+ fetchImpl,
+ nonStreaming: true
+ })
+
+ const chunks: ModelStreamChunk[] = []
+ for await (const chunk of client.stream(buildRequest(new AbortController().signal))) {
+ chunks.push(chunk)
+ }
+
+ const usageChunk = chunks.find((chunk) => chunk.kind === 'usage')
+ const usage = usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage : null
+ expect(usage).not.toBeNull()
+ expect(usage).toMatchObject({
+ promptTokens: 400,
+ completionTokens: 20,
+ totalTokens: 420,
+ cachedTokens: 300,
+ cacheHitTokens: 300,
+ cacheMissTokens: 100
+ })
+ expect(usage?.cacheHitRate).toBeCloseTo(0.75)
+ })
+
it('uses the Anthropic Messages API format when selected', async () => {
const sentUrls: string[] = []
const sentBodies: Array> = []
From f5caca62c83335adf6f47b91e29142201ea2269f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?=
<100058663+luoye520ww@users.noreply.github.com>
Date: Tue, 16 Jun 2026 09:26:32 +0800
Subject: [PATCH 8/9] show latest cache rate in usage tooltips (#325)
---
src/renderer/src/components/SessionHeader.tsx | 25 ++++++++++++++-----
.../src/components/chat/FloatingComposer.tsx | 13 +++++++---
.../src/hooks/use-thread-usage.test.ts | 8 +++++-
src/renderer/src/hooks/use-thread-usage.ts | 6 +++++
src/renderer/src/locales/en/common.json | 6 +++--
src/renderer/src/locales/zh/common.json | 6 +++--
6 files changed, 50 insertions(+), 14 deletions(-)
diff --git a/src/renderer/src/components/SessionHeader.tsx b/src/renderer/src/components/SessionHeader.tsx
index 19ce27db3..aeb184fc6 100644
--- a/src/renderer/src/components/SessionHeader.tsx
+++ b/src/renderer/src/components/SessionHeader.tsx
@@ -5,7 +5,13 @@ import { useTranslation } from 'react-i18next'
import { useChatStore } from '../store/chat-store'
import { formatRelativeTime } from '../lib/format-relative-time'
import { workspaceLabelFromPath } from '../lib/workspace-label'
-import { formatCompactNumber, formatCost, formatPercent, useThreadUsage } from '../hooks/use-thread-usage'
+import {
+ formatCompactNumber,
+ formatCost,
+ formatPercent,
+ primaryCacheHitRate,
+ useThreadUsage
+} from '../hooks/use-thread-usage'
type Props = {
compact?: boolean
@@ -191,12 +197,19 @@ export function SessionHeader({ compact = false, className = '' }: Props): React
- {t('sessionUsageCache', { cache: formatPercent(threadUsage.lastTurnCacheHitRate ?? threadUsage.cacheHitRate) })}
+ {t('sessionUsageCache', { cache: formatPercent(primaryCacheHitRate(threadUsage)) })}
>
) : null}
diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx
index 6182f9acf..c918d66bb 100644
--- a/src/renderer/src/components/chat/FloatingComposer.tsx
+++ b/src/renderer/src/components/chat/FloatingComposer.tsx
@@ -70,6 +70,7 @@ import {
formatCompactNumber,
formatCost,
formatPercent,
+ primaryCacheHitRate,
useThreadUsageState
} from '../../hooks/use-thread-usage'
import { buildContextCapacity, estimateBlockTokens } from '../../lib/context-capacity'
@@ -2289,15 +2290,21 @@ export function FloatingComposer({
className="ds-composer-usage ds-no-drag inline-flex min-h-7 max-w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-visible rounded-lg border border-ds-border-muted bg-ds-card/72 px-2.5 py-0.5 text-[12.5px] font-medium leading-5 text-ds-muted shadow-sm"
title={
threadUsage
- ? t('sessionUsageDetailsTitle', {
+ ? t(
+ threadUsage.lastTurnCacheHitRate != null
+ ? 'sessionUsageDetailsTitleWithLatestCache'
+ : 'sessionUsageDetailsTitle',
+ {
tokens: formatCompactNumber(threadUsage.totalTokens),
cost: formatCost(threadUsage.costUsd, i18n.language, threadUsage.costCny),
saved: formatCompactNumber(threadUsage.tokenEconomySavingsTokens),
cache: formatPercent(threadUsage.cacheHitRate),
+ latestCache: formatPercent(threadUsage.lastTurnCacheHitRate),
cached: formatCompactNumber(threadUsage.cachedTokens),
miss: formatCompactNumber(threadUsage.cacheMissTokens),
turns: threadUsage.turns
- })
+ }
+ )
: t('sessionUsageUnavailable')
}
>
@@ -2333,7 +2340,7 @@ export function FloatingComposer({
·
{t('sessionUsageCache', {
- cache: formatPercent(threadUsage.lastTurnCacheHitRate ?? threadUsage.cacheHitRate)
+ cache: formatPercent(primaryCacheHitRate(threadUsage))
})}
·
diff --git a/src/renderer/src/hooks/use-thread-usage.test.ts b/src/renderer/src/hooks/use-thread-usage.test.ts
index aebf5d481..56c64e0b2 100644
--- a/src/renderer/src/hooks/use-thread-usage.test.ts
+++ b/src/renderer/src/hooks/use-thread-usage.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { formatCost, loadThreadUsage } from './use-thread-usage'
+import { formatCost, loadThreadUsage, primaryCacheHitRate } from './use-thread-usage'
type RuntimeRequest = (path: string, method?: string) => Promise<{ ok: boolean; status: number; body: string }>
@@ -34,6 +34,12 @@ describe('thread usage formatting', () => {
expect(formatCost(0.00000001, 'en')).toBe('$<0.0001')
})
+ it('prefers latest-turn cache hit rate for compact cache chips', () => {
+ expect(primaryCacheHitRate({ cacheHitRate: 0.4, lastTurnCacheHitRate: 0.95 })).toBe(0.95)
+ expect(primaryCacheHitRate({ cacheHitRate: 0.4, lastTurnCacheHitRate: null })).toBe(0.4)
+ expect(primaryCacheHitRate({ cacheHitRate: null, lastTurnCacheHitRate: null })).toBeNull()
+ })
+
it('keeps cache hit rate unknown for cachedTokens-only thread usage buckets', async () => {
const runtimeRequest = vi.fn(async (path) => {
if (path === threadUsagePath('thr_cached_only')) {
diff --git a/src/renderer/src/hooks/use-thread-usage.ts b/src/renderer/src/hooks/use-thread-usage.ts
index 5a3856048..8516b1e90 100644
--- a/src/renderer/src/hooks/use-thread-usage.ts
+++ b/src/renderer/src/hooks/use-thread-usage.ts
@@ -78,6 +78,12 @@ export function formatPercent(value: number | null): string {
return `${percent.toFixed(1)}%`
}
+export function primaryCacheHitRate(
+ usage: Pick
+): number | null {
+ return usage.lastTurnCacheHitRate ?? usage.cacheHitRate
+}
+
export async function loadThreadUsage(threadId: string): Promise {
if (typeof window.kunGui?.runtimeRequest !== 'function') return null
const params = new URLSearchParams({
diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json
index e83734b66..64d7b93a0 100644
--- a/src/renderer/src/locales/en/common.json
+++ b/src/renderer/src/locales/en/common.json
@@ -914,8 +914,10 @@
"sessionUsageContextSavingsTitle": "Saved about {{tokens}} context tokens",
"sessionUsageTurns": "{{turns}} turns",
"sessionUsageCache": "cache {{cache}}",
- "sessionUsageCacheTitle": "{{cached}} cached / {{miss}} miss",
- "sessionUsageDetailsTitle": "{{tokens}} tokens · {{cost}} · saved {{saved}} tokens · cache {{cache}} · {{cached}} cached / {{miss}} miss · {{turns}} turns",
+ "sessionUsageCacheTitle": "Cumulative cache {{cache}} · {{cached}} cached / {{miss}} miss",
+ "sessionUsageCacheTitleWithLatest": "Latest turn cache {{latestCache}} · cumulative {{cache}} · {{cached}} cached / {{miss}} miss",
+ "sessionUsageDetailsTitle": "{{tokens}} tokens · {{cost}} · saved {{saved}} tokens · cumulative cache {{cache}} · {{cached}} cached / {{miss}} miss · {{turns}} turns",
+ "sessionUsageDetailsTitleWithLatestCache": "{{tokens}} tokens · {{cost}} · saved {{saved}} tokens · latest cache {{latestCache}} · cumulative cache {{cache}} · {{cached}} cached / {{miss}} miss · {{turns}} turns",
"sessionUsageLoading": "Loading usage",
"sessionUsageUnavailable": "No usage yet",
"contextCapacityTitle": "Context window",
diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json
index 7055069c3..b003781d2 100644
--- a/src/renderer/src/locales/zh/common.json
+++ b/src/renderer/src/locales/zh/common.json
@@ -914,8 +914,10 @@
"sessionUsageContextSavingsTitle": "节省约 {{tokens}} tokens 上下文",
"sessionUsageTurns": "{{turns}} 回合",
"sessionUsageCache": "cache {{cache}}",
- "sessionUsageCacheTitle": "{{cached}} 命中 / {{miss}} 未命中",
- "sessionUsageDetailsTitle": "{{tokens}} tokens · {{cost}} · 省 {{saved}} tokens · cache {{cache}} · {{cached}} 命中 / {{miss}} 未命中 · {{turns}} 回合",
+ "sessionUsageCacheTitle": "累计缓存 {{cache}} · {{cached}} 命中 / {{miss}} 未命中",
+ "sessionUsageCacheTitleWithLatest": "最近一轮缓存 {{latestCache}} · 累计 {{cache}} · {{cached}} 命中 / {{miss}} 未命中",
+ "sessionUsageDetailsTitle": "{{tokens}} tokens · {{cost}} · 省 {{saved}} tokens · 累计缓存 {{cache}} · {{cached}} 命中 / {{miss}} 未命中 · {{turns}} 回合",
+ "sessionUsageDetailsTitleWithLatestCache": "{{tokens}} tokens · {{cost}} · 省 {{saved}} tokens · 最近一轮缓存 {{latestCache}} · 累计缓存 {{cache}} · {{cached}} 命中 / {{miss}} 未命中 · {{turns}} 回合",
"sessionUsageLoading": "正在读取用量",
"sessionUsageUnavailable": "暂无用量",
"contextCapacityTitle": "上下文容量",
From 408e570ad90e980fea886a68a66bcaa2bc66da12 Mon Sep 17 00:00:00 2001
From: XingYu-Zhong <1736101137@qq.com>
Date: Tue, 16 Jun 2026 12:42:03 +0800
Subject: [PATCH 9/9] feat: add @floating-ui/dom dependency
---
package-lock.json | 266 +++++++++++++++++++++++++++++++++++++++++++++-
package.json | 1 +
2 files changed, 264 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b8e8f3269..8816d5eba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
+ "@floating-ui/dom": "^1.7.6",
"@larksuiteoapi/node-sdk": "^1.64.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tencent-weixin/openclaw-weixin": "2.4.3",
@@ -1915,17 +1916,26 @@
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
- "optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
@@ -2319,6 +2329,256 @@
}
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
+ "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.100",
+ "@napi-rs/canvas-darwin-arm64": "0.1.100",
+ "@napi-rs/canvas-darwin-x64": "0.1.100",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.100",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.100",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.100",
+ "@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.100"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
+ "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
+ "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
+ "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
+ "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
+ "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
+ "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
+ "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
+ "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
+ "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
+ "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.100",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
+ "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
"node_modules/@nodable/entities": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@nodable/entities/-/entities-2.1.1.tgz",
diff --git a/package.json b/package.json
index d68268909..b31f3bc0b 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
+ "@floating-ui/dom": "^1.7.6",
"@larksuiteoapi/node-sdk": "^1.64.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tencent-weixin/openclaw-weixin": "2.4.3",