Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/cordis.yml
#	apps/cli/package.json
#	apps/cli/src/bin.ts
#	apps/cli/tests/args.spec.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/ui/tui/README.i18n.yaml
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-30 19:54:30 +08:00
274 changed files with 4018 additions and 3096 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: e6c2ed05033064fdf687f07f5f642e286368cf53
README.zh.md: 76296e31318aed922f20aa8868de13b445f935e9
README.md: 4e2358a11b79c8638183e83ef4f9643cba210412
README.zh.md: 4f40ba600d05c616fc2a59b830fd99c36f976893

View File

@@ -75,7 +75,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
## Color

View File

@@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI再挂载由配置创建的 agent使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出而不是留下空白终端。dispose资源释放会停止接收扩展请求卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader拒绝待处理问题排空终端输入恢复终端状态注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI再挂载由配置创建的 agent使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出而不是留下空白终端。dispose资源释放会停止接收扩展请求卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader拒绝待处理问题排空终端输入恢复终端状态注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
## 颜色

View File

@@ -29,7 +29,13 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
readonly agent: Agent
readonly runtime: TuiRuntime
readonly sessionQuery: SessionQueryService | undefined
/**
* The optional session-query service, re-read at each use. `sessionQuery` is
* mounted by an independent plugin, and a flat config tree gives no ordering
* guarantee between it and this front door, so a value captured once at
* construction can be `undefined` even though the service arrives moments later.
*/
readonly sessionQuery: (this: void) => SessionQueryService | undefined
readonly ui: TUI
readonly editor: HintEditor
/** Current agent status, re-read at each resume precondition point. */
@@ -74,9 +80,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
events: live.events.map(event => structuredClone(event)),
}
} else {
/* v8 ignore next -- caller checks the optional service before mapping records */
if (sessionQuery === undefined) throw new Error('session query is unavailable')
snapshot = await sessionQuery.readSession(record.header.id)
const readQuery = sessionQuery()
/* v8 ignore start -- caller proves the optional service before mapping records */
if (readQuery === undefined) throw new Error('session query is unavailable')
/* v8 ignore stop */
snapshot = await readQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
@@ -104,11 +112,13 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
* resolve the exact identity and workspace the host will re-exec into.
*/
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
const query = sessionQuery()
/* v8 ignore start -- showResume alone calls this after proving the optional service exists */
if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
/* v8 ignore stop */
const initialStatus = deps.agentStatus()
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
@@ -177,13 +187,14 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
return
}
if (sessionQuery === undefined) {
const listQuery = sessionQuery()
if (listQuery === undefined) {
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
return
}
const scan = ++resumeScan
void resumeOverlay?.close()
void sessionQuery.listSessions().then(async (records) => {
void listQuery.listSessions().then(async (records) => {
if (deps.isDisposed() || scan !== resumeScan) return
// Every workspace in the store is summarized; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.

View File

@@ -19,7 +19,7 @@ import {
type SlashCommand,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import { Service, type Context, type Fiber } from 'cordis'
import { Service, type Context, type Fiber, type FiberState } from 'cordis'
import {
assembleContextFor,
installAgentLlmTarget,
@@ -56,6 +56,7 @@ import {
TuiExtensionServiceImpl,
TuiOverlayManager,
} from './extension/overlay-manager.ts'
import {
parseTuiPromptTemplate,
renderTuiPromptTemplate,
@@ -171,6 +172,9 @@ export type {
TuiViewport,
} from './extension/types.ts'
/** First terminal Cordis state: FAILED, DISPOSED, and UNLOADING are unusable. */
const FIBER_FAILED = 3 as FiberState.FAILED
declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
@@ -183,8 +187,6 @@ declare module 'cordis' {
tuiGoodbyeMessage: string | undefined
/** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */
tuiInitialSkill: string | undefined
/** Launcher-owned session-store root the app bundle defaults to; absent keeps the bundle's project-local default. */
launcherSessionsRoot: string | undefined
}
}
@@ -229,16 +231,6 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
*/
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(SESSIONS_ROOT_KEY, root)`) to supply its session-store root as
* the app bundle's default persistence root. Shared-store policy (one store
* across every cwd) belongs to the launcher — the dsh CLI resolves it under the
* Harness home — never to a plugin; a bundle without this slot keeps its own
* project-local default, and an explicit `persistenceRoot` config still wins.
*/
export const SESSIONS_ROOT_KEY = 'launcherSessionsRoot'
/**
* Optional terminal-local interaction service provided by one mounted TUI.
*
@@ -307,7 +299,6 @@ export function createTuiChat(
const sessionId = SessionId(config.sessionId ?? 'main')
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
const sessionQuery = ctx.get('sessionQuery')
const resolved = resolveTuiConfig(config)
const palette = createPalette(resolved.theme.color)
const mdTheme = markdownTheme(palette)
@@ -854,7 +845,14 @@ export function createTuiChat(
resolved,
palette,
overlayManager,
sessionQuery,
// Optional and independently mounted. Cordis transiently leaves this sibling
// non-ACTIVE during command callbacks, so the non-strict read is intentional;
// terminal fiber states still exclude failed, closing, and closed providers.
sessionQuery: () => {
const implementation = ctx.reflect._getImpl('sessionQuery', false)
if (implementation === undefined || implementation.fiber.state >= FIBER_FAILED) return undefined
return ctx.get('sessionQuery', false)
},
ui,
editor,
appendNotice,
@@ -1663,9 +1661,35 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
if (existing !== undefined) start(existing)
}
const ROOT_DISPOSE_TIMEOUT_MS = 5_000
/**
* Dispose the whole application before process exit, with a bounded fallback.
* @param ctx - The TUI plugin context whose root owns sibling resources.
* @param code - Process status to report.
* @param exit - Exit boundary, replaceable by tests.
*/
export function disposeRootAndExit(
ctx: Context,
code: number,
exit: (status: number) => void = (status) => { process.exit(status) },
): void {
let exited = false
const exitOnce = (): void => {
if (exited) return
exited = true
exit(code)
}
const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS)
void ctx.root.fiber.dispose().then(
() => { clearTimeout(timeout); exitOnce() },
() => { clearTimeout(timeout); exitOnce() },
)
}
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
and the tui-agent PTY smoke covers the real entry */
and apps/cli PTY smokes cover the real entry */
export function apply(ctx: Context, config: Config): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
@@ -1685,7 +1709,7 @@ export function apply(ctx: Context, config: Config): void {
initialSkill === undefined ? {} : { initialSkill },
), {
terminal: new ProcessTerminal(),
exit: code => process.exit(code),
exit: (code) => { disposeRootAndExit(ctx, code) },
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
})

View File

@@ -29,6 +29,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
disposeRootAndExit,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
@@ -502,6 +503,41 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it('allows a transient session-query state but rejects a terminal state', async () => {
let queryCtx: Context | undefined
let listCalls = 0
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
await ctx.plugin({
apply(child: Context) {
queryCtx = child
child.provide('sessionQuery', {
listSessions: async () => { listCalls++; return [] },
} as never)
},
})
},
})
if (queryCtx === undefined) throw new Error('query provider did not mount')
const activeState = queryCtx.fiber.state
queryCtx.fiber.state = 0
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(listCalls).toBe(1)
result.terminal.send('\u001B')
await tick()
queryCtx.fiber.state = 5
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('session query is not mounted')
expect(listCalls).toBe(1)
queryCtx.fiber.state = activeState
await dispose(result)
})
it('keeps persisted query records readable without a persistence service', async () => {
const target = header('query-only-persisted', 10, '/workspace')
const result = await setup({
@@ -5008,6 +5044,59 @@ describe('TUI extension service', () => {
})
})
describe('application exit', () => {
it('disposes the root fiber rather than only the TUI child before exiting', async () => {
const rootDispose = vi.fn(() => Promise.resolve())
const childDispose = vi.fn(() => Promise.resolve())
const ctx = {
root: { fiber: { dispose: rootDispose } },
fiber: { dispose: childDispose },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 7, exit)
await Promise.resolve()
expect(rootDispose).toHaveBeenCalledOnce()
expect(childDispose).not.toHaveBeenCalled()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when root disposal does not settle', async () => {
vi.useFakeTimers()
try {
let settle!: () => void
const disposal = new Promise<void>((resolve) => { settle = resolve })
const ctx = {
root: { fiber: { dispose: () => disposal } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 9, exit)
await vi.advanceTimersByTimeAsync(4_999)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(9)
settle()
await disposal
await Promise.resolve()
expect(exit).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('exits after a rejected root disposal without an unhandled rejection', async () => {
const ctx = {
root: { fiber: { dispose: () => Promise.reject(new Error('cleanup failed')) } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 5, exit)
await Promise.resolve()
await Promise.resolve()
expect(exit).toHaveBeenCalledWith(5)
})
})
describe('terminal mounting', () => {
it('starts immediately when the configured agent already exists', async () => {
const ctx = new Context()