Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 00771491b2f122b80a6e990f49cf8d2b9d212cc0
|
||||
README.zh.md: da0f7d25febcff9436e5b32640ed19746115227a
|
||||
README.md: 0b9c89c7bd0e17eb3afcfabfee4ed53e3a132876
|
||||
README.zh.md: 12478b0f2a778e912793c3906e343f238ceb71a6
|
||||
|
||||
@@ -139,7 +139,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -1044,10 +1044,12 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
// Skill listing is async while `createTuiChat` is synchronous, so the
|
||||
// completions rebuild once the catalog resolves. Disabled-for-model skills
|
||||
// are absent from `list()`, so they never appear as completions; a user can
|
||||
// TUI retains the last complete catalog for synchronous editor completion
|
||||
// and refreshes it after registry invalidation. Disabled-for-model skills are
|
||||
// absent from snapshots, so they never appear as completions; a user can
|
||||
// still invoke one by typing its exact name.
|
||||
let skillCommands: SlashCommand[] = []
|
||||
let skillCommandScan = 0
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
const base = new CombinedAutocompleteProvider(
|
||||
[
|
||||
@@ -1068,24 +1070,36 @@ export function createTuiChat(
|
||||
agent,
|
||||
))
|
||||
}
|
||||
const refreshVisibleSlashAutocomplete = (): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col)
|
||||
if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) {
|
||||
// pi-tui's provider setter closes an existing menu but does not query
|
||||
// the replacement for the current draft. Tab in a slash-name context
|
||||
// only requests suggestions, so it refreshes without editing the text.
|
||||
editor.handleInput('\t')
|
||||
}
|
||||
}
|
||||
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
|
||||
refreshCommandAutocomplete()
|
||||
|
||||
const loadSkillCommands = (service: SkillService): void => {
|
||||
service.list({ cwd, signal: skillAbort.signal }).then(
|
||||
(summaries) => {
|
||||
if (disposed || summaries.length === 0) return
|
||||
const refreshSkillCommands = (service: SkillService): void => {
|
||||
const scan = ++skillCommandScan
|
||||
service.snapshot({ cwd, signal: skillAbort.signal }).then(
|
||||
(snapshot) => {
|
||||
if (disposed || scan !== skillCommandScan || !snapshot.complete) return
|
||||
// The argument-hint slot shows in the menu but is never inserted on
|
||||
// selection, so it carries the skill's scope instead of an
|
||||
// instructions placeholder. `SkillSource` is open-ended; every
|
||||
// non-project source (user, custom, bundled, runtime, …) collapses
|
||||
// to `(user)`.
|
||||
skillCommands = summaries.map(skill => ({
|
||||
skillCommands = snapshot.skills.map(skill => ({
|
||||
name: `skill:${skill.name}`,
|
||||
description: skill.description,
|
||||
argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)',
|
||||
}))
|
||||
refreshCommandAutocomplete()
|
||||
refreshVisibleSlashAutocomplete()
|
||||
requestRender()
|
||||
},
|
||||
() => {
|
||||
@@ -1094,7 +1108,10 @@ export function createTuiChat(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (skills !== undefined) loadSkillCommands(skills)
|
||||
const disposeSkillChanges = skills === undefined
|
||||
? () => {}
|
||||
: ctx.on('skills/change', () => { refreshSkillCommands(skills) })
|
||||
if (skills !== undefined) refreshSkillCommands(skills)
|
||||
|
||||
// The agent scope is minted by agent-loop and intentionally inherits only
|
||||
// that core plugin's dependencies. A child command producer declares its own
|
||||
@@ -1488,6 +1505,7 @@ export function createTuiChat(
|
||||
fileSearch.dispose()
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
disposeSkillChanges()
|
||||
disposePromptChanges()
|
||||
for (const value of promptValues) value.dispose()
|
||||
stopBannerReveal()
|
||||
|
||||
@@ -18,7 +18,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider } from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -3890,6 +3890,133 @@ describe('skill slash command', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('refreshes slash completions after runtime skill additions and complete removals', async () => {
|
||||
let skills: SkillService | undefined
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
await ctx.plugin(SkillService)
|
||||
skills = ctx.get('skills')
|
||||
},
|
||||
})
|
||||
if (skills === undefined) throw new Error('skills service not mounted')
|
||||
|
||||
result.terminal.send('/skill:dynamic')
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
const disposeSkill = skills.register({
|
||||
name: 'dynamic-skill',
|
||||
description: 'DYNAMIC_COMPLETION_MARKER',
|
||||
source: 'runtime',
|
||||
content: 'Dynamic body.',
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
|
||||
result.terminal.send('\x03')
|
||||
disposeSkill()
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:dynamic')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('retains last-good slash completions across incomplete snapshots', async () => {
|
||||
let skills: SkillService | undefined
|
||||
let provider: SkillProvider | undefined
|
||||
let invalidate = (): void => {}
|
||||
let fail = false
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
await ctx.plugin(SkillService)
|
||||
skills = ctx.get('skills')
|
||||
provider = {
|
||||
name: 'flaky-completion',
|
||||
async list() {
|
||||
if (fail) throw new Error('transient completion failure')
|
||||
return [{
|
||||
name: 'stable-skill',
|
||||
description: 'STABLE_COMPLETION_MARKER',
|
||||
source: 'test',
|
||||
provider: 'flaky-completion',
|
||||
rank: 1,
|
||||
locator: 'stable',
|
||||
}]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
skills?.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider as SkillProvider
|
||||
})
|
||||
},
|
||||
})
|
||||
if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted')
|
||||
|
||||
fail = true
|
||||
invalidate()
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:stable')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => {
|
||||
const pendingSnapshots: Array<PromiseWithResolvers<SkillCatalogSnapshot>> = []
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => {
|
||||
const pending = Promise.withResolvers<SkillCatalogSnapshot>()
|
||||
pendingSnapshots.push(pending)
|
||||
return pending.promise
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
expect(pendingSnapshots).toHaveLength(1)
|
||||
|
||||
result.ctx.emit('skills/change')
|
||||
result.ctx.emit('skills/change')
|
||||
expect(pendingSnapshots).toHaveLength(3)
|
||||
pendingSnapshots[2]?.resolve({
|
||||
skills: [{
|
||||
name: 'latest-skill',
|
||||
description: 'LATEST_COMPLETION_MARKER',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}],
|
||||
complete: true,
|
||||
})
|
||||
await tick()
|
||||
pendingSnapshots[0]?.resolve({
|
||||
skills: [{ name: 'stale-first', description: 'STALE_FIRST', source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
pendingSnapshots[1]?.resolve({
|
||||
skills: [{ name: 'stale-second', description: 'STALE_SECOND', source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
await tick()
|
||||
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:latest')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER')
|
||||
expect(result.terminal.output).not.toContain('STALE_FIRST')
|
||||
expect(result.terminal.output).not.toContain('STALE_SECOND')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('loads a skill as a user turn, appending typed instructions', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill:demo-skill')
|
||||
@@ -3965,7 +4092,7 @@ describe('skill slash command', () => {
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
list: () => Promise.reject(new Error('list boom')),
|
||||
snapshot: () => Promise.reject(new Error('list boom')),
|
||||
get: () => Promise.reject(new Error('get boom')),
|
||||
} as never)
|
||||
},
|
||||
@@ -3979,13 +4106,13 @@ describe('skill slash command', () => {
|
||||
})
|
||||
|
||||
it('drops skill list and lookup results that settle after disposal', async () => {
|
||||
const pendingList: Array<(value: SkillSummary[]) => void> = []
|
||||
const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = []
|
||||
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
|
||||
snapshot: () => new Promise<SkillCatalogSnapshot>((resolve) => { pendingSnapshots.push(resolve) }),
|
||||
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
|
||||
} as never)
|
||||
},
|
||||
@@ -3998,7 +4125,14 @@ describe('skill slash command', () => {
|
||||
await tick()
|
||||
await dispose(result)
|
||||
|
||||
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
|
||||
result.ctx.emit('skills/change')
|
||||
expect(pendingSnapshots).toHaveLength(1)
|
||||
for (const resolve of pendingSnapshots) {
|
||||
resolve({
|
||||
skills: [{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
}
|
||||
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
|
||||
pendingGet[1]?.reject(new Error('late failure'))
|
||||
await tick()
|
||||
|
||||
Reference in New Issue
Block a user