Merge branch 'worktree/web-carrier-chain' into worktree/web-ask-user-question
This commit is contained in:
@@ -24,6 +24,28 @@ function text(t: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: t }]
|
||||
}
|
||||
|
||||
const MARKDOWN_FIXTURE = [
|
||||
'# Markdown fixture',
|
||||
'',
|
||||
'Assistant output renders **strong text**, *emphasis*, and `inline code`.',
|
||||
'',
|
||||
'- first item',
|
||||
' - nested item',
|
||||
'',
|
||||
'| Surface | State |',
|
||||
'| --- | --- |',
|
||||
'| history | rendered |',
|
||||
'| streaming | stable |',
|
||||
'',
|
||||
'[DeepSeek](https://www.deepseek.com)',
|
||||
'',
|
||||
'```ts',
|
||||
'const markdown = true',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
||||
|
||||
function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
@@ -40,7 +62,13 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
}
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
|
||||
push({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: {
|
||||
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`),
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
})
|
||||
if (turn % 9 === 4) {
|
||||
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
}
|
||||
@@ -49,7 +77,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const withReasoning = turn % 3 === 1
|
||||
const blocks: ContentBlock[] = []
|
||||
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
|
||||
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
|
||||
blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
|
||||
if (withTool) {
|
||||
const callId = `fx-call-${turn}`
|
||||
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
|
||||
@@ -377,8 +405,8 @@ export function createFixtureApi(): ApiProxy {
|
||||
const step = 0
|
||||
append(id, { type: 'step/start', data: { turn, step } })
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
|
||||
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
|
||||
/* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
|
||||
const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
|
||||
let i = 0
|
||||
const finish = (aborted: boolean): void => {
|
||||
replays.delete(id)
|
||||
@@ -444,7 +472,13 @@ export function createFixtureApi(): ApiProxy {
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
|
||||
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
|
||||
startReply(
|
||||
id,
|
||||
turn,
|
||||
userText === 'render markdown'
|
||||
? MARKDOWN_FIXTURE
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
cancel: (request) => {
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('createFixtureApi', () => {
|
||||
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
|
||||
// Real prompt: replay starts (running flips true), cancel freezes it.
|
||||
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
|
||||
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
|
||||
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
|
||||
await api.sessions.cancel(req({ sessionId: id }))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MessageText key={i} text={block.text} />
|
||||
case 'text': return <MarkdownText key={i} text={block.text} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
case 'tool-call': return null
|
||||
|
||||
@@ -158,6 +158,44 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
const markdown = '# Rendered\n\n- **one**\n- `two`'
|
||||
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(1)
|
||||
const literal = view.getByText((_content, element) => (
|
||||
element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
|
||||
))
|
||||
expect(literal.querySelector('h1')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
|
||||
})
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
|
||||
partial: null,
|
||||
})
|
||||
})
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, markdown),
|
||||
assistant(2, markdown),
|
||||
{ ...assistant(3, markdown), interrupted: true },
|
||||
],
|
||||
})
|
||||
})
|
||||
expect(view.getByText('已停止')).toBeTruthy()
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('streaming partial frames re-render only the tail (Profiler count)', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# @deepseek-ai/dsh-client-ui-primitives
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
|
||||
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
|
||||
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
|
||||
- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure.
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -15,5 +15,6 @@ export type { MenuItem } from './Menu.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export * from './icons/index.tsx'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
.markdown {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-base);
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font: var(--dsw-font-markdown-h1);
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font: var(--dsw-font-markdown-h2);
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font: var(--dsw-font-markdown-h3);
|
||||
}
|
||||
|
||||
.markdown :where(h4, h5, h6) {
|
||||
font: var(--dsw-font-markdown-h4);
|
||||
}
|
||||
|
||||
.markdown :where(strong, th) {
|
||||
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) {
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
.markdown li + li {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-markdown-inline-code);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.tableScroll table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--dsw-font-markdown-table);
|
||||
}
|
||||
|
||||
.tableScroll :where(th, td) {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--dsw-alias-markdown-citation);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableScroll th {
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
font: var(--dsw-font-markdown-table-head);
|
||||
}
|
||||
|
||||
.imageAlt {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
64
packages/client/ui-primitives/src/markdown/MarkdownText.tsx
Normal file
64
packages/client/ui-primitives/src/markdown/MarkdownText.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
switch (new URL(url).protocol) {
|
||||
case 'http:':
|
||||
case 'https:':
|
||||
case 'mailto:':
|
||||
return url
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
const components: Components = {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection.
|
||||
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
*/
|
||||
export function MarkdownText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
|
||||
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
|
||||
|
||||
import css from './MessageText.module.css'
|
||||
|
||||
|
||||
@@ -1,14 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageText', () => {
|
||||
it('renders the text verbatim', () => {
|
||||
const { container } = render(<MessageText text={'line1\nline2'} />)
|
||||
expect(container.textContent).toBe('line1\nline2')
|
||||
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
|
||||
expect(container.textContent).toBe('# line1\n`line2`')
|
||||
expect(container.querySelector('h1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownText', () => {
|
||||
it('renders CommonMark and GFM elements as semantic DOM', () => {
|
||||
const markdown = [
|
||||
'# Heading',
|
||||
'',
|
||||
'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
|
||||
'Hard break.',
|
||||
'',
|
||||
'> Quote',
|
||||
'',
|
||||
'- parent',
|
||||
' - child',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'- [x] done',
|
||||
'- [ ] pending',
|
||||
'',
|
||||
'| Name | Value |',
|
||||
'| --- | --- |',
|
||||
'| alpha | beta |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'```ts',
|
||||
'const answer = 42',
|
||||
'```',
|
||||
'',
|
||||
'<https://deepseek.com>',
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
|
||||
expect(container.querySelector('strong')?.textContent).toBe('strong')
|
||||
expect(container.querySelector('em')?.textContent).toBe('emphasis')
|
||||
expect(container.querySelector('del')?.textContent).toBe('deleted')
|
||||
expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
|
||||
expect(container.querySelectorAll('ul')).toHaveLength(3)
|
||||
expect(container.querySelector('ol')).not.toBeNull()
|
||||
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
|
||||
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
|
||||
expect(container.querySelector('hr')).not.toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
expect(container.querySelector('br')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
|
||||
const markdown = [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
'<img src="x" onerror="globalThis.compromised = true">',
|
||||
'[script](javascript:alert(1)) [relative](/settings)',
|
||||
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
|
||||
'',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
const neutralized = [...container.querySelectorAll('p')]
|
||||
.find(paragraph => paragraph.textContent === 'script relative')
|
||||
expect(neutralized?.querySelector('a')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByText('remote diagram')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps incomplete streaming Markdown renderable', () => {
|
||||
const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(screen.getByText('**unfinished')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -135,6 +135,26 @@ function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: obj
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry-identity React keys for chain boundaries. A chain outlet renders ONE
|
||||
* elected entry through an error boundary; without a key, a boundary that
|
||||
* failed on entry A would survive a re-election and keep a healthy entry B
|
||||
* blacked out. Keying by entry identity remounts the boundary fresh whenever
|
||||
* the election changes (entries are identity-stable per registration, so the
|
||||
* key is stable while the same entry stays elected).
|
||||
*/
|
||||
let nextEntryKey = 0
|
||||
const entryKeys = new WeakMap<StoredEntry, number>()
|
||||
|
||||
function entryKeyOf(entry: StoredEntry): number {
|
||||
let key = entryKeys.get(entry)
|
||||
if (key === undefined) {
|
||||
key = nextEntryKey++
|
||||
entryKeys.set(entry, key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-entry isolation: one registrant crashing (component render or inject
|
||||
* factory) must not take down siblings. Assembly errors (missing providers)
|
||||
@@ -265,9 +285,22 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
// pass runs per render with zero mount side effects: the first non-null
|
||||
// election renders, decliners never mount.
|
||||
for (const entry of entries) {
|
||||
// Chain entries always carry select (SlotCore register validation).
|
||||
const matched = (entry.select as (owner: object) => unknown)(ownerProps)
|
||||
if (matched !== null) return guarded(entry, undefined, { ...ownerProps, matched })
|
||||
let matched: unknown
|
||||
try {
|
||||
// Chain entries always carry select (SlotCore register validation).
|
||||
matched = (entry.select as (owner: object) => unknown)(ownerProps)
|
||||
} catch (error) {
|
||||
// A throwing selector is a registrant contract breach (select MUST be
|
||||
// pure and total), but it runs before the entry's SlotErrorBoundary
|
||||
// exists — uncontained it would black out the whole owner region. So
|
||||
// it degrades to a decline: the chain and the fallback stay intact,
|
||||
// and the breach is reported like a crashed entry.
|
||||
console.error(
|
||||
`chain selector crashed in '${slotKey}' (${entry.registrant ?? 'unknown registrant'}), treating as declined:`,
|
||||
error)
|
||||
continue
|
||||
}
|
||||
if (matched !== null) return guarded(entry, entryKeyOf(entry), { ...ownerProps, matched })
|
||||
}
|
||||
return <>{opts?.fallback ?? null}</>
|
||||
}
|
||||
|
||||
@@ -312,6 +312,55 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
expect(declinerBody).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('contains a throwing selector to its entry: reported, treated as declined, chain and fallback intact', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <span>never</span>,
|
||||
select: () => { throw new Error('selector boom') },
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
||||
select: (owner) => (owner as { pick?: string }).pick ?? null,
|
||||
}))
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
|
||||
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
|
||||
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
|
||||
</>)
|
||||
// The breach never escapes to the owner region: later entries still get
|
||||
// tried, and an all-throw/all-null pass still lands on the fallback.
|
||||
expect(view.container.querySelector('main')!.textContent).toBe('OK')
|
||||
expect(view.container.querySelector('aside')!.textContent).toBe('fb')
|
||||
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('remounts the boundary on re-election: a failed entry does not black out its replacement', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => { throw new Error('entry A boom') },
|
||||
select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null,
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>B-ok</b>,
|
||||
select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null,
|
||||
}))
|
||||
let pick = 'A'
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { pick }))
|
||||
spy.mockRestore()
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of
|
||||
// holding A's failed state over the healthy replacement.
|
||||
pick = 'B'
|
||||
act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site
|
||||
expect(view.container.textContent).toBe('B-ok')
|
||||
expect(view.container.querySelector('[data-slot-error]')).toBeNull()
|
||||
})
|
||||
|
||||
it('falls to the owner fallback when every selector declines, and re-routes live', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
|
||||
import { Server as NetServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startWebServer, type RunningWebServer } from '../src/index.ts'
|
||||
|
||||
/** Reserve a loopback port for tests that need to address a second server. */
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = createNetServer()
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const port = (probe.address() as AddressInfo).port
|
||||
probe.close(() => { resolve(port) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** dist fixture: index.html + one asset of each MIME class + a subdir. */
|
||||
function makeDist(): { distIndex: string; distRoot: string } {
|
||||
const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
|
||||
@@ -106,8 +94,7 @@ afterEach(async () => {
|
||||
|
||||
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
|
||||
@@ -147,8 +134,8 @@ describe('startWebServer', () => {
|
||||
|
||||
it('rejects when the port is already taken', async () => {
|
||||
const { distIndex } = makeDist()
|
||||
const port = await freePort()
|
||||
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
|
||||
const { port } = server
|
||||
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
|
||||
.rejects.toMatchObject({ code: 'EADDRINUSE' })
|
||||
})
|
||||
@@ -205,9 +192,8 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
snapshot: () => rows,
|
||||
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
|
||||
}
|
||||
const port = await freePort()
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
return `http://127.0.0.1:${String(server.port)}`
|
||||
}
|
||||
@@ -243,9 +229,8 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
|
||||
snapshot: () => rows,
|
||||
clientPath: () => '/nonexistent/lib/client.js',
|
||||
}
|
||||
const port = await freePort()
|
||||
server = await startWebServer(
|
||||
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
|
||||
)
|
||||
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead.
|
||||
|
||||
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [file-reference autocomplete Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md) owns path-only `@file` behavior; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
|
||||
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
|
||||
|
||||
@@ -16,7 +16,9 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
@@ -44,6 +46,9 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
|
||||
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
|
||||
| `modelDialogWidth` | `72` | Model-selector width in columns |
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
|
||||
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
|
||||
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |
|
||||
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
@@ -57,6 +62,7 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
@@ -81,6 +87,26 @@ Submitted text is retained under the agent loop's normal session-history and com
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### File-reference autocomplete
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A selected file remains ordinary user text such as `@src/index.ts` or `@"docs/design notes.md"`; autocomplete adds no content block, durable context, or special reference payload. When `read` is registered, every request from this TUI agent also contains the following fixed system-prompt section. The model decides whether the task requires the file contents and calls `read` through the normal tool loop when it does; a path alone is not evidence that the file was inspected.
|
||||
|
||||
##### Exact system-prompt text
|
||||
|
||||
```markdown
|
||||
Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Autocomplete itself adds no tokens. The selected path contributes only its ordinary user-text tokens; the fixed instruction contributes system-prompt tokens whenever `read` is available. File contents consume context only after a model-selected `read` call returns them.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The fixed instruction is part of the stable system-prompt prefix and is reusable across turns. Each selected path is append-only user text; a later `read` result appends the requested contents through the ordinary tool transcript.
|
||||
|
||||
### Session model selection
|
||||
|
||||
#### What the model sees
|
||||
@@ -129,3 +155,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
|
||||
- **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again.
|
||||
- **File discovery is host-workspace discovery** — autocomplete reads the TUI process's session `cwd`, while the selected text is later interpreted by the configured `read` tool. Deployments that mount a remote or virtual filesystem must keep those namespaces aligned or provide another completion surface.
|
||||
- **File search uses explicit directory exclusions, not ignore files** — `.git` and `node_modules` are excluded by default and deployments may configure more basenames, but `.gitignore` and `.ignore` are not interpreted. Directory symlinks are not traversed.
|
||||
|
||||
346
packages/ui/tui/src/file-autocomplete.ts
Normal file
346
packages/ui/tui/src/file-autocomplete.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Host-workspace discovery for TUI `@file` completion. The index contains
|
||||
* paths only: selected values remain ordinary prompt text and file contents
|
||||
* stay behind the model-facing `read` tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tui/file-autocomplete
|
||||
*/
|
||||
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** Default maximum file and directory candidates rendered for one query. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
|
||||
/** Default maximum entries retained in one workspace search index. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
|
||||
/** Directory basenames omitted from traversal unless the deployment overrides them. */
|
||||
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
|
||||
|
||||
/** Resolved limits and exclusions for one TUI workspace index. */
|
||||
export interface FileSearchConfig {
|
||||
/** Maximum ranked candidates returned for one query. */
|
||||
maxResults: number
|
||||
/** Maximum indexed files and directories. */
|
||||
maxEntries: number
|
||||
/** Directory basenames never traversed or offered. */
|
||||
excludedDirectories: readonly string[]
|
||||
}
|
||||
|
||||
/** One path-only completion candidate inside the session cwd. */
|
||||
export interface FileSearchCandidate {
|
||||
/** User-facing path accepted by the normal prompt and filesystem tools. */
|
||||
path: string
|
||||
/** Directories keep completion open; files finish the mention. */
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
|
||||
/** Active `@` token ending at the editor cursor. */
|
||||
export interface ActiveAtToken {
|
||||
/** Complete token replaced when the user accepts a completion. */
|
||||
prefix: string
|
||||
/** Path query after `@` or `@"`. */
|
||||
query: string
|
||||
/** Whether the user opened a quoted path. */
|
||||
quoted: boolean
|
||||
}
|
||||
|
||||
interface IndexedPath extends FileSearchCandidate {}
|
||||
|
||||
interface RankedPath {
|
||||
candidate: FileSearchCandidate
|
||||
score: number
|
||||
}
|
||||
|
||||
interface IndexGeneration {
|
||||
controller: AbortController
|
||||
promise: Promise<IndexedPath[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
|
||||
* inside another token, such as an email address, is not a completion trigger.
|
||||
* @param line - current editor line.
|
||||
* @param cursorCol - cursor column within that line.
|
||||
* @returns the active token, or `undefined` outside an `@` token.
|
||||
*/
|
||||
export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
|
||||
const beforeCursor = line.slice(0, cursorCol)
|
||||
const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
|
||||
if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
|
||||
return { prefix: quoted[1], query: quoted[2], quoted: true }
|
||||
}
|
||||
const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
|
||||
if (plain?.[1] === undefined || plain[2] === undefined) return undefined
|
||||
return { prefix: plain[1], query: plain[2], quoted: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a selected path as prompt text. Whitespace uses Pi's quoted
|
||||
* `@"path"` grammar; directories retain a trailing slash so completion can
|
||||
* descend another level.
|
||||
* @param candidate - selected file or directory.
|
||||
* @param preserveQuote - retain an explicitly opened quote even when unnecessary.
|
||||
* @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
|
||||
*/
|
||||
export function formatFileMention(
|
||||
candidate: FileSearchCandidate,
|
||||
preserveQuote: boolean,
|
||||
): string | undefined {
|
||||
const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
|
||||
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
||||
const quoted = preserveQuote || /\s/u.test(path)
|
||||
if (!quoted) return `@${path}`
|
||||
return `@"${path}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
||||
* Directory-scoped queries list live state; bare fuzzy queries share one
|
||||
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
||||
*/
|
||||
export class WorkspaceFileSearch {
|
||||
private readonly excludedDirectories: ReadonlySet<string>
|
||||
private generation: IndexGeneration | undefined
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly config: FileSearchConfig,
|
||||
) {
|
||||
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
||||
throw new Error('file search maxResults must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
||||
throw new Error('file search maxEntries must be a positive safe integer')
|
||||
}
|
||||
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
||||
throw new Error('file search excludedDirectories entries must be non-empty directory basenames')
|
||||
}
|
||||
this.excludedDirectories = new Set(config.excludedDirectories)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return ranked path candidates for the current token.
|
||||
* @param rawQuery - path text following `@` or `@"`.
|
||||
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
||||
* @returns at most `maxResults` deterministic candidates.
|
||||
*/
|
||||
async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
|
||||
signal.throwIfAborted()
|
||||
if (this.disposed) return []
|
||||
const query = rawQuery.replaceAll('\\', '/')
|
||||
const slash = query.lastIndexOf('/')
|
||||
if (query === '' || slash >= 0) {
|
||||
const directory = slash < 0 ? '' : query.slice(0, slash + 1)
|
||||
const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
||||
return this.listDirectory(directory, fragment, signal)
|
||||
}
|
||||
const indexed = await waitForPromise(this.ensureIndex(), signal)
|
||||
return rankCandidates(
|
||||
indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
|
||||
query,
|
||||
this.config.maxResults,
|
||||
)
|
||||
}
|
||||
|
||||
/** Discard the current index so the next bare query observes a fresh tree. */
|
||||
invalidate(): void {
|
||||
this.generation?.controller.abort(new Error('file search index invalidated'))
|
||||
this.generation = undefined
|
||||
}
|
||||
|
||||
/** Abort traversal and make later queries return no candidates. */
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private ensureIndex(): Promise<IndexedPath[]> {
|
||||
if (this.generation !== undefined) return this.generation.promise
|
||||
const controller = new AbortController()
|
||||
const generation = {
|
||||
controller,
|
||||
promise: Promise.resolve([] as IndexedPath[]),
|
||||
} satisfies IndexGeneration
|
||||
generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
|
||||
/* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
|
||||
if (this.generation === generation) this.generation = undefined
|
||||
throw error
|
||||
})
|
||||
this.generation = generation
|
||||
return generation.promise
|
||||
}
|
||||
|
||||
private async scanWorkspace(signal: AbortSignal): Promise<IndexedPath[]> {
|
||||
const indexed: IndexedPath[] = []
|
||||
const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }]
|
||||
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
||||
signal.throwIfAborted()
|
||||
const directory = directories[cursor]
|
||||
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
||||
if (directory === undefined) {
|
||||
throw new Error('file search selected a missing directory')
|
||||
}
|
||||
const entries = await readDirectory(directory.absolute, signal)
|
||||
for (const entry of entries) {
|
||||
signal.throwIfAborted()
|
||||
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
indexed.push({ path, kind: 'directory' })
|
||||
directories.push({ absolute: join(directory.absolute, entry.name), relative: path })
|
||||
} else if (entry.isFile()) {
|
||||
indexed.push({ path, kind: 'file' })
|
||||
}
|
||||
if (indexed.length >= this.config.maxEntries) break
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
private async listDirectory(
|
||||
displayDirectory: string,
|
||||
fragment: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileSearchCandidate[]> {
|
||||
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
||||
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal)
|
||||
if (absolute === undefined) return []
|
||||
const entries = await readDirectory(absolute, signal)
|
||||
const candidates: FileSearchCandidate[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' })
|
||||
} else if (entry.isFile()) {
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' })
|
||||
}
|
||||
}
|
||||
return rankCandidates(candidates, fragment, this.config.maxResults)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDisplayDirectory(
|
||||
root: string,
|
||||
displayDirectory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
const resolvedRoot = resolve(root)
|
||||
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
||||
const fromRoot = relative(resolvedRoot, absolute)
|
||||
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
||||
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
||||
if (isAbsolute(fromRoot)) return undefined
|
||||
let current = resolvedRoot
|
||||
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
||||
signal.throwIfAborted()
|
||||
current = join(current, segment)
|
||||
try {
|
||||
const status = await lstat(current)
|
||||
signal.throwIfAborted()
|
||||
if (status.isSymbolicLink() || !status.isDirectory()) return undefined
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readDirectory(absolute: string, signal: AbortSignal) {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const entries = await readdir(absolute, { withFileTypes: true })
|
||||
signal.throwIfAborted()
|
||||
return entries.sort((left, right) => compareText(left.name, right.name))
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
// An unreadable/missing subtree contributes no candidates; other readable
|
||||
// branches remain useful and autocomplete is advisory.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function visibleForGlobalQuery(path: string, query: string): boolean {
|
||||
if (query.startsWith('.') || query.includes('/.')) return true
|
||||
return !path.split('/').some(segment => segment.startsWith('.'))
|
||||
}
|
||||
|
||||
function rankCandidates(
|
||||
candidates: readonly FileSearchCandidate[],
|
||||
query: string,
|
||||
limit: number,
|
||||
): FileSearchCandidate[] {
|
||||
const ranked: RankedPath[] = []
|
||||
for (const candidate of candidates) {
|
||||
const score = scoreCandidate(candidate, query)
|
||||
if (score !== undefined) ranked.push({ candidate, score })
|
||||
}
|
||||
ranked.sort((left, right) =>
|
||||
right.score - left.score
|
||||
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
||||
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
||||
|| compareText(left.candidate.path, right.candidate.path))
|
||||
return ranked.slice(0, limit).map(entry => entry.candidate)
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
|
||||
if (query === '') return 0
|
||||
const path = candidate.path.toLowerCase()
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
const needle = query.toLowerCase()
|
||||
const directoryBonus = candidate.kind === 'directory' ? 25 : 0
|
||||
if (name === needle) return 1_000 + directoryBonus
|
||||
if (name.startsWith(needle)) return 900 + directoryBonus
|
||||
if (name.includes(needle)) return 700 + directoryBonus
|
||||
if (path.includes(needle)) return 500 + directoryBonus
|
||||
const subsequence = subsequenceScore(path, needle)
|
||||
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
|
||||
}
|
||||
|
||||
function subsequenceScore(target: string, query: string): number | undefined {
|
||||
let targetIndex = 0
|
||||
let gap = 0
|
||||
for (const character of query) {
|
||||
const found = target.indexOf(character, targetIndex)
|
||||
if (found < 0) return undefined
|
||||
gap += found - targetIndex
|
||||
targetIndex = found + 1
|
||||
}
|
||||
return Math.max(0, 100 - gap)
|
||||
}
|
||||
|
||||
function kindRank(kind: FileSearchCandidate['kind']): number {
|
||||
return kind === 'directory' ? 0 : 1
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
/* v8 ignore next -- entries and candidates are unique; host enumeration
|
||||
* order determines which comparison direction sort requests. */
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function waitForPromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
||||
if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted'))
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolvePromise(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
rejectPromise(errorReason(error, 'file search index failed'))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function errorReason(reason: unknown, fallback: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(fallback, { cause: reason })
|
||||
}
|
||||
@@ -146,11 +146,28 @@ export abstract class TuiExtensionService extends Service {
|
||||
*/
|
||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
}
|
||||
import {
|
||||
activeAtToken,
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
formatFileMention,
|
||||
WorkspaceFileSearch,
|
||||
} from './file-autocomplete.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
} from './file-autocomplete.ts'
|
||||
|
||||
export const name = 'ui-tui'
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
|
||||
|
||||
/** Presentation settings for the pi-tui terminal mode. */
|
||||
/** Model guidance for path-only file references selected through the TUI. */
|
||||
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
|
||||
|
||||
/** Interaction and presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
/** Render model reasoning blocks. */
|
||||
showReasoning?: boolean
|
||||
@@ -168,6 +185,12 @@ export interface TuiConfig {
|
||||
modelDialogWidth?: number
|
||||
/** Model-selector maximum height in terminal rows. */
|
||||
modelDialogMaxHeight?: number
|
||||
/** Maximum fuzzy file candidates displayed for one `@` query. */
|
||||
fileSearchMaxResults?: number
|
||||
/** Maximum paths retained in one `@` workspace index. */
|
||||
fileSearchMaxEntries?: number
|
||||
/** Directory basenames excluded from `@` traversal and completion. */
|
||||
fileSearchExcludedDirectories?: string[]
|
||||
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
|
||||
showHardwareCursor?: boolean
|
||||
/** Apply the built-in ANSI color palette. */
|
||||
@@ -191,14 +214,16 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
|
||||
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
|
||||
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
|
||||
const showHardwareCursorSchema = z.boolean().default(false)
|
||||
const colorSchema = z.boolean().default(true)
|
||||
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
|
||||
const truecolorSchema = z.boolean()
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
const tuiConfigSchemaFields = {
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
@@ -207,11 +232,17 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
fileSearchMaxResults: fileSearchMaxResultsSchema,
|
||||
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
|
||||
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
})
|
||||
}
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)
|
||||
|
||||
/** Serializable plugin configuration. */
|
||||
export interface Config extends TuiConfig {
|
||||
@@ -233,18 +264,21 @@ export const Config: z<Config> = z.object({
|
||||
welcome: z.string(),
|
||||
sessionId: z.string().default('main'),
|
||||
resumeCommand: z.string(),
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
|
||||
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
|
||||
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
|
||||
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
|
||||
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
|
||||
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
|
||||
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
|
||||
showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
|
||||
color: tuiConfigSchemaFields.color,
|
||||
truecolor: tuiConfigSchemaFields.truecolor,
|
||||
title: tuiConfigSchemaFields.title,
|
||||
})
|
||||
|
||||
/** Fully defaulted TUI presentation settings. */
|
||||
@@ -257,6 +291,9 @@ export interface ResolvedTuiConfig {
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
fileSearchMaxResults: number
|
||||
fileSearchMaxEntries: number
|
||||
fileSearchExcludedDirectories: string[]
|
||||
showHardwareCursor: boolean
|
||||
color: boolean
|
||||
truecolor: boolean
|
||||
@@ -295,6 +332,9 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 72,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
|
||||
showHardwareCursor: config?.showHardwareCursor ?? false,
|
||||
color: config?.color ?? true,
|
||||
truecolor: config?.truecolor ?? false,
|
||||
@@ -1349,11 +1389,12 @@ interface PendingQuestion {
|
||||
overlay: TuiOverlaySession | undefined
|
||||
}
|
||||
|
||||
/** Add session candidates to pi-tui's existing command/file provider. */
|
||||
class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
/** Merge path-only file candidates and optional session snapshots with commands. */
|
||||
class ReferenceAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
private readonly sessions: SessionReferenceService,
|
||||
private readonly files: WorkspaceFileSearch,
|
||||
private readonly sessions: SessionReferenceService | undefined,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
@@ -1367,17 +1408,33 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
const currentLine = lines[cursorLine]
|
||||
/* v8 ignore next -- Editor always supplies its current state line. */
|
||||
if (currentLine === undefined) return basePromise
|
||||
const token = /(?:^|\s)(@[^\s]*)$/u.exec(currentLine.slice(0, cursorCol))?.[1]
|
||||
if (token === undefined) return basePromise
|
||||
let candidates
|
||||
try {
|
||||
candidates = await this.sessions.listCandidates(this.agent, token.slice(1), undefined, options.signal)
|
||||
} catch {
|
||||
const token = activeAtToken(currentLine, cursorCol)
|
||||
if (token === undefined) {
|
||||
this.files.invalidate()
|
||||
return basePromise
|
||||
}
|
||||
const base = await basePromise
|
||||
const filePromise = this.files.list(token.query, options.signal).catch(() => [])
|
||||
const sessionPromise = this.sessions === undefined || token.quoted
|
||||
? Promise.resolve([])
|
||||
: this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
|
||||
const [base, fileCandidates, sessionCandidates] = await Promise.all([
|
||||
basePromise,
|
||||
filePromise,
|
||||
sessionPromise,
|
||||
])
|
||||
if (options.signal.aborted) return base
|
||||
const items: AutocompleteItem[] = candidates.map((candidate) => {
|
||||
const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
|
||||
const value = formatFileMention(candidate, token.quoted)
|
||||
if (value === undefined) return []
|
||||
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
|
||||
const directory = candidate.kind === 'directory'
|
||||
return [{
|
||||
value,
|
||||
label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
|
||||
description: displayInlineText(candidate.path),
|
||||
}]
|
||||
})
|
||||
const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
|
||||
const mentionLabel = displayInlineText(candidate.label)
|
||||
const sessionId = displayInlineText(candidate.sessionId)
|
||||
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
|
||||
@@ -1388,8 +1445,9 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
description,
|
||||
}
|
||||
})
|
||||
const items = [...fileItems, ...sessionItems]
|
||||
if (items.length === 0) return base
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token }
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
@@ -1558,6 +1616,11 @@ export function createTuiChat(
|
||||
// rather than declaring an injection that would make the TUI require them.
|
||||
const skills = ctx.get('skills')
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const fileSearch = new WorkspaceFileSearch(cwd, {
|
||||
maxResults: resolved.fileSearchMaxResults,
|
||||
maxEntries: resolved.fileSearchMaxEntries,
|
||||
excludedDirectories: resolved.fileSearchExcludedDirectories,
|
||||
})
|
||||
const skillAbort = new AbortController()
|
||||
const tokens = sessionTokens(agent.session)
|
||||
const toolCards = new Map<string, ToolCardComponent>()
|
||||
@@ -2327,9 +2390,12 @@ export function createTuiChat(
|
||||
agent.session.header.cwd ?? process.cwd(),
|
||||
)
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
editor.setAutocompleteProvider(sessionReferences === undefined
|
||||
? base
|
||||
: new SessionAutocompleteProvider(base, sessionReferences, agent))
|
||||
editor.setAutocompleteProvider(new ReferenceAutocompleteProvider(
|
||||
base,
|
||||
fileSearch,
|
||||
sessionReferences,
|
||||
agent,
|
||||
))
|
||||
}
|
||||
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
|
||||
refreshCommandAutocomplete()
|
||||
@@ -2413,6 +2479,16 @@ export function createTuiChat(
|
||||
handler: () => { requestExit(); return { kind: 'success' } },
|
||||
})
|
||||
})
|
||||
const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'ui:tui-file-reference',
|
||||
order: 99,
|
||||
// Tool visibility can change dynamically or by agent scope. Empty
|
||||
// sections are omitted by renderPrompt, so guidance never names a tool
|
||||
// that this agent cannot call.
|
||||
text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
})
|
||||
|
||||
const runCommand = (text: string): void => {
|
||||
const controller = new AbortController()
|
||||
@@ -2660,6 +2736,7 @@ export function createTuiChat(
|
||||
|
||||
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||
recordEventUsage(tokens, event)
|
||||
advanceTurnPhase(event)
|
||||
if (event.type === 'steering/message') {
|
||||
@@ -2708,6 +2785,7 @@ export function createTuiChat(
|
||||
|
||||
const detachListeners = (): void => {
|
||||
skillAbort.abort()
|
||||
fileSearch.dispose()
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
stopBannerReveal()
|
||||
@@ -2755,10 +2833,13 @@ export function createTuiChat(
|
||||
} catch (error: unknown) {
|
||||
disposed = true
|
||||
detachListeners()
|
||||
void commandFiber.dispose().catch(
|
||||
void Promise.all([
|
||||
commandFiber.dispose(),
|
||||
fileReferencePromptFiber.dispose(),
|
||||
]).catch(
|
||||
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
|
||||
(cleanupError: unknown) => {
|
||||
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
|
||||
ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`)
|
||||
},
|
||||
)
|
||||
clearStatus()
|
||||
@@ -2775,7 +2856,10 @@ export function createTuiChat(
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
await shutdown(false)
|
||||
await commandFiber.dispose()
|
||||
await Promise.all([
|
||||
commandFiber.dispose(),
|
||||
fileReferencePromptFiber.dispose(),
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
197
packages/ui/tui/tests/file-autocomplete.spec.ts
Normal file
197
packages/ui/tui/tests/file-autocomplete.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activeAtToken,
|
||||
formatFileMention,
|
||||
WorkspaceFileSearch,
|
||||
} from '../src/file-autocomplete.ts'
|
||||
|
||||
const searches: WorkspaceFileSearch[] = []
|
||||
const roots: string[] = []
|
||||
|
||||
async function workspace(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
|
||||
roots.push(root)
|
||||
await mkdir(join(root, 'src'), { recursive: true })
|
||||
await mkdir(join(root, 'docs'), { recursive: true })
|
||||
await mkdir(join(root, '.hidden'), { recursive: true })
|
||||
await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true })
|
||||
await writeFile(join(root, 'README.md'), 'readme')
|
||||
await writeFile(join(root, 'src', 'tui.spec.ts'), 'test')
|
||||
await writeFile(join(root, 'src', 'terminal-view.ts'), 'view')
|
||||
await writeFile(join(root, 'docs', 'design notes.md'), 'design')
|
||||
await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden')
|
||||
await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored')
|
||||
try {
|
||||
await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts'))
|
||||
} catch {
|
||||
// Windows may deny symlink creation without Developer Mode; the product
|
||||
// still skips every non-file/non-directory Dirent on platforms that expose one.
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function search(root: string, overrides: Partial<ConstructorParameters<typeof WorkspaceFileSearch>[1]> = {}): WorkspaceFileSearch {
|
||||
const instance = new WorkspaceFileSearch(root, {
|
||||
maxResults: overrides.maxResults ?? 20,
|
||||
maxEntries: overrides.maxEntries ?? 10_000,
|
||||
excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'],
|
||||
})
|
||||
searches.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const instance of searches.splice(0)) instance.dispose()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('TUI file autocomplete grammar', () => {
|
||||
it('recognizes boundary and quoted mentions without treating emails as references', () => {
|
||||
expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false })
|
||||
expect(activeAtToken('read @"docs/design n', 20)).toEqual({
|
||||
prefix: '@"docs/design n',
|
||||
query: 'docs/design n',
|
||||
quoted: true,
|
||||
})
|
||||
expect(activeAtToken('mail a@b.test', 13)).toBeUndefined()
|
||||
expect(activeAtToken('done @src/x" next', 17)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('formats files, directories, quotes, and rejects unsafe editor values', () => {
|
||||
expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts')
|
||||
expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/')
|
||||
expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false))
|
||||
.toBe('@"docs/design notes.md"')
|
||||
expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"')
|
||||
expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined()
|
||||
expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined()
|
||||
expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceFileSearch', () => {
|
||||
it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => {
|
||||
const root = await workspace()
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(await files.list('', signal)).toEqual([
|
||||
{ path: 'docs', kind: 'directory' },
|
||||
{ path: 'src', kind: 'directory' },
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('src/', signal)).toEqual([
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
{ path: 'src/tui.spec.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('src/ts', signal)).toEqual([
|
||||
{ path: 'src/tui.spec.ts', kind: 'file' },
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('docs/design n', signal)).toEqual([
|
||||
{ path: 'docs/design notes.md', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('node_modules/', signal)).toEqual([])
|
||||
expect(await files.list('.hidden/', signal)).toEqual([
|
||||
{ path: '.hidden/secret.txt', kind: 'file' },
|
||||
])
|
||||
const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/`
|
||||
expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([
|
||||
{ path: `${absoluteSrc}tui.spec.ts`, kind: 'file' },
|
||||
{ path: `${absoluteSrc}terminal-view.ts`, kind: 'file' },
|
||||
])
|
||||
expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
|
||||
expect(await files.list('../', signal)).toEqual([])
|
||||
expect(await files.list('README.md/', signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not traverse directory symlinks during direct completion', async () => {
|
||||
const root = await workspace()
|
||||
const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-'))
|
||||
roots.push(outside)
|
||||
await writeFile(join(outside, 'outside-secret.txt'), 'secret')
|
||||
await symlink(
|
||||
outside,
|
||||
join(root, 'escape'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
)
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(await files.list('escape/', signal)).toEqual([])
|
||||
expect(await files.list('escape/outside', signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
|
||||
const root = await workspace()
|
||||
await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper')
|
||||
const files = search(root, { maxResults: 2 })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(await files.list('tspc', signal)).toEqual([
|
||||
{ path: 'src/tspc-helper.ts', kind: 'file' },
|
||||
{ path: 'src/tui.spec.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('README.md', signal)).toEqual([
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('terminal', signal)).toEqual([
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('secret', signal)).toEqual([])
|
||||
expect(await files.list('.hidden', signal)).toEqual([
|
||||
{ path: '.hidden', kind: 'directory' },
|
||||
{ path: '.hidden/secret.txt', kind: 'file' },
|
||||
])
|
||||
})
|
||||
|
||||
it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => {
|
||||
const root = await workspace()
|
||||
const capped = search(root, { maxEntries: 2 })
|
||||
const signal = new AbortController().signal
|
||||
expect(await capped.list('README', signal)).toEqual([
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
|
||||
const files = search(root)
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
await writeFile(join(root, 'fresh-file.ts'), 'fresh')
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
files.invalidate()
|
||||
expect(await files.list('fresh-file', signal)).toEqual([
|
||||
{ path: 'fresh-file.ts', kind: 'file' },
|
||||
])
|
||||
files.dispose()
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('cancels individual callers, skips missing directories, and validates limits', async () => {
|
||||
const root = await workspace()
|
||||
expect(() => search(root, { maxResults: 0 })).toThrow('maxResults')
|
||||
expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries')
|
||||
expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames')
|
||||
|
||||
const files = search(root)
|
||||
expect(await files.list('missing/', new AbortController().signal)).toEqual([])
|
||||
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort(new Error('pre-aborted'))
|
||||
await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted')
|
||||
|
||||
files.invalidate()
|
||||
const running = new AbortController()
|
||||
const pending = files.list('tui', running.signal)
|
||||
running.abort(new Error('superseded'))
|
||||
await expect(pending).rejects.toThrow('superseded')
|
||||
|
||||
files.invalidate()
|
||||
const nonErrorAbort = new AbortController()
|
||||
const nonErrorPending = files.list('tui', nonErrorAbort.signal)
|
||||
nonErrorAbort.abort('cancelled')
|
||||
await expect(nonErrorPending).rejects.toThrow('file search aborted')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=5 viewportRow=4 bufferRow=4
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
4| " @tsc "
|
||||
style 5-5 inverse
|
||||
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
6| " → File · terminal-special-case.t src/terminal-special-case.ts "
|
||||
style 1-32 fg=bright-blue
|
||||
7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
8-35| <blank>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
@@ -31,6 +32,7 @@ const CHECKPOINTS = [
|
||||
'retry-cancelled',
|
||||
'retry-exhausted',
|
||||
'banner-gradient',
|
||||
'file-autocomplete',
|
||||
'code-mode-pending',
|
||||
'dynamic-workflow-pending',
|
||||
'cordis-tools-pending',
|
||||
@@ -337,6 +339,24 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins fuzzy file candidates and the active path-only mention', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-snapshot-'))
|
||||
await mkdir(join(cwd, 'src'), { recursive: true })
|
||||
await writeFile(join(cwd, 'src', 'terminal-special-case.ts'), 'export const marker = true\n')
|
||||
await writeFile(join(cwd, 'src', 'terminal-state.ts'), 'export const state = true\n')
|
||||
const harness = await setupSnapshot({ cwd, formatCwd: () => '/workspace/project' })
|
||||
try {
|
||||
harness.terminal.send('@tsc')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await harness.terminal.snapshot()).toContain('File · terminal-special-case.t')
|
||||
})
|
||||
await checkpoint('file-autocomplete', harness.terminal)
|
||||
} finally {
|
||||
await disposeSnapshot(harness)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('pins Code Mode run_code with its production presenter', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
@@ -16,6 +17,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
createTuiChat,
|
||||
FILE_REFERENCE_PROMPT,
|
||||
mountTui,
|
||||
renderSkillInvocation,
|
||||
resolveTuiConfig,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
type TuiOverlaySession,
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
@@ -153,6 +156,9 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 20,
|
||||
modelDialogWidth: 72,
|
||||
modelDialogMaxHeight: 20,
|
||||
fileSearchMaxResults: 20,
|
||||
fileSearchMaxEntries: 10_000,
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules'],
|
||||
showHardwareCursor: false,
|
||||
color: true,
|
||||
truecolor: false,
|
||||
@@ -167,6 +173,9 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
fileSearchMaxResults: 7,
|
||||
fileSearchMaxEntries: 123,
|
||||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
truecolor: true,
|
||||
@@ -180,6 +189,9 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
fileSearchMaxResults: 7,
|
||||
fileSearchMaxEntries: 123,
|
||||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
truecolor: true,
|
||||
@@ -1063,6 +1075,125 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('fuzzy-completes files and directories while sending only the selected path text', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-'))
|
||||
await mkdir(join(cwd, 'src'), { recursive: true })
|
||||
await mkdir(join(cwd, 'docs'), { recursive: true })
|
||||
await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
|
||||
await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
|
||||
await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
|
||||
const result = await setup({
|
||||
cwd,
|
||||
tools: {
|
||||
read: {
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: () => Promise.resolve([]),
|
||||
},
|
||||
},
|
||||
})
|
||||
try {
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
expect(assembly.sections).toContainEqual({
|
||||
name: 'ui:tui-file-reference',
|
||||
text: FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
|
||||
result.terminal.send('@sfts')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('File · source-file.ts')
|
||||
})
|
||||
expect(result.terminal.output).toContain('src/source-file.ts')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toEqual([])
|
||||
|
||||
result.terminal.send('@do')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Folder · docs/')
|
||||
})
|
||||
result.terminal.send('\t')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('File · design notes.md')
|
||||
})
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||||
expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])
|
||||
expect(result.agent.sentOptions[1]?.contexts).toEqual([])
|
||||
|
||||
result.terminal.send('@unsafe')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('File · unsafe')
|
||||
result.terminal.send('\x03')
|
||||
} finally {
|
||||
await result.controller.dispose()
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
expect(assembly.sections).not.toContainEqual({
|
||||
name: 'ui:tui-file-reference',
|
||||
text: FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
await result.ctx.fiber.dispose()
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates failed file discovery from editor autocomplete', async () => {
|
||||
const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed'))
|
||||
const result = await setup()
|
||||
try {
|
||||
result.terminal.send('@failed')
|
||||
await vi.waitFor(() => { expect(list).toHaveBeenCalled() })
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
} finally {
|
||||
list.mockRestore()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows file-reference guidance only while read is visible to the agent', async () => {
|
||||
const read: ToolDefinition = {
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: () => Promise.resolve([]),
|
||||
}
|
||||
let visibility: 'none' | 'global' | 'agent' = 'none'
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', {
|
||||
get(name: string, scope?: Agent) {
|
||||
if (name !== 'read' || visibility === 'none') return undefined
|
||||
return (scope === undefined) === (visibility === 'global') ? read : undefined
|
||||
},
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
const fileReferenceText = async (): Promise<string | undefined> => {
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
|
||||
}
|
||||
try {
|
||||
expect(await fileReferenceText()).toBe('')
|
||||
visibility = 'global'
|
||||
expect(await fileReferenceText()).toBe('')
|
||||
visibility = 'agent'
|
||||
expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
|
||||
visibility = 'none'
|
||||
expect(await fileReferenceText()).toBe('')
|
||||
} finally {
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
it('escapes session autocomplete metadata while preserving the referenced session id', async () => {
|
||||
const unsafeId = SessionId('evil\x1b\x07\u009b\ns')
|
||||
const unsafeCwd = '/x/\x1b\x07\u009b\nf'
|
||||
|
||||
Reference in New Issue
Block a user