feat: add native runtime customization

OpenCode and Continue customization was previously planned but unavailable, and Runtime-native capabilities were not represented consistently across providers. GoodBuddy now provides secure Main-owned customization settings, truthful native inventories, OpenCode Agents and Commands, Continue Rules and Prompts, DSH Web Search/Fetch, MCP Prompt and Resource metadata, and manual compaction where supported.

Native capabilities are presented in eleven accessible tabs with Tools separated from Commands, LSP, and Formatters. Tool source and Ask/Execute availability are explicit, external OpenCode remains connection-only, Continue reports unsupported static tool discovery instead of advertising unreachable Skills, and disposable inventory probes avoid retaining background runtimes.

Ask remains read-only at the Runtime boundary, Execute keeps the existing authorization controls, and credentials remain confined to Main.

Release note: 新增 OpenCode、Continue 与 DeepSeek Harness 的 Runtime 原生定制与真实能力清单;工具来源、Ask/Execute 可用性、上下文压缩和 MCP 元数据现在可清晰查看,同时继续保持 Main 进程凭据保护与现有权限边界。
This commit is contained in:
mesalogo
2026-08-16 17:08:46 +08:00
parent ff61b5f81d
commit b56b0f8826
55 changed files with 9059 additions and 433 deletions
+376
View File
@@ -131,6 +131,12 @@ const api: DesktopApi = {
cancel: vi.fn(async () => {}),
respondApproval: vi.fn(async () => {}),
respondQuestion: vi.fn(async () => {}),
compactConversation: vi.fn(async () => ({
provider: 'continue' as const,
strategy: 'goodbuddy-summary' as const,
compacted: false,
detail: 'No context to compact'
})),
onEvent: vi.fn((listener) => {
agentListener = listener
return () => {
@@ -494,6 +500,36 @@ const api: DesktopApi = {
installed: []
}))
},
runtimeCustomization: {
getSettings: vi.fn(async () => ({
opencode: {},
continue: { presets: [] }
})),
updateSettings: vi.fn(async (settings) => settings),
getNativeSnapshot: vi.fn(async (input) => ({
provider: input.provider,
available: true,
inventoryStatus: 'available' as const,
detail: 'Ready',
agents: [],
tools: [],
toolsSupported: input.provider !== 'continue',
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: false,
context: {
strategy: 'unsupported' as const,
manualCompact: false,
detail: 'Unsupported'
}
}))
},
context: {
selectFiles: vi.fn(async () => []),
onFileSelectionProgress: vi.fn((listener) => {
@@ -4362,6 +4398,346 @@ describe('App', () => {
}
)
it('submits native OpenCode Agent and Command controls', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'opencode',
opencodeEmbedded: true,
opencodeModelSource: { kind: 'platform' }
})
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
vi.mocked(
api.runtimeCustomization.getSettings
).mockResolvedValueOnce({
opencode: {},
continue: { presets: [] }
})
vi.mocked(
api.runtimeCustomization.getNativeSnapshot
).mockResolvedValueOnce({
provider: 'opencode',
available: true,
inventoryStatus: 'available',
detail: 'Ready',
agents: [
{
id: 'planner',
name: 'Planner',
description: 'Plan before editing',
mode: 'primary',
native: true,
hidden: false
}
],
tools: [],
toolsSupported: true,
commands: [
{
id: 'review',
name: 'review',
description: 'Review a target',
source: 'command'
}
],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: true,
context: {
strategy: 'native',
manualCompact: true,
detail: 'OpenCode native context'
}
})
render(<App />)
const agentPicker = await screen.findByRole('button', {
name: /OpenCode Runtime Agent/u
})
fireEvent.click(agentPicker)
fireEvent.click(
within(
screen.getByRole('menu', {
name: 'OpenCode Runtime Agent'
})
).getByRole('menuitemradio', { name: /Planner/u })
)
const actionPicker = screen.getByRole('button', {
name: /Runtime /u
})
fireEvent.click(actionPicker)
fireEvent.click(
within(
screen.getByRole('menu', {
name: 'Runtime 快捷操作'
})
).getByRole('menuitemradio', { name: /\/review/u })
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: 'src/main' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '/review src/main',
runtimeControl: {
provider: 'opencode',
agent: 'planner',
command: {
name: 'review',
arguments: 'src/main'
}
}
})
)
)
})
it('fills editable Continue Prompts and submits the selected preset', async () => {
const presetId = '00000000-0000-4000-8000-000000000721'
const promptId = '00000000-0000-4000-8000-000000000722'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'continue',
continueModelSource: { kind: 'platform' }
})
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: 'continue',
label: 'Continue',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
vi.mocked(
api.runtimeCustomization.getSettings
).mockResolvedValueOnce({
opencode: {},
continue: {
defaultPresetId: presetId,
presets: [
{
id: presetId,
name: '代码审查',
rules: [],
prompts: [
{
id: promptId,
name: '审查草稿',
description: '检查当前草稿',
prompt: '请审查当前草稿。'
}
]
}
]
}
})
vi.mocked(
api.runtimeCustomization.getNativeSnapshot
).mockResolvedValueOnce({
provider: 'continue',
available: true,
inventoryStatus: 'available',
detail: 'Ready',
agents: [],
tools: [],
toolsSupported: false,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [
{
id: promptId,
name: '原生同 ID Prompt',
prompt: '不应填入此原生 Prompt。',
source: 'configuration'
}
],
resources: [],
resourcesSupported: false,
context: {
strategy: 'goodbuddy-summary',
manualCompact: true,
detail: 'GoodBuddy summary context'
}
})
render(<App />)
const presetPicker = await screen.findByRole('button', {
name: /Continue .*使/u
})
fireEvent.click(presetPicker)
fireEvent.click(
within(
screen.getByRole('menu', {
name: 'Continue 配置预设'
})
).getByRole('menuitemradio', { name: //u })
)
const actionPicker = screen.getByRole('button', {
name: /Runtime /u
})
fireEvent.click(actionPicker)
fireEvent.click(
within(
screen.getByRole('menu', {
name: 'Runtime 快捷操作'
})
).getByRole('menuitemradio', { name: /稿/u })
)
const composer = screen.getByLabelText('向 GoodBuddy 提问')
expect(composer).toHaveValue('请审查当前草稿。')
fireEvent.change(composer, {
target: { value: '请审查当前草稿,并优先检查权限。' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '请审查当前草稿,并优先检查权限。',
runtimeControl: {
provider: 'continue',
presetId
}
})
)
)
})
it('manually compacts Continue context and persists the summary state', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000731'
const messages = [
{
id: '00000000-0000-4000-8000-000000000732',
role: 'user' as const,
content: '第一轮问题',
createdAt: 1_775_000_000_000,
state: 'complete' as const
},
{
id: '00000000-0000-4000-8000-000000000733',
role: 'assistant' as const,
content: '第一轮回答',
createdAt: 1_775_000_000_001,
state: 'complete' as const
}
]
const summaryState = {
coveredHistoryDigest: 'a'.repeat(64),
coveredMessageCount: 1,
coveredFromMessageId: messages[0]!.id,
coveredThroughMessageId: messages[0]!.id,
summary: '用户提出了第一轮问题。'
}
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
runtimeSelection: { provider: 'continue' },
title: 'Continue 长对话',
updatedAt: 1_775_000_000_001,
messages
}
])
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: 'continue',
label: 'Continue',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
vi.mocked(
api.runtimeCustomization.getNativeSnapshot
).mockResolvedValueOnce({
provider: 'continue',
available: true,
inventoryStatus: 'available',
detail: 'Ready',
agents: [],
tools: [],
toolsSupported: false,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: false,
context: {
strategy: 'goodbuddy-summary',
manualCompact: true,
detail: 'GoodBuddy summary context'
}
})
vi.mocked(api.agent.compactConversation).mockResolvedValueOnce({
provider: 'continue',
strategy: 'goodbuddy-summary',
compacted: true,
detail: '已压缩 Continue 对话历史',
contextCompressionState: summaryState
})
render(<App />)
fireEvent.click(
await screen.findByRole('button', {
name: '压缩上下文'
})
)
await waitFor(() =>
expect(api.agent.compactConversation).toHaveBeenCalledWith({
requestId: expect.any(String),
conversationId,
projectId,
runtimeSelection: { provider: 'continue' },
history: messages.map(({ role, content }) => ({
role,
content
})),
historyMessageIds: messages.map((message) => message.id),
contextCompressionState: undefined
})
)
expect(
await screen.findByText('已压缩 Continue 对话历史')
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({
header: expect.objectContaining({
contextCompressionState: summaryState,
contextMetrics: expect.objectContaining({
basis: 'conversation',
source: 'estimated'
})
})
})
])
)
})
it('restores the direct-model mode after leaving an Agent Runtime', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
+574 -11
View File
@@ -27,6 +27,7 @@ import {
Search,
Send,
Settings,
RefreshCw,
ShieldCheck,
PanelRightOpen,
Sparkles,
@@ -63,11 +64,20 @@ import type {
ContextFileSelectionProgress,
KnowledgeSearchReference,
KnowledgeSnapshot,
RuntimeCustomizationSettings,
RuntimeNativeSnapshot,
RuntimeControl,
RuntimeSettings
} from '../../shared/contracts'
import {
defaultContextCompressionSettings,
maximumPastedImageBytes
} from '../../shared/contracts'
import {
buildConversationSummaryHistory,
estimatedContextRequestOverheadTokens,
estimateMessagesTokens
} from '../../shared/context-window'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
@@ -1457,6 +1467,12 @@ type ComposerMenuOption<T extends string> = {
disabled?: boolean
}
type RuntimeActionChoice = ComposerMenuOption<string> & {
action?:
| { type: 'command'; id: string }
| { type: 'prompt'; prompt: string }
}
function ComposerMenuSelect<T extends string>({
ariaLabel,
className,
@@ -1763,8 +1779,26 @@ function App(): React.JSX.Element {
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [composerMenuOpen, setComposerMenuOpen] = useState<
'expert' | 'mode' | undefined
| 'expert'
| 'mode'
| 'runtime-agent'
| 'runtime-action'
| 'runtime-preset'
| undefined
>()
const [runtimeCustomization, setRuntimeCustomization] =
useState<RuntimeCustomizationSettings>()
const [runtimeNativeSnapshot, setRuntimeNativeSnapshot] =
useState<RuntimeNativeSnapshot>()
const [selectedRuntimeAgent, setSelectedRuntimeAgent] =
useState('')
const [selectedRuntimeCommand, setSelectedRuntimeCommand] =
useState('')
const [selectedContinuePreset, setSelectedContinuePreset] =
useState('')
const [runtimeContextCompacting, setRuntimeContextCompacting] =
useState(false)
const runtimeCustomizationRequestRef = useRef(0)
const runtimeMenuButtonRef = useRef<HTMLButtonElement>(null)
const runtimeMenuRef = useRef<HTMLDivElement>(null)
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
@@ -1801,6 +1835,33 @@ function App(): React.JSX.Element {
setRuntimeMenuOpen(false)
}
}, [])
const setRuntimeAgentMenuOpen = useCallback(
(open: boolean): void => {
setComposerMenuOpen(open ? 'runtime-agent' : undefined)
if (open) {
setRuntimeMenuOpen(false)
}
},
[]
)
const setRuntimeActionMenuOpen = useCallback(
(open: boolean): void => {
setComposerMenuOpen(open ? 'runtime-action' : undefined)
if (open) {
setRuntimeMenuOpen(false)
}
},
[]
)
const setRuntimePresetMenuOpen = useCallback(
(open: boolean): void => {
setComposerMenuOpen(open ? 'runtime-preset' : undefined)
if (open) {
setRuntimeMenuOpen(false)
}
},
[]
)
const assistantExpertOptions = useMemo<
ComposerMenuOption<string>[]
>(
@@ -2425,6 +2486,234 @@ function App(): React.JSX.Element {
configuredRuntimeLabels
)
: undefined
useEffect(() => {
const requestId = runtimeCustomizationRequestRef.current + 1
runtimeCustomizationRequestRef.current = requestId
queueMicrotask(() => {
if (runtimeCustomizationRequestRef.current !== requestId) {
return
}
setRuntimeNativeSnapshot(undefined)
setRuntimeCustomization(undefined)
setSelectedRuntimeAgent('')
setSelectedRuntimeCommand('')
setSelectedContinuePreset('')
})
if (
!activeRuntimeSelection ||
activeConversation?.remote ||
(activeRuntimeSelection.provider !== 'opencode' &&
activeRuntimeSelection.provider !== 'continue')
) {
return
}
const provider = activeRuntimeSelection.provider
void Promise.all([
window.goodbuddy.runtimeCustomization.getSettings(),
window.goodbuddy.runtimeCustomization.getNativeSnapshot({
provider,
...('profileId' in activeRuntimeSelection &&
activeRuntimeSelection.profileId
? { profileId: activeRuntimeSelection.profileId }
: {}),
...(activeProjectId ? { projectId: activeProjectId } : {})
})
])
.then(([customization, snapshot]) => {
if (runtimeCustomizationRequestRef.current !== requestId) {
return
}
setRuntimeCustomization(customization)
setRuntimeNativeSnapshot(snapshot)
setSelectedContinuePreset('')
})
.catch(() => {
if (runtimeCustomizationRequestRef.current === requestId) {
setRuntimeCustomization(undefined)
setRuntimeNativeSnapshot(undefined)
}
})
}, [
activeConversation?.remote,
activeProjectId,
activeRuntimeSelection,
activeRuntimeSelectionKey
])
const runtimeAgentOptions = useMemo<
ComposerMenuOption<string>[]
>(() => {
if (
activeRuntimeSelection?.provider !== 'opencode' ||
!runtimeNativeSnapshot
) {
return []
}
const configuredDefault =
runtimeCustomization?.opencode.defaultAgent
return [
{
value: '',
label: configuredDefault
? t('composer.runtimeControls.configuredAgent', {
name: configuredDefault
})
: t('composer.runtimeControls.runtimeDefaultAgent'),
description: t(
'composer.runtimeControls.runtimeDefaultAgentDescription'
)
},
...runtimeNativeSnapshot.agents
.filter(
(agent) =>
!agent.hidden &&
(agent.mode === 'primary' || agent.mode === 'all')
)
.map((agent) => ({
value: agent.id,
label: agent.name,
description:
agent.description ??
t('composer.runtimeControls.agentDescription')
}))
]
}, [
activeRuntimeSelection?.provider,
runtimeCustomization?.opencode.defaultAgent,
runtimeNativeSnapshot,
t
])
const runtimePresetOptions = useMemo<
ComposerMenuOption<string>[]
>(() => {
if (
activeRuntimeSelection?.provider !== 'continue' ||
!runtimeCustomization
) {
return []
}
return [
{
value: '',
label: t('composer.runtimeControls.noPreset'),
description: t(
'composer.runtimeControls.noPresetDescription'
)
},
...runtimeCustomization.continue.presets.map((preset) => ({
value: preset.id,
label: preset.name,
description:
preset.description ??
t('composer.runtimeControls.presetDescription', {
rules: preset.rules.filter((rule) => rule.enabled).length,
prompts: preset.prompts.length
})
}))
]
}, [
activeRuntimeSelection?.provider,
runtimeCustomization,
t
])
const runtimeActionOptions = useMemo<RuntimeActionChoice[]>(() => {
if (!runtimeNativeSnapshot) {
return []
}
const nativePrompts = runtimeNativeSnapshot.prompts
const selectedPreset =
activeRuntimeSelection?.provider === 'continue'
? runtimeCustomization?.continue.presets.find(
(preset) =>
preset.id ===
(selectedContinuePreset ||
runtimeCustomization.continue.defaultPresetId)
)
: undefined
return [
{
value: '',
label: t('composer.runtimeControls.noAction'),
description: t(
'composer.runtimeControls.noActionDescription'
)
},
...(activeRuntimeSelection?.provider === 'opencode'
? runtimeNativeSnapshot.commands.map((command) => ({
value: JSON.stringify(['command', command.id]),
label: `/${command.name}`,
description:
command.description ??
t('composer.runtimeControls.commandDescription'),
action: {
type: 'command' as const,
id: command.id
}
}))
: []),
...nativePrompts.map((prompt) => ({
value: JSON.stringify(['native-prompt', prompt.id]),
label: prompt.name,
description:
prompt.description ??
t('composer.runtimeControls.promptDescription'),
action: {
type: 'prompt' as const,
prompt: prompt.prompt
}
})),
...(selectedPreset?.prompts.map((prompt) => ({
value: JSON.stringify([
'preset-prompt',
selectedPreset.id,
prompt.id
]),
label: prompt.name,
description:
prompt.description ??
t('composer.runtimeControls.promptDescription'),
action: {
type: 'prompt' as const,
prompt: prompt.prompt
}
})) ?? [])
]
}, [
activeRuntimeSelection?.provider,
runtimeCustomization,
runtimeNativeSnapshot,
selectedContinuePreset,
t
])
const selectRuntimeAction = useCallback(
(value: string): void => {
if (!value) {
setSelectedRuntimeCommand('')
return
}
const choice = runtimeActionOptions.find(
(candidate) => candidate.value === value
)
if (choice?.action?.type === 'command') {
setSelectedRuntimeCommand(choice.action.id)
return
}
if (choice?.action?.type === 'prompt') {
setInput(choice.action.prompt)
setSelectedRuntimeCommand('')
requestAnimationFrame(() => {
resizeComposerTextarea(inputRef.current)
inputRef.current?.focus()
})
}
},
[runtimeActionOptions, setInput]
)
useEffect(() => {
if (!runtimeMenuOpen) {
return
@@ -4980,7 +5269,19 @@ function App(): React.JSX.Element {
}, [])
const submit = async (): Promise<void> => {
const prompt = input.trim()
const command =
activeRuntimeSelection?.provider === 'opencode'
? runtimeNativeSnapshot?.commands.find(
(candidate) =>
candidate.id === selectedRuntimeCommand
)
: undefined
const commandArguments = input.trim()
const prompt = command
? `/${command.name}${
commandArguments ? ` ${commandArguments}` : ''
}`
: commandArguments
if (!prompt || !activeConversation) {
return
}
@@ -5049,6 +5350,30 @@ function App(): React.JSX.Element {
notify({ tone: 'info', message: t('runtime.notSelected') })
return
}
const runtimeControlSnapshot: RuntimeControl | undefined =
runtimeSelectionSnapshot.provider === 'opencode' &&
(selectedRuntimeAgent || command)
? {
provider: 'opencode',
...(selectedRuntimeAgent
? { agent: selectedRuntimeAgent }
: {}),
...(command
? {
command: {
name: command.name,
arguments: commandArguments
}
}
: {})
}
: runtimeSelectionSnapshot.provider === 'continue' &&
selectedContinuePreset
? {
provider: 'continue',
presetId: selectedContinuePreset
}
: undefined
const selectedExpertSnapshot =
runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode
@@ -5089,7 +5414,9 @@ function App(): React.JSX.Element {
runtime.capability === 'image-generation'
? ''
: buildMemoryContext(assistantMemories)
const executionPrompt = memoryContext
const executionPrompt = command
? prompt
: memoryContext
? `${prompt}\n\n${memoryContext}`
: prompt
const assistantMessage: Message = {
@@ -5156,6 +5483,7 @@ function App(): React.JSX.Element {
conversationId,
projectId: projectIdSnapshot,
runtimeSelection: runtimeSelectionSnapshot,
runtimeControl: runtimeControlSnapshot,
expertId:
selectedExpertSnapshot && selectedExpertSnapshot !== 'team'
? selectedExpertSnapshot
@@ -5190,6 +5518,9 @@ function App(): React.JSX.Element {
for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id)
}
if (command) {
setSelectedRuntimeCommand('')
}
} catch (error) {
preparingConversations.current.delete(conversationId)
for (const attachment of attachmentSnapshot) {
@@ -5205,6 +5536,130 @@ function App(): React.JSX.Element {
}
}
const compactRuntimeContext = async (): Promise<void> => {
if (
!activeConversation ||
!activeRuntimeSelection ||
(activeRuntimeSelection.provider !== 'opencode' &&
activeRuntimeSelection.provider !== 'continue') ||
runtimeContextCompacting ||
isRunning
) {
return
}
const history = activeConversation.messages
.filter(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
if (history.length < 2) {
notify({
tone: 'info',
message: t('composer.context.nothingToCompact'),
dedupeKey: 'runtime-context-compact'
})
return
}
const requestId = crypto.randomUUID()
setRuntimeContextCompacting(true)
try {
const result =
await window.goodbuddy.agent.compactConversation({
requestId,
conversationId: activeConversation.id,
projectId: activeConversation.projectId,
runtimeSelection: activeRuntimeSelection,
history: history.map((message) => ({
role: message.role,
content: message.content
})),
historyMessageIds: history.map((message) => message.id),
contextCompressionState:
activeConversation.contextCompressionState
})
if (result.contextCompressionState) {
const state = result.contextCompressionState
const remainingHistory = history.slice(
Math.min(state.coveredMessageCount, history.length)
)
const estimatedAfterTokens =
estimatedContextRequestOverheadTokens +
estimateMessagesTokens([
...buildConversationSummaryHistory(state.summary),
...remainingHistory.map((message) => ({
role: message.role,
content: message.content
}))
])
const selectedProfileId =
'profileId' in activeRuntimeSelection
? activeRuntimeSelection.profileId
: undefined
const configuredSelection = runtimeSettings
? getRuntimeSelectionForProvider(
activeRuntimeSelection.provider,
runtimeSettings
)
: undefined
const configuredProfileId =
configuredSelection &&
'profileId' in configuredSelection
? configuredSelection.profileId
: undefined
const contextWindowTokens =
runtimeSettings?.modelProfiles.find(
(profile) =>
profile.id ===
(selectedProfileId ?? configuredProfileId)
)?.contextWindowTokens
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeConversation.id
? {
...conversation,
contextCompressionState: state,
contextMetrics: {
runtimeSelectionKey:
activeRuntimeSelectionKey,
contextTokens: estimatedAfterTokens,
effectiveTriggerTokens:
contextWindowTokens ??
runtimeSettings?.contextCompression
?.triggerTokens ??
defaultContextCompressionSettings.triggerTokens,
...(contextWindowTokens
? { contextWindowTokens }
: {}),
compressionEnabled: false,
source: 'estimated',
basis: 'conversation'
},
updatedAt: Date.now()
}
: conversation
)
)
}
notify({
tone: result.compacted ? 'success' : 'info',
message: result.detail,
dedupeKey: 'runtime-context-compact'
})
} catch (reason) {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: t('composer.context.compactFailed'),
dedupeKey: 'runtime-context-compact'
})
} finally {
setRuntimeContextCompacting(false)
}
}
const stop = async (): Promise<void> => {
const requestId = [...activeRuns.current.entries()].find(
([, run]) => run.conversationId === activeId
@@ -5715,18 +6170,17 @@ function App(): React.JSX.Element {
if (
!activeConversation ||
!runtimeSettings ||
activeRuntimeSelection?.provider !== 'model' ||
!activeRuntimeSelection ||
activeConversation.remote
) {
return undefined
}
const profile = runtimeSettings.modelProfiles.find(
(candidate) =>
candidate.id === activeRuntimeSelection.profileId
)
if (
!profile ||
profile.protocol === 'openai-images-generations'
activeRuntimeSelection.provider === 'model' &&
runtimeSettings.modelProfiles.find(
(candidate) =>
candidate.id === activeRuntimeSelection.profileId
)?.protocol === 'openai-images-generations'
) {
return undefined
}
@@ -6727,6 +7181,83 @@ function App(): React.JSX.Element {
options={assistantExpertOptions}
value={selectedExpertId}
/>
{activeRuntimeSelection?.provider === 'opencode' &&
runtimeAgentOptions.length > 1 && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.agentLabel'
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === 'runtime-agent'
}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
)}
{activeRuntimeSelection?.provider === 'continue' &&
runtimePresetOptions.length > 1 && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.presetLabel'
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === 'runtime-preset'
}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)}
{(activeRuntimeSelection?.provider === 'opencode' ||
activeRuntimeSelection?.provider === 'continue') &&
runtimeActionOptions.length > 1 && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.actionLabel'
)}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === 'runtime-action'
}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={
runtimeActionOptions.find(
(option) =>
option.action?.type === 'command' &&
option.action.id ===
selectedRuntimeCommand
)?.value ?? ''
}
/>
)}
<ComposerMenuSelect
ariaLabel={t('composer.modeLabel')}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
@@ -7014,7 +7545,15 @@ function App(): React.JSX.Element {
type="button"
aria-label={t('composer.send')}
disabled={
!input.trim() ||
(!input.trim() &&
!(
activeRuntimeSelection?.provider ===
'opencode' &&
runtimeNativeSnapshot?.commands.some(
(command) =>
command.id === selectedRuntimeCommand
)
)) ||
selectingContextFiles ||
!runtime?.available ||
runtimeSwitching ||
@@ -7133,6 +7672,30 @@ function App(): React.JSX.Element {
)}
</div>
)}
{(activeRuntimeSelection?.provider === 'opencode' ||
activeRuntimeSelection?.provider === 'continue') &&
runtimeNativeSnapshot?.context.manualCompact && (
<button
className="composer-context-compact"
disabled={runtimeContextCompacting || isRunning}
onClick={() => void compactRuntimeContext()}
title={runtimeNativeSnapshot.context.detail}
type="button"
>
{runtimeContextCompacting ? (
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{runtimeContextCompacting
? t('composer.context.compacting')
: t('composer.context.compact')}
</button>
)}
{contextError && (
<span className="composer-meta__error">
{contextError}
+91 -2
View File
@@ -1342,8 +1342,10 @@ export function McpSettingsSection({
</div>
<span className="mcp-server-card__summary">
{result
? t('mcp.builtin.toolCount', {
count: result.toolCount
? t('mcp.custom.contentCounts', {
tools: result.toolCount,
prompts: result.promptCount ?? 0,
resources: result.resourceCount ?? 0
})
: t('mcp.custom.toolsUndetected')}
<ChevronDown
@@ -1466,6 +1468,93 @@ export function McpSettingsSection({
</p>
)}
</section>
<section
aria-label={t('mcp.custom.promptsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.custom.prompts')}</strong>
<small>
{result.promptsSupported
? t('mcp.custom.promptCount', {
count: result.promptCount ?? 0
})
: t('mcp.custom.notSupported')}
</small>
</div>
{result.prompts?.length ? (
<ul>
{result.prompts.map((prompt) => (
<li key={prompt.name}>
<div>
<code>{prompt.name}</code>
</div>
{prompt.description && (
<p>{prompt.description}</p>
)}
{prompt.arguments.length > 0 && (
<small>
{t('mcp.custom.promptArguments', {
names: prompt.arguments
.map((argument) =>
argument.required
? `${argument.name}*`
: argument.name
)
.join(', ')
})}
</small>
)}
</li>
))}
</ul>
) : result.promptsSupported ? (
<p className="settings-empty">
{t('mcp.custom.noPrompts')}
</p>
) : null}
</section>
<section
aria-label={t('mcp.custom.resourcesAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.custom.resources')}</strong>
<small>
{result.resourcesSupported
? t('mcp.custom.resourceCount', {
count: result.resourceCount ?? 0
})
: t('mcp.custom.notSupported')}
</small>
</div>
{result.resources?.length ? (
<ul>
{result.resources.map((resource) => (
<li key={`${resource.uri}\0${resource.name}`}>
<div>
<strong>{resource.name}</strong>
{resource.mimeType && (
<small>{resource.mimeType}</small>
)}
</div>
<code>{resource.uri}</code>
{resource.description && (
<p>{resource.description}</p>
)}
</li>
))}
</ul>
) : result.resourcesSupported ? (
<p className="settings-empty">
{t('mcp.custom.noResources')}
</p>
) : null}
</section>
</>
) : (
<p className="settings-empty">
File diff suppressed because it is too large Load Diff
+458 -1
View File
@@ -24,6 +24,7 @@ import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel'
import { RuntimeCustomizationSection } from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
@@ -435,6 +436,50 @@ const getRuntimeExtensionSnapshot = vi.fn(
const applyRuntimeExtension = vi.fn(
async () => runtimeExtensionSnapshot
)
const runtimeCustomizationSettings = {
opencode: {},
continue: { presets: [] }
}
const getRuntimeCustomizationSettings = vi.fn<
DesktopApi['runtimeCustomization']['getSettings']
>(
async () => runtimeCustomizationSettings
)
const updateRuntimeCustomizationSettings = vi.fn<
DesktopApi['runtimeCustomization']['updateSettings']
>(
async () => runtimeCustomizationSettings
)
const getRuntimeNativeSnapshot = vi.fn<
DesktopApi['runtimeCustomization']['getNativeSnapshot']
>(async (input) => ({
provider: input.provider,
available: true,
inventoryStatus: 'available',
detail: 'Ready',
agents: [],
tools: [],
toolsSupported: input.provider !== 'continue',
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: input.provider === 'opencode',
context: {
strategy:
input.provider === 'opencode'
? ('native' as const)
: input.provider === 'continue'
? ('goodbuddy-summary' as const)
: ('unsupported' as const),
manualCompact: input.provider !== 'deepseek-harness',
detail: 'Context status'
}
}))
describe('SettingsPanel runtime files', () => {
beforeEach(async () => {
@@ -511,6 +556,11 @@ describe('SettingsPanel runtime files', () => {
getSnapshot: getRuntimeExtensionSnapshot,
apply: applyRuntimeExtension
},
runtimeCustomization: {
getSettings: getRuntimeCustomizationSettings,
updateSettings: updateRuntimeCustomizationSettings,
getNativeSnapshot: getRuntimeNativeSnapshot
},
updates: {
getSettings: getApplicationSettings,
updateSettings: updateApplicationSettings,
@@ -1775,7 +1825,7 @@ describe('SettingsPanel runtime files', () => {
screen.getByText(/自定义 Continue 可执行文件将以当前用户权限运行/)
).toBeInTheDocument()
expect(
screen.getByText(/Ask 仅可调用知识库与全局笔记读取工具/)
screen.getByText(/Ask 仅可调用当前 Runtime 允许的只读能力/)
).toBeInTheDocument()
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
expect(input).toHaveValue('')
@@ -1859,6 +1909,381 @@ describe('SettingsPanel runtime files', () => {
.not.toHaveAttribute('open')
})
it('selects a native OpenCode Agent and excludes GoodBuddy assignments from inventory', async () => {
const settings = {
opencode: { defaultAgent: 'planner' },
continue: { presets: [] }
}
getRuntimeCustomizationSettings.mockResolvedValueOnce(settings)
getRuntimeNativeSnapshot.mockResolvedValueOnce({
provider: 'opencode',
available: true,
inventoryStatus: 'available',
detail: 'OpenCode 原生能力已就绪',
agents: [
{
id: 'planner',
name: 'Planner',
mode: 'primary',
native: true,
hidden: false
},
{
id: 'reviewer',
name: 'Reviewer',
mode: 'all',
native: true,
hidden: false
},
{
id: 'explorer',
name: 'Explorer',
mode: 'subagent',
native: true,
hidden: false
}
],
tools: [
{
id: 'edit',
name: 'edit',
description: 'Edit a file',
kind: 'write',
source: 'runtime',
ask: 'blocked',
execute: 'allowed'
}
],
toolsSupported: true,
commands: [],
lsp: [],
formatters: [],
mcpServers: [
{
id: 'native-mcp',
name: 'Native MCP',
status: 'connected'
}
],
skills: [
{
id: 'native-skill',
name: 'Native Skill',
source: 'runtime'
}
],
rules: [],
prompts: [],
resources: [],
resourcesSupported: true,
context: {
strategy: 'native',
manualCompact: true,
detail: '由 OpenCode 原生管理'
}
})
updateRuntimeCustomizationSettings.mockImplementationOnce(
async (input) => input
)
render(<RuntimeCustomizationSection provider="opencode" />)
const agent = await screen.findByLabelText(/ Runtime Agent/u)
expect(agent).toHaveValue('planner')
const inventoryTabs = screen.getByRole('tablist', {
name: 'Runtime 原生能力'
})
expect(within(inventoryTabs).getAllByRole('tab')).toHaveLength(11)
const agentsTab = within(inventoryTabs).getByRole('tab', {
name: / Agents/u
})
const agentsPanel = screen.getByRole('tabpanel')
expect(agentsTab).toHaveAttribute('aria-selected', 'true')
expect(agentsTab).toHaveAttribute('aria-controls', agentsPanel.id)
expect(agentsPanel).toHaveAttribute(
'aria-labelledby',
agentsTab.id
)
expect(screen.getAllByRole('tabpanel')).toHaveLength(1)
expect(screen.getByText('Explorer')).toBeInTheDocument()
fireEvent.keyDown(agentsTab, { key: 'ArrowRight' })
const toolsTab = within(inventoryTabs).getByRole('tab', {
name: / Tools/u
})
expect(toolsTab).toHaveAttribute('aria-selected', 'true')
expect(toolsTab).toHaveFocus()
expect(screen.getByText('edit')).toBeInTheDocument()
expect(
screen.getByText(
'Edit a file · 文件修改 · Runtime 内置 · Ask:不可用 · Execute:可用'
)
).toBeInTheDocument()
const commandsTab = within(inventoryTabs).getByRole('tab', {
name: /Commands/u
})
fireEvent.click(commandsTab)
expect(commandsTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('未发现')).toBeInTheDocument()
expect(
screen.getByText(
'当前 Runtime 未报告此类别中的可用原生能力。'
)
).toBeInTheDocument()
expect(screen.queryByText('Native MCP')).not.toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / MCP/u
})
)
expect(screen.getByText('Native MCP')).toBeInTheDocument()
expect(screen.queryByText('Explorer')).not.toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / Skills/u
})
)
expect(screen.getByText('Native Skill')).toBeInTheDocument()
expect(screen.queryByText('Native MCP')).not.toBeInTheDocument()
expect(screen.queryByText('GoodBuddy MCP')).not.toBeInTheDocument()
fireEvent.change(agent, { target: { value: 'reviewer' } })
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith({
opencode: { defaultAgent: 'reviewer' },
continue: { presets: [] }
})
)
})
it('distinguishes external OpenCode connectivity from readable native inventory', async () => {
const fallbackSnapshot = await getRuntimeNativeSnapshot({
provider: 'opencode'
})
getRuntimeNativeSnapshot.mockResolvedValueOnce({
...fallbackSnapshot,
provider: 'opencode',
available: true,
inventoryStatus: 'connection-only',
detail: 'External OpenCode connection only',
toolsSupported: false
})
render(<RuntimeCustomizationSection provider="opencode" />)
expect(
await screen.findByText('仅确认 Runtime 连接')
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(
'External OpenCode connection only'
)
expect(
screen.queryByText('Runtime 原生能力可用')
).not.toBeInTheDocument()
})
it('edits Continue presets, Rules, Prompt metadata, and merged native Rules', async () => {
const presetId = '00000000-0000-4000-8000-000000000701'
const ruleId = '00000000-0000-4000-8000-000000000702'
const promptId = '00000000-0000-4000-8000-000000000703'
const settings = {
opencode: {},
continue: {
defaultPresetId: presetId,
presets: [
{
id: presetId,
name: '代码审查',
rules: [
{
id: ruleId,
name: '安全优先',
content: '先检查安全边界。',
enabled: true
}
],
prompts: [
{
id: promptId,
name: '审查变更',
prompt: '请审查当前变更。'
}
]
}
]
}
}
getRuntimeCustomizationSettings.mockResolvedValueOnce(settings)
getRuntimeNativeSnapshot.mockResolvedValueOnce({
provider: 'continue',
available: true,
inventoryStatus: 'available',
detail: 'Continue 原生能力已就绪',
agents: [],
tools: [],
toolsSupported: false,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [
{
id: 'configuration-rule-1',
name: 'Native Rule',
content: '遵循原生规则。',
source: 'configuration'
}
],
prompts: [],
resources: [],
resourcesSupported: false,
context: {
strategy: 'goodbuddy-summary',
manualCompact: true,
detail: '由 GoodBuddy 摘要压缩'
}
})
updateRuntimeCustomizationSettings.mockImplementationOnce(
async (input) => input
)
render(<RuntimeCustomizationSection provider="continue" />)
expect(
await screen.findByLabelText('默认配置预设')
).toHaveValue(presetId)
expect(
screen.getByText('查看最终合并的 2 条 Rule')
).toBeInTheDocument()
const inventoryTabs = screen.getByRole('tablist', {
name: 'Runtime 原生能力'
})
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / Tools/u
})
)
expect(
screen.getByText('当前 Runtime 不支持静态发现原生 Tools')
).toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: / Skills/u
})
)
expect(screen.getByText('未发现')).toBeInTheDocument()
fireEvent.click(
within(inventoryTabs).getByRole('tab', {
name: /MCP Resources/u
})
)
expect(
screen.getByText('当前 Runtime 不支持')
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('安全优先 内容'), {
target: { value: '先检查权限和数据边界。' }
})
fireEvent.change(screen.getByLabelText('审查变更 说明'), {
target: { value: '用于提交前检查' }
})
fireEvent.change(screen.getByLabelText('审查变更 内容'), {
target: { value: '请审查当前提交。' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
continue: expect.objectContaining({
presets: [
expect.objectContaining({
rules: [
expect.objectContaining({
content: '先检查权限和数据边界。'
})
],
prompts: [
expect.objectContaining({
description: '用于提交前检查',
prompt: '请审查当前提交。'
})
]
})
]
})
})
)
)
})
it('preserves Continue drafts across inventory refreshes and failed-save retries', async () => {
const presetId = '00000000-0000-4000-8000-000000000704'
getRuntimeCustomizationSettings.mockResolvedValueOnce({
opencode: {},
continue: {
presets: [
{
id: presetId,
name: 'Draft preset',
rules: [],
prompts: []
}
]
}
})
updateRuntimeCustomizationSettings
.mockRejectedValueOnce(new Error('保存失败'))
.mockImplementationOnce(async (input) => input)
render(<RuntimeCustomizationSection provider="continue" />)
const nameInput = await screen.findByLabelText('预设名称')
fireEvent.change(nameInput, {
target: { value: 'Unsaved draft' }
})
fireEvent.click(
screen.getByRole('button', {
name: '刷新 Runtime 原生能力'
})
)
await waitFor(() =>
expect(getRuntimeNativeSnapshot).toHaveBeenCalledTimes(2)
)
expect(nameInput).toHaveValue('Unsaved draft')
expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce()
fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' })
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'保存失败'
)
fireEvent.click(
screen.getByRole('button', { name: '重试' })
)
await waitFor(() =>
expect(updateRuntimeCustomizationSettings).toHaveBeenCalledTimes(2)
)
expect(
updateRuntimeCustomizationSettings
).toHaveBeenLastCalledWith(
expect.objectContaining({
continue: expect.objectContaining({
presets: [
expect.objectContaining({ name: 'Unsaved draft' })
]
})
})
)
expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce()
})
it('opens only saved Runtime-owned config files or fixed config directories', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
@@ -3289,6 +3714,31 @@ describe('SettingsPanel runtime files', () => {
name: 'team_search',
description: '搜索团队资料'
}
],
promptsSupported: true,
promptCount: 1,
prompts: [
{
name: 'prepare_review',
description: '准备审查 Prompt',
arguments: [
{
name: 'scope',
description: '审查范围',
required: true
}
]
}
],
resourcesSupported: true,
resourceCount: 1,
resources: [
{
uri: 'mcp://team/review-guide',
name: 'Review Guide',
description: '团队审查指南',
mimeType: 'text/markdown'
}
]
})
render(
@@ -3316,6 +3766,13 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.getByText('服务端支持动态更新工具列表')
).toBeInTheDocument()
expect(screen.getByText('prepare_review')).toBeInTheDocument()
expect(screen.getByText('参数:scope** 必填)')).toBeInTheDocument()
expect(screen.getByText('Review Guide')).toBeInTheDocument()
expect(
screen.getByText('mcp://team/review-guide')
).toBeInTheDocument()
expect(screen.getByText('text/markdown')).toBeInTheDocument()
expect(serverToggle).toHaveAttribute('aria-expanded', 'true')
expect(
screen.getByRole('region', { name: '团队工具服务 工具' })
+34
View File
@@ -46,6 +46,7 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import { RuntimeCustomizationSection } from './RuntimeCustomizationSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
@@ -1888,6 +1889,17 @@ export function SettingsPanel({
</details>
</div>
)}
{agentRuntimeType === 'opencode' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
opencodeModelSource.kind === 'profile'
? opencodeModelSource.profileId
: undefined
}
provider="opencode"
/>
)}
{agentRuntimeType === 'continue' && (
<div className="settings-section">
@@ -2096,6 +2108,17 @@ export function SettingsPanel({
</details>
</div>
)}
{agentRuntimeType === 'continue' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
continueModelSource.kind === 'profile'
? continueModelSource.profileId
: undefined
}
provider="continue"
/>
)}
{agentRuntimeType === 'deepseek-harness' && (
<div className="settings-section">
<div className="settings-section__title">
@@ -2194,6 +2217,17 @@ export function SettingsPanel({
</details>
</div>
)}
{agentRuntimeType === 'deepseek-harness' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
deepseekHarnessModelSource.kind === 'profile'
? deepseekHarnessModelSource.profileId
: undefined
}
provider="deepseek-harness"
/>
)}
{agentRuntimeType === 'deepseek-harness' && (
<DshMarketplaceSection onNotify={onNotify} />
)}
+24 -1
View File
@@ -321,6 +321,25 @@ export const app = {
settings: 'Conversation settings',
expertLabel: 'Expert role',
modeLabel: 'Work mode',
runtimeControls: {
agentLabel: 'OpenCode Runtime Agent',
presetLabel: 'Continue configuration preset',
actionLabel: 'Runtime shortcut',
configuredAgent: 'Default · {{name}}',
runtimeDefaultAgent: 'OpenCode default Agent',
runtimeDefaultAgentDescription:
'Use the default Runtime Agent saved in settings',
agentDescription: 'Native OpenCode Runtime Agent',
noPreset: 'Use the settings default',
noPresetDescription:
'Apply the default Continue preset from Runtime settings',
presetDescription:
'{{rules}} enabled Rules · {{prompts}} Prompts',
noAction: 'Runtime shortcuts',
noActionDescription: 'Send the composer input directly',
commandDescription: 'Run an OpenCode Command',
promptDescription: 'Insert a Prompt and keep editing'
},
stop: 'Stop generating',
send: 'Send',
sendTitle: 'Send message',
@@ -343,7 +362,11 @@ export const app = {
conversationThresholdUsage:
'Estimated compressed conversation ≈{{used}} · Compression at {{total}}',
progressLabel: 'Current context usage',
compressionTrigger: 'Automatic compression at ≈{{tokens}}'
compressionTrigger: 'Automatic compression at ≈{{tokens}}',
compact: 'Compact context',
compacting: 'Compacting…',
nothingToCompact: 'There is no earlier conversation history to compact',
compactFailed: 'Context compaction failed'
},
experts: {
general: 'General assistant',
@@ -308,6 +308,8 @@ export const integrations = {
dynamicToolsUnsupported:
'Server does not advertise dynamic tool-list updates',
toolsUndetected: 'Tools not checked',
contentCounts:
'{{tools}} tools · {{prompts}} Prompts · {{resources}} Resources',
testAriaLabel: 'Test {{name}}',
test: 'Test',
editAriaLabel: 'Edit {{name}}',
@@ -318,7 +320,18 @@ export const integrations = {
assignmentSeparator: ', ',
none: 'None',
noTools: 'The server exposes no available tools.',
testHelp: 'Select Test to connect to the server and load its tool list.'
prompts: 'MCP Prompts',
promptCount: '{{count}} Prompts',
promptsAriaLabel: '{{name}} Prompts',
promptArguments: 'Arguments: {{names}} (* required)',
noPrompts: 'The server exposes no Prompts.',
resources: 'MCP Resources',
resourceCount: '{{count}} Resources',
resourcesAriaLabel: '{{name}} Resources',
noResources: 'The server exposes no Resources.',
notSupported: 'Not advertised by the server',
testHelp:
'Select Test to load the server Tools, Prompts, and Resources metadata.'
}
}
} satisfies TranslationShape<typeof chineseIntegrations>
+133 -3
View File
@@ -194,7 +194,137 @@ export const settings = {
followGoodBuddy: 'Follow GoodBuddy · {{name}} ({{model}})',
noCompatibleModel: 'No compatible text model is configured',
permissions:
'Choose Ask or Execute in a conversation. Ask can only use read-only knowledge base and global note tools. Execute can use enabled tools and note-writing tools, and records tool calls in Activity.',
'Choose Ask or Execute in a conversation. Ask can use only read-only capabilities allowed by the current Runtime. Execute can use enabled tools, and records tool calls in Activity.',
customization: {
title: 'Native Runtime customization',
description:
'Manage capabilities supplied by this Runtime. The inventory excludes Skills assigned by GoodBuddy and temporary GoodBuddy MCP servers.',
refresh: 'Refresh native Runtime capabilities',
retry: 'Retry',
loading: 'Loading native Runtime capabilities…',
save: 'Save Runtime customization',
saving: 'Saving…',
saved: 'Runtime customization saved',
enabled: 'Enabled',
disabled: 'Disabled',
errors: {
load: 'Could not load native Runtime capabilities',
save: 'Could not save Runtime customization'
},
inventory: {
tabsAriaLabel: 'Native Runtime capabilities',
nativeOnly:
'Only Runtime-native configuration and plugin capabilities are shown. GoodBuddy assignments are excluded.',
status: {
available: 'Native Runtime capabilities available',
partial: 'Native Runtime capabilities partially available',
unavailable: 'Native Runtime capabilities unavailable',
'connection-only': 'Runtime connection only',
unsupported: 'Native inventory is not supported'
},
agents: 'Native Agents',
tools: 'Native Tools',
skills: 'Native Skills',
mcp: 'Native MCP',
commands: 'Commands',
rules: 'Native Rules',
prompts: 'Prompt templates',
resources: 'MCP Resources',
lsp: 'LSP status',
formatters: 'Formatter status',
empty: 'None detected',
emptyDescription:
'The current Runtime did not report any native capabilities in this category.',
unsupported: 'Not supported by this Runtime',
toolsUnsupported:
'This Runtime does not support static discovery of native Tools',
toolModes: 'Ask: {{ask}} · Execute: {{execute}}',
toolKind: {
read: 'Read',
write: 'File modification',
shell: 'Command execution',
network: 'Network access',
agent: 'Agent orchestration',
interaction: 'User interaction',
other: 'Other'
},
toolSource: {
runtime: 'Runtime built-in',
plugin: 'Runtime plugin',
mcp: 'MCP',
skill: 'Skill',
unknown: 'Unknown source'
},
toolAccess: {
allowed: 'Available',
blocked: 'Unavailable',
conditional: 'Request-dependent'
}
},
agentMode: {
primary: 'Primary Agent',
subagent: 'Subagent',
all: 'Primary / subagent'
},
status: {
connected: 'Connected',
disabled: 'Disabled',
failed: 'Failed',
'needs-auth': 'Authentication required',
unsupported: 'Unsupported',
unknown: 'Unknown',
error: 'Error',
'not-loaded': 'Not loaded'
},
commandSource: {
command: 'Runtime command',
mcp: 'MCP prompt',
skill: 'Skill command',
runtime: 'Runtime'
},
context: {
title: 'Context and compaction'
},
opencode: {
defaultAgent: 'Default Runtime Agent',
runtimeDefault: 'Let OpenCode choose',
agentDescription:
'Applies only to GoodBuddy-managed local OpenCode. A conversation can still select a different Agent.'
},
continue: {
editPreset: 'Edit configuration preset',
noPresets: 'No presets',
addPreset: 'Add preset',
removePreset: 'Delete preset',
defaultPreset: 'Default configuration preset',
noDefaultPreset: 'Do not apply a GoodBuddy preset',
newPreset: 'New Continue preset',
presetName: 'Preset name',
presetDescription: 'Preset description',
rules: 'Rules',
addRule: 'Add Rule',
newRule: 'New Rule',
newRuleContent:
'Enter a rule that should apply to every request.',
ruleName: 'Rule name',
ruleContent: '{{name}} content',
removeRule: 'Delete Rule {{name}}',
prompts: 'Prompt templates',
addPrompt: 'Add Prompt',
newPrompt: 'New Prompt',
newPromptContent:
'Enter a Prompt that can be used from the chat composer.',
promptName: 'Prompt name',
promptDescription: '{{name}} description',
promptDescriptionPlaceholder:
'Optional description of when to use this Prompt',
promptContent: '{{name}} content',
removePrompt: 'Delete Prompt {{name}}',
mergedRules: 'View {{count}} merged Rules',
emptyPreset:
'Add a preset to manage Continue Rules and Prompt templates.'
}
},
advanced: 'Advanced settings',
sourceLegend: 'Model configuration source',
followRecommended: 'Follow the GoodBuddy model (recommended)',
@@ -247,7 +377,7 @@ export const settings = {
title: 'DeepSeek Harness',
previewDescription: 'Developer preview · OpenAI-compatible',
description:
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask limits model tool calls to read-only tools, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask can call native read/skill plus enabled Web Search/Fetch, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
managedSource:
'Administrator-provided OpenAI-compatible connection',
connection: 'OpenAI-compatible model connection',
@@ -268,7 +398,7 @@ export const settings = {
disabledDescription:
'The plugin marketplace is off by default. Turn it on to connect to the public npm catalog and show its management interface. Turning off the marketplace does not disable or uninstall existing plugins.',
permissionNotice:
'Third-party install scripts, initialization code, and tools run with your user permissions. Ask limits the model from calling non-read-only tools, but cannot limit plugin initialization code. Execute can call every tool from enabled plugins. Install only packages you trust.',
'Third-party install scripts, initialization code, and tools run with your user permissions. Ask cannot call third-party plugin tools, but it cannot limit plugin initialization code. Execute can call every tool from enabled plugins. Install only packages you trust.',
refresh: 'Refresh',
refreshAria: 'Refresh the DSH plugin marketplace',
searchLabel: 'Search plugins',
+22 -1
View File
@@ -313,6 +313,23 @@ export const app = {
settings: '对话设置',
expertLabel: '专家角色',
modeLabel: '工作模式',
runtimeControls: {
agentLabel: 'OpenCode Runtime Agent',
presetLabel: 'Continue 配置预设',
actionLabel: 'Runtime 快捷操作',
configuredAgent: '默认 · {{name}}',
runtimeDefaultAgent: 'OpenCode 默认 Agent',
runtimeDefaultAgentDescription: '使用设置中保存的默认 Runtime Agent',
agentDescription: 'OpenCode 原生 Runtime Agent',
noPreset: '使用设置默认预设',
noPresetDescription: '按 Runtime 设置应用默认 Continue 预设',
presetDescription:
'{{rules}} 条启用 Rule · {{prompts}} 个 Prompt',
noAction: 'Runtime 快捷操作',
noActionDescription: '直接发送输入内容',
commandDescription: '执行 OpenCode Command',
promptDescription: '填入 Prompt 后可继续编辑'
},
stop: '停止生成',
send: '发送',
sendTitle: '发送消息',
@@ -334,7 +351,11 @@ export const app = {
conversationThresholdUsage:
'压缩后对话估算 ≈{{used}} · 压缩线 {{total}}',
progressLabel: '当前上下文使用量',
compressionTrigger: '自动压缩线:≈{{tokens}}'
compressionTrigger: '自动压缩线:≈{{tokens}}',
compact: '压缩上下文',
compacting: '正在压缩…',
nothingToCompact: '当前没有可压缩的较早对话历史',
compactFailed: '上下文压缩失败'
},
experts: {
general: '通用助手',
@@ -292,6 +292,8 @@ export const integrations = {
dynamicToolsSupported: '服务端支持动态更新工具列表',
dynamicToolsUnsupported: '服务端未声明支持动态更新工具列表',
toolsUndetected: '工具未检测',
contentCounts:
'{{tools}} 个工具 · {{prompts}} 个 Prompt · {{resources}} 个 Resource',
testAriaLabel: '测试 {{name}}',
test: '测试',
editAriaLabel: '编辑 {{name}}',
@@ -302,7 +304,18 @@ export const integrations = {
assignmentSeparator: '、',
none: '无',
noTools: '服务器未公开可用工具。',
testHelp: '点击“测试”连接服务器并读取其工具列表。'
prompts: 'MCP Prompts',
promptCount: '{{count}} 个 Prompt',
promptsAriaLabel: '{{name}} Prompts',
promptArguments: '参数:{{names}}* 必填)',
noPrompts: '服务器未公开 Prompt。',
resources: 'MCP Resources',
resourceCount: '{{count}} 个 Resource',
resourcesAriaLabel: '{{name}} Resources',
noResources: '服务器未公开 Resource。',
notSupported: '服务端未声明支持',
testHelp:
'点击“测试”连接服务器并读取其 Tools、Prompts 与 Resources 元数据。'
}
}
} as const
+127 -3
View File
@@ -173,7 +173,131 @@ export const settings = {
followGoodBuddy: '跟随 GoodBuddy · {{name}}{{model}}',
noCompatibleModel: '尚未配置兼容的文本模型',
permissions:
'对话时可选择 Ask 或 Execute。Ask 仅可调用知识库与全局笔记读取工具Execute 可调用已启用工具及笔记写入工具,调用过程会记录到活动。',
'对话时可选择 Ask 或 Execute。Ask 仅可调用当前 Runtime 允许的只读能力;Execute 可调用已启用工具,调用过程会记录到活动。',
customization: {
title: 'Runtime 原生定制',
description:
'管理当前 Runtime 自己提供的能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。',
refresh: '刷新 Runtime 原生能力',
retry: '重试',
loading: '正在读取 Runtime 原生能力…',
save: '保存 Runtime 定制',
saving: '正在保存…',
saved: '已保存 Runtime 定制设置',
enabled: '已启用',
disabled: '已停用',
errors: {
load: '读取 Runtime 原生能力失败',
save: '保存 Runtime 定制失败'
},
inventory: {
tabsAriaLabel: 'Runtime 原生能力',
nativeOnly:
'这里只显示 Runtime 原生配置与插件能力,不显示 GoodBuddy 分配内容。',
status: {
available: 'Runtime 原生能力可用',
partial: 'Runtime 原生能力部分可用',
unavailable: 'Runtime 原生能力不可用',
'connection-only': '仅确认 Runtime 连接',
unsupported: 'Runtime 不支持原生能力清单'
},
agents: '原生 Agents',
tools: '原生 Tools',
skills: '原生 Skills',
mcp: '原生 MCP',
commands: 'Commands',
rules: '原生 Rules',
prompts: 'Prompt 模板',
resources: 'MCP Resources',
lsp: 'LSP 状态',
formatters: 'Formatter 状态',
empty: '未发现',
emptyDescription: '当前 Runtime 未报告此类别中的可用原生能力。',
unsupported: '当前 Runtime 不支持',
toolsUnsupported: '当前 Runtime 不支持静态发现原生 Tools',
toolModes: 'Ask{{ask}} · Execute{{execute}}',
toolKind: {
read: '读取',
write: '文件修改',
shell: '命令执行',
network: '网络访问',
agent: 'Agent 编排',
interaction: '用户交互',
other: '其他'
},
toolSource: {
runtime: 'Runtime 内置',
plugin: 'Runtime 插件',
mcp: 'MCP',
skill: 'Skill',
unknown: '来源未知'
},
toolAccess: {
allowed: '可用',
blocked: '不可用',
conditional: '按当前请求可用'
}
},
agentMode: {
primary: '主 Agent',
subagent: '子 Agent',
all: '主 Agent / 子 Agent'
},
status: {
connected: '已连接',
disabled: '已停用',
failed: '失败',
'needs-auth': '需要认证',
unsupported: '不支持',
unknown: '未知',
error: '错误',
'not-loaded': '未加载'
},
commandSource: {
command: 'Runtime Command',
mcp: 'MCP Prompt',
skill: 'Skill Command',
runtime: 'Runtime'
},
context: {
title: '上下文与压缩'
},
opencode: {
defaultAgent: '默认 Runtime Agent',
runtimeDefault: '由 OpenCode 选择',
agentDescription:
'只影响 GoodBuddy 管理的本机 OpenCode;聊天中仍可为当前对话单独选择。'
},
continue: {
editPreset: '编辑配置预设',
noPresets: '尚无预设',
addPreset: '添加预设',
removePreset: '删除预设',
defaultPreset: '默认配置预设',
noDefaultPreset: '不应用 GoodBuddy 预设',
newPreset: '新 Continue 预设',
presetName: '预设名称',
presetDescription: '预设说明',
rules: 'Rules',
addRule: '添加 Rule',
newRule: '新 Rule',
newRuleContent: '在此输入每次请求都应遵守的规则。',
ruleName: 'Rule 名称',
ruleContent: '{{name}} 内容',
removeRule: '删除 Rule {{name}}',
prompts: 'Prompt 模板',
addPrompt: '添加 Prompt',
newPrompt: '新 Prompt',
newPromptContent: '在此输入可从聊天输入区调用的 Prompt。',
promptName: 'Prompt 名称',
promptDescription: '{{name}} 说明',
promptDescriptionPlaceholder: '可选,说明此 Prompt 的用途',
promptContent: '{{name}} 内容',
removePrompt: '删除 Prompt {{name}}',
mergedRules: '查看最终合并的 {{count}} 条 Rule',
emptyPreset: '添加一个预设后即可管理 Rules 与 Prompt 模板。'
}
},
advanced: '高级设置',
sourceLegend: '模型配置来源',
followRecommended: '跟随 GoodBuddy 模型(推荐)',
@@ -223,7 +347,7 @@ export const settings = {
title: 'DeepSeek Harness',
previewDescription: '开发者预览 · OpenAI 兼容',
description:
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 仅允许模型调用只读工具,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 可调用 Harness 原生 read/skill 与已启用的网页搜索/抓取,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
managedSource: '管理员预置的 OpenAI 兼容连接',
connection: 'OpenAI 兼容模型连接',
connectionPlaceholder: '选择 OpenAI 兼容模型连接',
@@ -242,7 +366,7 @@ export const settings = {
disabledDescription:
'插件市场默认关闭。开启后才会连接公共 npm 目录并显示管理界面;关闭市场不会停用或卸载已有插件。',
permissionNotice:
'第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行。Ask 只限制模型调用非只读工具,无法限制插件初始化代码;Execute 可调用已启用插件提供的全部工具。请仅安装可信包。',
'第三方插件的安装脚本、初始化代码及工具均以当前用户权限运行。Ask 不允许模型调用第三方插件工具,无法限制插件初始化代码;Execute 可调用已启用插件提供的全部工具。请仅安装可信包。',
refresh: '刷新',
refreshAria: '刷新 DSH 插件市场',
searchLabel: '搜索插件',
+244
View File
@@ -4481,6 +4481,11 @@ button > svg {
width: 108px;
}
.composer-picker--runtime > .model-button,
.composer-picker--runtime-action > .model-button {
width: 138px;
}
.composer-picker--ask svg {
color: var(--accent);
}
@@ -4688,6 +4693,25 @@ button > svg {
white-space: nowrap;
}
.composer-context-compact {
display: inline-flex;
min-height: 26px;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
align-items: center;
background: var(--surface-raised);
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
}
.composer-context-compact:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent);
}
.composer-meta kbd {
padding: 1px var(--space-1);
border: 1px solid var(--border-default);
@@ -5262,6 +5286,213 @@ button > svg {
color: var(--text-secondary) !important;
}
.runtime-customization-section {
gap: var(--space-4);
}
.runtime-customization-section__header,
.runtime-customization-section__header > div,
.runtime-customization-item__header,
.runtime-preset-editor__section > div:first-child {
display: flex;
align-items: center;
}
.runtime-customization-section__header,
.runtime-preset-editor__section > div:first-child {
justify-content: space-between;
gap: var(--space-3);
}
.runtime-customization-section__header > div {
min-width: 0;
}
.runtime-customization-section__header > div {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.runtime-customization-section__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.runtime-customization-editor,
.runtime-native-inventory,
.runtime-preset-editor,
.runtime-preset-editor__section {
display: grid;
gap: var(--space-3);
}
.runtime-customization-editor {
min-width: 0;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-subtle);
}
.runtime-native-inventory__status {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
gap: var(--space-1);
}
.runtime-native-inventory__status--available {
border-color: color-mix(in srgb, var(--success) 35%, transparent);
background: var(--success-subtle);
}
.runtime-native-inventory__status--unavailable {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.runtime-native-inventory__status--partial,
.runtime-native-inventory__status--connection-only,
.runtime-native-inventory__status--unsupported {
border-color: var(--warning-border);
background: var(--warning-subtle);
}
.runtime-native-inventory__status small {
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.runtime-customization-section__actions {
justify-content: flex-end;
}
.runtime-native-inventory > .page-tabs {
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--border-subtle);
}
.runtime-native-inventory__panel,
.runtime-customization-item,
.runtime-merged-rules {
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.runtime-native-inventory__panel ul,
.runtime-merged-rules ol {
display: grid;
margin: 0;
padding: 0;
gap: var(--space-2);
list-style: none;
}
.runtime-native-inventory__panel li {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.runtime-native-inventory__panel li + li {
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.runtime-native-inventory__panel li small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
overflow-wrap: anywhere;
}
.runtime-preset-toolbar {
display: grid;
grid-template-columns: minmax(180px, 1fr) max-content max-content;
align-items: end;
gap: var(--space-2);
}
.runtime-preset-toolbar > button,
.runtime-preset-editor__section > div:first-child > button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.runtime-preset-editor {
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.runtime-preset-editor__section {
padding-top: var(--space-2);
}
.runtime-customization-item {
display: grid;
gap: var(--space-2);
}
.runtime-customization-item__header {
gap: var(--space-2);
}
.runtime-customization-item__header > input {
min-width: 0;
flex: 1;
}
.runtime-customization-item > input,
.runtime-customization-item textarea {
width: 100%;
}
.runtime-customization-item textarea {
min-height: 88px;
resize: vertical;
}
.runtime-customization-item .toggle-row--compact {
flex: 0 0 auto;
padding: 0;
border: 0;
background: transparent;
}
.runtime-merged-rules summary {
cursor: pointer;
font-weight: 650;
}
.runtime-merged-rules li {
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.runtime-merged-rules pre {
max-height: 240px;
margin: var(--space-2) 0 0;
padding: var(--space-2);
overflow: auto;
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-secondary);
font: inherit;
font-size: var(--font-caption);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.runtime-extension-marketplace {
min-width: 0;
}
@@ -10840,6 +11071,19 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
.runtime-preset-toolbar {
grid-template-columns: 1fr;
}
.runtime-preset-toolbar > button {
width: 100%;
}
.runtime-customization-section__error {
align-items: flex-start;
flex-direction: column;
}
.model-connection-manager {
grid-template-columns: 1fr;
}