Merge remote-tracking branch 'origin/master' into fix/tui-cwd-first-frame-race

This commit is contained in:
Chinesezjc
2026-07-24 12:47:01 +08:00
790 changed files with 36599 additions and 8898 deletions
+587
View File
@@ -0,0 +1,587 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
Component,
OverlayHandle,
} from '@earendil-works/pi-tui'
import type {
TuiComponent,
TuiOverlayHost,
TuiOverlayOptions,
TuiOverlaySession,
TuiTheme,
} from '../src/extension.ts'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
type TuiOverlayDriver,
} from '../src/overlay-manager.ts'
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,
muted: (value: string) => `muted:${value}`,
dim: (value: string) => `dim:${value}`,
accent: (value: string) => `accent:${value}`,
success: (value: string) => `success:${value}`,
warning: (value: string) => `warning:${value}`,
error: (value: string) => `error:${value}`,
bold: (value: string) => `bold:${value}`,
})
interface ShownOverlay {
component: Component
options: TuiOverlayOptions | undefined
hidden: boolean
focused: boolean
}
interface DriverFixture {
driver: TuiOverlayDriver
shown: ShownOverlay[]
errors: unknown[]
invalidations: number
showError?: unknown
onShow?: (component: Component) => void
}
function driverFixture(): DriverFixture {
const fixture: DriverFixture = {
shown: [],
errors: [],
invalidations: 0,
driver: undefined as never,
}
fixture.driver = {
viewport: () => ({ columns: 96, rows: 32 }),
theme: () => theme,
display: value => `safe:${value}`,
show(component, options) {
if (fixture.showError !== undefined) throw fixture.showError
const shown: ShownOverlay = {
component,
options,
hidden: false,
focused: true,
}
fixture.shown.push(shown)
const handle: OverlayHandle = {
hide() {
shown.hidden = true
shown.focused = false
},
setHidden(hidden) {
shown.hidden = hidden
},
isHidden: () => shown.hidden,
focus() {
shown.focused = true
},
unfocus() {
shown.focused = false
},
isFocused: () => shown.focused,
}
fixture.onShow?.(component)
return handle
},
invalidate() {
fixture.invalidations += 1
},
reportError(error) {
fixture.errors.push(error)
},
}
return fixture
}
function component(lines = ['overlay']): TuiComponent {
return {
render: () => lines,
invalidate() {},
}
}
async function microtask(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('TuiOverlayManager', () => {
it('serializes overlays, exposes the constrained host, and settles normal close once', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
let firstHost: TuiOverlayHost | undefined
const firstComponent = {
focused: false,
wantsKeyRelease: true,
inputs: [] as string[],
invalidated: 0,
render: (width: number) => [`first:${String(width)}`],
handleInput(data: string) {
this.inputs.push(data)
},
invalidate() {
this.invalidated += 1
},
}
const first = manager.open({
create(host) {
firstHost = host
return firstComponent
},
options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } },
})
const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } }
const second = manager.open({
create: () => component(['second']),
options: secondOptions,
})
;(secondOptions as { width: number }).width = 80
;(secondOptions.margin as { bottom: number }).bottom = 4
expect(manager.hasActiveOverlay()).toBe(true)
expect(first.state).toBe('active')
expect(second.state).toBe('queued')
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.options).toEqual({
width: '75%',
minWidth: 24,
maxHeight: 20,
anchor: 'center',
margin: { bottom: 1 },
})
expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 })
expect(Object.isFrozen(firstHost?.viewport)).toBe(true)
expect(firstHost?.theme.accent('x')).toBe('accent:x')
expect(firstHost?.display('\u001b')).toBe('safe:\u001b')
firstHost?.invalidate()
expect(firstComponent.invalidated).toBe(1)
expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40'])
fixture.shown[0]!.component.handleInput?.('x')
fixture.shown[0]!.component.invalidate()
expect(firstComponent.inputs).toEqual(['x'])
expect(firstComponent.invalidated).toBe(2)
expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true)
;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true
expect(firstComponent.focused).toBe(true)
expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true)
const firstOutcome = await first.close()
expect(firstOutcome).toEqual({ reason: 'closed' })
expect(await first.close()).toBe(firstOutcome)
expect(firstHost?.signal.aborted).toBe(true)
const beforeClosedInvalidation = fixture.invalidations
firstHost?.invalidate()
expect(fixture.invalidations).toBe(beforeClosedInvalidation)
await microtask()
expect(first.state).toBe('closed')
expect(second.state).toBe('active')
expect(fixture.shown[0]?.hidden).toBe(true)
expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } })
expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true)
expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true)
expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false)
expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false)
;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true
fixture.shown[1]!.component.handleInput?.('ignored')
await second.close()
await microtask()
const numericMargin = manager.open({
create: () => component(['numeric margin']),
options: { margin: 1 },
})
expect(fixture.shown[2]?.options).toEqual({ margin: 1 })
await numericMargin.close()
await microtask()
const emptyOptions = manager.open({
create: () => component(['empty options']),
options: {},
})
expect(fixture.shown[3]?.options).toEqual({})
await emptyOptions.close()
await microtask()
expect(manager.hasActiveOverlay()).toBe(false)
await manager.dispose()
await manager.dispose()
})
it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const preAborted = new AbortController()
preAborted.abort()
const pre = manager.open({
signal: preAborted.signal,
create: () => component(['never']),
})
expect(await pre.closed).toEqual({ reason: 'aborted' })
expect(fixture.shown).toHaveLength(0)
const activeAbort = new AbortController()
let activeHost: TuiOverlayHost | undefined
const active = manager.open({
signal: activeAbort.signal,
create(host) {
activeHost = host
return component(['active'])
},
})
const queuedAbort = new AbortController()
const queued = manager.open({
signal: queuedAbort.signal,
create: () => component(['queued']),
})
queuedAbort.abort()
expect(await queued.closed).toEqual({ reason: 'aborted' })
expect(queued.state).toBe('closed')
activeAbort.abort()
expect(await active.closed).toEqual({ reason: 'aborted' })
expect(activeHost?.signal.aborted).toBe(true)
await microtask()
expect(fixture.shown).toHaveLength(1)
expect(manager.hasActiveOverlay()).toBe(false)
})
it('does not mount entries closed or aborted during component construction', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const closed = manager.open({
create(host) {
host.invalidate()
host.close()
return component(['closed during construction'])
},
})
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
const controller = new AbortController()
const aborted = manager.open({
signal: controller.signal,
create() {
controller.abort()
return component(['aborted during construction'])
},
})
await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' })
const after = manager.open({ create: () => component(['after construction closes']) })
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes'])
await after.close()
})
it('hides a handle returned after reentrant closure during mounting', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
fixture.onShow = (shown) => {
;(shown as Component & { focused: boolean }).focused = true
}
const closed = manager.open({
create(host) {
return {
get focused(): boolean {
return false
},
set focused(_value: boolean) {
host.close()
},
render: () => ['closed during mount'],
invalidate() {},
}
},
})
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
expect(fixture.shown[0]?.hidden).toBe(true)
expect(manager.hasActiveOverlay()).toBe(false)
delete fixture.onShow
const after = manager.open({ create: () => component(['after mount close']) })
expect(fixture.shown[1]?.hidden).toBe(false)
expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close'])
await after.close()
})
it('stops admission and disposes active and queued overlays with the TUI', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const active = manager.open({ create: () => component(['active']) })
const queued = manager.open({ create: () => component(['queued']) })
manager.beginShutdown()
expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down')
await manager.dispose()
expect(await active.closed).toEqual({ reason: 'tui-disposed' })
expect(await queued.closed).toEqual({ reason: 'tui-disposed' })
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.hidden).toBe(true)
await manager.dispose()
})
it('contains factory, mount, render, input, and invalidation failures', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const factoryError = new Error('factory failed')
const factory = manager.open({
create() {
throw factoryError
},
})
const afterFactory = manager.open({ create: () => component(['after factory']) })
expect(await factory.closed).toEqual({ reason: 'error', error: factoryError })
await microtask()
expect(afterFactory.state).toBe('active')
await afterFactory.close()
await microtask()
const showError = new Error('show failed')
fixture.showError = showError
const show = manager.open({ create: () => component(['show']) })
expect(await show.closed).toEqual({ reason: 'error', error: showError })
delete fixture.showError
await microtask()
const renderError = new Error('render failed')
const rendering = manager.open({
create: () => ({
render() {
throw renderError
},
invalidate() {
throw new Error('must be suppressed after the first failure')
},
}),
})
const renderComponent = fixture.shown.at(-1)!.component
expect(renderComponent.render(20)).toEqual([])
renderComponent.invalidate()
expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1)
expect(await rendering.closed).toEqual({ reason: 'error', error: renderError })
await microtask()
const inputError = new Error('input failed')
const input = manager.open({
create: () => ({
render: () => ['input'],
handleInput() {
throw inputError
},
invalidate() {},
}),
})
fixture.shown.at(-1)!.component.handleInput?.('x')
expect(await input.closed).toEqual({ reason: 'error', error: inputError })
await microtask()
const invalidateError = new Error('invalidate failed')
let invalidatingHost: TuiOverlayHost | undefined
const invalidating = manager.open({
create(host) {
invalidatingHost = host
return {
render: () => ['invalidate'],
invalidate() {
throw invalidateError
},
}
},
})
const invalidationsBeforeFailure = fixture.invalidations
invalidatingHost?.invalidate()
invalidatingHost?.invalidate()
expect(fixture.invalidations).toBe(invalidationsBeforeFailure)
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError })
await microtask()
const focusError = new Error('focus failed')
const focus = manager.open({
create: () => ({
get focused(): boolean {
throw focusError
},
set focused(_value: boolean) {
throw new Error('focus assignment failed')
},
get wantsKeyRelease(): boolean {
throw new Error('key-release query failed')
},
render: () => ['focus'],
invalidate() {},
}),
})
const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean }
expect(guarded.focused).toBe(false)
guarded.focused = true
expect(guarded.wantsKeyRelease).toBe(false)
expect(await focus.closed).toEqual({ reason: 'error', error: focusError })
expect(fixture.errors).toEqual([
factoryError,
showError,
renderError,
inputError,
invalidateError,
focusError,
])
})
it('contains host redraw, overlay removal, and error-reporter failures', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
let host: TuiOverlayHost | undefined
const invalidationError = new Error('redraw failed')
let redrawFails = false
fixture.driver.invalidate = () => {
if (redrawFails) throw invalidationError
}
fixture.driver.reportError = () => { throw new Error('report failed') }
const invalidating = manager.open({
create(value) {
host = value
return component()
},
})
redrawFails = true
host?.invalidate()
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError })
await microtask()
redrawFails = false
fixture.driver.invalidate = () => {}
const hideError = new Error('hide failed')
fixture.driver.show = () => ({
hide() { throw hideError },
setHidden() {},
isHidden: () => false,
focus() {},
unfocus() {},
isFocused: () => true,
})
const hiding = manager.open({
create(value) {
host = value
return component()
},
})
host?.close()
expect(await hiding.closed).toEqual({ reason: 'closed' })
})
})
describe('TuiExtensionService', () => {
it('binds an open overlay to the calling plugin fiber', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const agent = {} as Agent
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, manager)
})
await provider
let session: TuiOverlaySession | undefined
let host: TuiOverlayHost | undefined
const consumer = ctx.inject(['tui'], (consumerCtx) => {
expect(consumerCtx.tui.agent).toBe(agent)
session = consumerCtx.tui.openOverlay({
create(value) {
host = value
return component(['plugin'])
},
})
})
await consumer
expect(session?.state).toBe('active')
await consumer.dispose()
expect(await session?.closed).toEqual({ reason: 'owner-disposed' })
expect(host?.signal.aborted).toBe(true)
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
it('unloads and reloads dependent plugins with the mounted TUI service', async () => {
const ctx = new Context()
const agent = {} as Agent
const sessions: TuiOverlaySession[] = []
let starts = 0
const consumer = ctx.inject(['tui'], (consumerCtx) => {
starts += 1
sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) }))
})
const firstFixture = driverFixture()
const firstManager = new TuiOverlayManager(firstFixture.driver)
const firstProvider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, firstManager)
})
await firstProvider
await consumer
expect(starts).toBe(1)
await firstProvider.dispose()
expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' })
const secondFixture = driverFixture()
const secondManager = new TuiOverlayManager(secondFixture.driver)
const secondProvider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, secondManager)
})
await secondProvider
await vi.waitFor(() => { expect(starts).toBe(2) })
await sessions[1]?.close()
await consumer.dispose()
await secondProvider.dispose()
await firstManager.dispose()
await secondManager.dispose()
await ctx.fiber.dispose()
})
it('rejects new service work after terminal shutdown begins', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
})
await provider
manager.beginShutdown()
const consumer = ctx.inject(['tui'], (consumerCtx) => {
expect(() => consumerCtx.tui.openOverlay({ create: () => component() }))
.toThrow('TUI is shutting down')
})
await consumer
await consumer.dispose()
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
it('does not admit an overlay when called from an unloading plugin', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
})
await provider
let error: unknown
const consumer = ctx.inject(['tui'], (consumerCtx) => {
consumerCtx.effect(() => () => {
try {
consumerCtx.tui.openOverlay({ create: () => component() })
} catch (value) {
error = value
}
})
})
await consumer
await consumer.dispose()
expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' })
expect(fixture.shown).toHaveLength(0)
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
})
@@ -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')
})
})
+16
View File
@@ -0,0 +1,16 @@
import SessionQueryService from '@deepseek-ai/dsh-session-query'
/** Test-only backend-independent query service. */
export class TestSessionQueryService extends SessionQueryService {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
}
}
@@ -11,10 +11,10 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import CommandService from '@deepseek-ai/dsh-commands'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import { createTuiChat } from '../src/index.ts'
import { HeadlessTerminal } from './headless-terminal.ts'
import { TestSessionQueryService } from './session-query.ts'
const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
@@ -57,7 +57,7 @@ describe('TUI session-reference snapshot', () => {
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const adapter = new SnapshotAdapter()
@@ -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>
+24 -4
View File
@@ -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'
@@ -6,8 +7,7 @@ import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -32,6 +32,7 @@ const CHECKPOINTS = [
'retry-cancelled',
'retry-exhausted',
'banner-gradient',
'file-autocomplete',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -157,7 +158,7 @@ function appendToolResult(
session: Session,
id: string,
content: ContentBlock[],
options: { isError?: boolean; meta?: unknown } = {},
options: { isError?: boolean; meta?: JsonValue } = {},
): void {
session.append('tool/result', {
turn: 1,
@@ -178,6 +179,7 @@ function visualTool(
name,
description: `${name} snapshot fixture`,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: () => Promise.resolve([]),
presentCall: call,
...result === undefined ? {} : { presentResult: result },
@@ -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 = {
+306 -18
View File
@@ -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'
@@ -11,16 +12,19 @@ import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
resolveTuiConfig,
type TuiOverlayHost,
type TuiOverlaySession,
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
import {
appendAssistant,
appendUser,
@@ -28,6 +32,12 @@ import {
disposeTuiTestHarness,
type TuiHarnessOptions,
} from './harness.ts'
import { TestSessionQueryService } from './session-query.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
class FakeTerminal implements Terminal {
columns = 88
@@ -146,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,
@@ -160,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,
@@ -173,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,
@@ -1024,7 +1043,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
sourceId = source.id
@@ -1068,13 +1087,132 @@ 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'
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
appendUser(source, 'safe background')
@@ -1106,7 +1244,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
@@ -1171,7 +1309,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
@@ -1291,7 +1429,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
@@ -1341,7 +1479,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const lateSuccess = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
@@ -1391,6 +1529,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('advertised by multiple providers')
expect(result.terminal.output).toContain('already alpha/a1')
result.terminal.send('/model')
result.terminal.send('\r')
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select model')
result.terminal.send('\x1b')
await tick()
result.agent.status = 'running'
result.terminal.send('/model')
result.terminal.send('\r')
@@ -1571,6 +1718,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
})
result.terminal.send('/plugin-ch')
await tick()
expect(result.terminal.output).toContain('<value> — Run a plugin command')
result.terminal.send('\x03')
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
await tick()
@@ -1858,17 +2010,17 @@ describe('renderSkillInvocation', () => {
describe('tool cards and surface replay', () => {
const tools: Record<string, ToolDefinition> = {
bash: {
name: 'bash', description: '', parameters: {}, execute: async () => [],
name: 'bash', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
},
signal: {
name: 'signal', description: '', parameters: {}, execute: async () => [],
name: 'signal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
},
edit: {
name: 'edit', description: '', parameters: {}, execute: async () => [],
name: 'edit', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({
card: 'diff',
title: 'Edit files',
@@ -1880,35 +2032,35 @@ describe('tool cards and surface replay', () => {
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
},
generic: {
name: 'generic', description: '', parameters: {}, execute: async () => [],
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
},
throwing: {
name: 'throwing', description: '', parameters: {}, execute: async () => [],
name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => { throw new Error('call presenter boom') },
presentResult: () => { throw new Error('result presenter boom') },
},
rawTerminal: {
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
},
undefinedViews: {
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => undefined,
presentResult: () => undefined,
},
empty: {
name: 'empty', description: '', parameters: {}, execute: async () => [],
name: 'empty', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
},
terminalResult: {
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
name: 'terminalResult', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
},
symbolic: {
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
},
}
@@ -2205,6 +2357,141 @@ describe('TUI user-interaction dialogs', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
await result.ctx.fiber.dispose()
})
it('rejects malformed questions when a dialog cannot be constructed', async () => {
const result = await setup()
const broken = {
id: 'broken',
question: 'Broken question',
get options(): never {
throw new Error('question setup failed')
},
}
const answer = result.ctx.userInteraction.ask({ questions: [broken] })
await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed')
await tick()
expect(result.terminal.output).toContain('TUI overlay failed: question setup failed')
await dispose(result)
})
})
describe('TUI extension service', () => {
it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => {
const result = await setup()
const sessions: TuiOverlaySession[] = []
const hosts: TuiOverlayHost[] = []
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
expect(pluginCtx.tui.agent).toBe(result.agent)
for (const label of ['first', 'second']) {
sessions.push(pluginCtx.tui.openOverlay({
create(host) {
hosts.push(host)
return {
focused: false,
render: width => [
host.theme.accent(`${label} plugin overlay`),
[
host.theme.text('text'),
host.theme.muted('muted'),
host.theme.dim('dim'),
host.theme.success('success'),
host.theme.warning('warning'),
host.theme.error('error'),
host.theme.bold('bold'),
].join(' '),
`${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`,
],
handleInput(data) {
host.invalidate()
if (data === label[0]) host.close()
},
invalidate() {},
}
},
options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 },
}))
}
})
await plugin
await vi.waitFor(() => {
expect(result.terminal.output).toContain('first plugin overlay')
})
expect(sessions.map(session => session.state)).toEqual(['active', 'queued'])
expect(hosts).toHaveLength(1)
const question = result.ctx.userInteraction.ask({
questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }],
})
result.terminal.send('f')
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' })
await vi.waitFor(() => {
expect(result.terminal.output).toContain('second plugin overlay')
})
expect(hosts).toHaveLength(2)
expect(sessions[1]?.state).toBe('active')
result.terminal.send('s')
await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' })
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Question after plugins?')
})
result.terminal.send('\r')
await expect(question).resolves.toEqual({
answers: [{ id: 'after-plugin', selected: ['Yes'] }],
})
result.terminal.send('editor works again')
result.terminal.send('\r')
expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }])
await plugin.dispose()
await dispose(result)
})
it('unloads and reloads dependent plugins with the mounted TUI', async () => {
const result = await setup()
const sessions: TuiOverlaySession[] = []
const signals: AbortSignal[] = []
let starts = 0
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
starts += 1
sessions.push(pluginCtx.tui.openOverlay({
create(host) {
signals.push(host.signal)
return {
render: () => [`plugin mount ${String(starts)}`],
invalidate() {},
}
},
}))
})
await plugin
await vi.waitFor(() => {
expect(result.terminal.output).toContain('plugin mount 1')
})
await result.controller.dispose()
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' })
expect(signals[0]?.aborted).toBe(true)
expect(result.ctx.get('tui')).toBeUndefined()
const secondTerminal = new FakeTerminal()
const secondController = createTuiChat(result.ctx, {
sessionId: result.agent.id,
color: false,
welcome: 'Mounted again.',
}, {
terminal: secondTerminal,
exit: vi.fn(),
})
await vi.waitFor(() => {
expect(starts).toBe(2)
expect(secondTerminal.output).toContain('plugin mount 2')
})
await sessions[1]?.close()
await secondController.dispose()
await plugin.dispose()
await result.ctx.fiber.dispose()
})
})
describe('terminal mounting', () => {
@@ -2367,6 +2654,7 @@ describe('terminal mounting', () => {
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
expect(ctx.get('tui')).toBeUndefined()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
session.append('assistant/chunk', {