Merge remote-tracking branch 'origin/master' into fix/tui-cwd-first-frame-race
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user