Merge remote-tracking branch 'origin/master' into pr/tui-details-fold

# Conflicts:
#	docs/cordis-catalog/services.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/index.ts
This commit is contained in:
Turtle
2026-08-03 15:55:22 +08:00
1329 changed files with 57136 additions and 10085 deletions

View File

@@ -20,6 +20,7 @@ import {
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,
brand: (value: string) => `brand:${value}`,
muted: (value: string) => `muted:${value}`,
dim: (value: string) => `dim:${value}`,
accent: (value: string) => `accent:${value}`,

View File

@@ -228,13 +228,14 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
steeredOptions.push(input)
const id = input.id
steeredIds.push(id)
return id
return { outcome: Promise.resolve({ status: 'admitted' as const, turn: 1, step: 1 }) }
},
inject(input) {
injected.push(input.content)
injectedOptions.push(input)
return input.id
},
reserveTurnAdmission: () => undefined,
cancel(cause) {
cancelled.push(cause)
},

View File

@@ -42,7 +42,7 @@ import {
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import {
appendAssistant,
appendUser,
@@ -138,6 +138,12 @@ async function tick(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
function promptWidth(output: string): number {
const row = output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
async function setup(options: TuiHarnessOptions = {}) {
const terminal = new FakeTerminal()
const exit = vi.fn()
@@ -1954,12 +1960,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
// cursor never shifts. Assert both the glyph slot and that constant width
// (color is off in this harness, so output carries no ANSI to strip).
const promptWidth = (): number => {
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
// Each phase swaps only the glyph character in the same slot at equal width.
const phaseGlyph: [() => void, string][] = [
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
@@ -1972,8 +1972,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
drive()
await tick()
expect(result.terminal.output).toContain(expected)
runningWidth ??= promptWidth()
expect(promptWidth()).toBe(runningWidth)
runningWidth ??= promptWidth(result.terminal.output)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
}
// Idle begins a fade-out; once it settles (clock past the fade window) the
@@ -1990,12 +1990,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
return rows.at(-1) ?? ''
}
expect(promptRow()).toContain('dsh > ')
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
expect(promptWidth()).toBe(runningWidth)
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙⊙]/u)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
await dispose(result)
})
it('shows a live standalone compaction in the fixed status area', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
const idleWidth = promptWidth(result.terminal.output)
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.output).toContain('Context being compacted 1.0s')
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
expect(result.terminal.progress.at(-1)).toBe(true)
clock = 1_450
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('Context being compacted 1.4s')
await dispose(result)
})
it('ignores a numbered compaction bracket while the status line is idle', async () => {
const result = await setup({ now: () => 1_000 })
result.session.append('compact/start', { turn: 1 })
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('fades a closed standalone compaction back to the plain caret', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
result.session.append('compact/end', { turn: null })
await tick()
clock = 2_000
await new Promise(resolve => setTimeout(resolve, 120))
result.terminal.output = ''
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
expect(result.terminal.output).not.toContain('Context being compacted')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('reports a failed standalone compaction when its live bracket closes', async () => {
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
result.session.append('compact/start', { turn: null })
result.terminal.output = ''
result.session.append('compact/end', { turn: null, error: 'summary failed' })
await tick()
expect(result.terminal.output).toContain('Compaction failed: summary failed')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('preserves live compaction progress across an idle status edge', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
result.ctx.emit('agent/status', result.agent, 'idle')
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('keeps a running turn phase glyph ahead of standalone compaction', async () => {
let clock = 0
const result = await setup({ status: 'running', now: () => clock })
clock = 1_000
result.terminal.output = ''
result.session.append('compact/start', { turn: null })
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
result.session.append('compact/end', { turn: null })
await tick()
result.terminal.output = ''
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('treats duplicate live compaction starts as one owned bracket', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
let clock = 0
try {
result = await setup({ omitInitialLifecycle: true, now: () => clock })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
expect(intervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
result.session.append('compact/end', { turn: null })
await tick()
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
didDispose = true
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
it('does not show compaction progress for a resumed orphaned start', async () => {
const result = await setup({
omitInitialLifecycle: true,
now: () => 1_000,
beforeMount(session) {
session.append('compact/start', { turn: null })
},
})
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.output).not.toContain('Context being compacted')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('releases the live compaction timer and progress bit on dispose', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
try {
result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
expect(intervalSpy).toHaveBeenCalledOnce()
await dispose(result)
didDispose = true
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
// Extract the running glyph's interpolated gray channel from a rendered frame.
const glyphGray = (frame: string): number => {
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
@@ -2103,7 +2280,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('shows the plain prompt caret while idle', async () => {
const result = await setup({ now: () => 0 })
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
await dispose(result)
})
@@ -4115,8 +4292,9 @@ describe('skill slash command', () => {
source: 'runtime',
content: 'Dynamic body.',
})
await tick()
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
})
result.terminal.send('\x03')
disposeSkill()
@@ -5401,6 +5579,7 @@ describe('TUI extension service', () => {
host.theme.accent(`${label} plugin overlay`),
[
host.theme.text('text'),
host.theme.brand('brand'),
host.theme.dim('dim'),
host.theme.success('success'),
host.theme.warning('warning'),
@@ -5568,7 +5747,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -5593,7 +5772,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -5628,14 +5807,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -5666,7 +5845,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -5710,7 +5889,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }
@@ -5778,6 +5957,10 @@ describe('terminal mounting', () => {
await dispose(result)
})
it('uses the official DeepSeek SVG ink for truecolor brand art', () => {
expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m')
})
it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => {
const result = await setup({ config: { theme: { color: true } } })
// `dim` is scheme-independent (SGR 2 over the default foreground), so the