Merge origin/master into codex/sandbox-policy-context

This commit is contained in:
NI0317
2026-07-31 15:03:11 +08:00
196 changed files with 4309 additions and 670 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/permission/README.md
README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4
README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa
README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a
README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184

View File

@@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
@@ -23,3 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.

View File

@@ -6,7 +6,9 @@
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset``sandbox/mode``approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed包括由 `session/end-seed` 标记的显式空 seed都会保留其有效权限只补齐缺失的持久事实而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话因此 HMR热模块替换会固定插件缺席期间创建的所有会话
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key单元折叠三个全量值旋钮事件在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
@@ -23,3 +25,4 @@
- **只组合两个机制调节项**preset 选择沙箱模式和审批策略agent智能体profile 选择尚未纳入 `PresetSpec`
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。

View File

@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -58,6 +59,7 @@
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a
import type {} from '@deepseek-ai/dsh-bash'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-commands'
@@ -68,6 +69,9 @@ export interface PresetSpec {
*/
export const CUSTOM_PRESET = 'custom'
/** Settings namespace carrying the default for future sessions. */
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
/**
* Fold the last selected preset from the durable log; replay needs no catch-up
* state.
@@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
return state
}
/** The {@link PermissionService} config: the deployment's preset table. */
/** User setting resolved when a new session receives its initial permission. */
export interface PermissionSettings {
/** Preset pinned into a newly created session. */
defaultPreset: string
}
/** The {@link PermissionService} config: preset table and composition default. */
export interface Config {
/**
* The preset table: name → knob bundle. Defaults to `workspace-write`
@@ -134,6 +144,11 @@ export interface Config {
* never). The name `custom` is reserved for the derived not-a-preset state.
*/
presets?: Record<string, PresetSpec>
/**
* Default for new sessions. When omitted, the preset matching the composed
* sandbox and approval defaults is used.
*/
defaultPreset?: string
}
/**
@@ -159,11 +174,13 @@ export class PermissionService extends Service {
name: 'danger-full-access', description: 'Full file access without approval prompts.',
},
}),
defaultPreset: z.string(),
})
static inject = ['bash', 'approval']
static inject = ['bash', 'approval', 'sessions']
private readonly presets: Record<string, PresetSpec>
private defaultSettings: () => PermissionSettings
constructor(ctx: Context, config: Config) {
super(ctx, 'permission')
@@ -175,6 +192,37 @@ export class PermissionService extends Service {
if (ctx.bash.sandboxMode === undefined) {
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
}
const inferredDefault = this.derive(EMPTY_KNOBS)
const defaultPreset = config.defaultPreset ?? inferredDefault
if (defaultPreset === CUSTOM_PRESET) {
throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly')
}
this.resolve(defaultPreset)
const baseSettings: PermissionSettings = { defaultPreset }
this.defaultSettings = () => baseSettings
const presetChoices = this.names.map((name) => {
const choice = z.const(name)
const label = this.presets[name]?.name
return label === undefined ? choice : choice.description(label)
})
const settingsSchema: z<PermissionSettings> = z.object({
defaultPreset: z.union(presetChoices).required(),
})
installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, {
setSource: (current) => {
this.defaultSettings = current
},
// The source thunk reads the latest scope snapshot at session creation;
// no process-level registration needs replacement on change.
onChange: () => {},
})
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
for (const session of ctx.sessions.list()) {
this.pinInitialPermission(session)
}
// The permissions projection unit: fold the three whole-value knob
// events; view derives the select over the composition defaults this
@@ -237,6 +285,15 @@ export class PermissionService extends Service {
return Object.keys(this.presets)
}
/**
* The preset currently selected as the default for future sessions.
* @returns the resolved settings value, or the composition default without
* a mounted settings provider.
*/
get defaultPreset(): string {
return this.defaultSettings().defaultPreset
}
/**
* Resolve the preset matching the effective knob values. A still-matching
* last selection wins shared-bundle ties; otherwise the first table match
@@ -328,6 +385,44 @@ export class PermissionService extends Service {
setApprovalPolicy(session, spec.approval)
}
}
/**
* Fill every missing permission fact before a session is published. A
* genuinely fresh session uses the current user default; seeded or partially
* initialized sessions preserve their effective knob values and only gain
* the missing durable facts.
*/
private pinInitialPermission(session: Session): void {
const events = session.events
const selected = effectivePermissionPreset(events)
const sandbox = effectiveSandboxMode(events)
const approval = effectiveApprovalPolicy(events)
const seeded = events.some(event => event.type === 'session/end-seed')
if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
const name = this.defaultPreset
const spec = this.resolve(name)
session.append('permission/preset', { preset: name })
setSandboxMode(session, spec.sandbox)
setApprovalPolicy(session, spec.approval)
return
}
const state: KnobState = {
preset: selected ?? null,
sandbox: sandbox ?? null,
approval: approval ?? null,
}
const effective = this.derive(state)
if (selected === undefined && effective !== CUSTOM_PRESET) {
session.append('permission/preset', { preset: effective })
}
if (sandbox === undefined) {
setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
}
if (approval === undefined) {
setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
}
}
}
export default PermissionService

View File

@@ -1,10 +1,29 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
import PermissionService, {
CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE,
} from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
/** Writable memory provider for the permission/settings lifecycle specs. */
class MemorySettings extends Settings {
readonly doc: Record<string, unknown> = {}
readonly writable = true
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc[ns] = structuredClone(section)
return Promise.resolve()
}
}
async function mounted(options: {
config?: Config
@@ -12,6 +31,7 @@ async function mounted(options: {
approvalDefault?: ApprovalPolicy | undefined
} = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
@@ -27,6 +47,23 @@ function freshSession(id: string): Session {
return new Session(SessionId(id))
}
async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(MemorySettings)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', {
config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' },
})
await ctx.plugin(PermissionService, {})
return ctx
}
describe('effectivePermissionPreset', () => {
it('folds to the last event, or undefined without one', () => {
const session = freshSession('sess-fold')
@@ -66,8 +103,11 @@ describe('PermissionService', () => {
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
})
it('composition defaults outside the table derive custom at zero events', async () => {
const ctx = await mounted({ approvalDefault: 'never' })
it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => {
const ctx = await mounted({
approvalDefault: 'never',
config: { defaultPreset: 'workspace-write' },
})
const session = freshSession('sess-defaults-custom')
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
})
@@ -138,6 +178,11 @@ describe('PermissionService', () => {
.rejects.toThrow(/reserved for the derived not-a-preset state/)
})
it('requires an explicit default when composition defaults match no preset', async () => {
await expect(mounted({ approvalDefault: 'never' }))
.rejects.toThrow(/configure defaultPreset explicitly/)
})
it('reads a schema-less approval stand-in as the ask default', async () => {
const ctx = await mounted({ approvalDefault: undefined })
const session = freshSession('sess-standin')
@@ -146,3 +191,111 @@ describe('PermissionService', () => {
expect(ctx.permission.current(session.events)).toBe('workspace-write')
})
})
describe('new-session default', () => {
it('pins the current setting into each new session without changing earlier sessions', async () => {
const ctx = await mountedStore()
const first = ctx.sessions.create(SessionId('first'))
expect(first.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'workspace-write' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
])
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
expect(ctx.permission.defaultPreset).toBe('danger-full-access')
const second = ctx.sessions.create(SessionId('second'))
expect(ctx.permission.current(first.events)).toBe('workspace-write')
expect(ctx.permission.current(second.events)).toBe('danger-full-access')
expect(second.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('preserves a seeded legacy session instead of applying the latest user default', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
const legacy = freshSession('legacy-source')
legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('preserves composition defaults when an empty stored session resumes', async () => {
const ctx = await mountedStore()
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.map(event => event.type)).toEqual([
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
])
})
it('pins sessions that already exist when the service remounts', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
const existing = ctx.sessions.create(SessionId('existing-before-permission'))
expect(existing.events).toEqual([])
await ctx.plugin(PermissionService, {})
expect(existing.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
expect(ctx.permission.current(existing.events)).toBe('workspace-write')
})
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
const ctx = await mountedStore()
const partial = freshSession('partial-source')
partial.append('sandbox/mode', { mode: 'workspace-write' })
partial.append('approval/policy', { policy: 'ask' })
const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events })
expect(resumed.events.at(-1)).toMatchObject({
type: 'permission/preset',
data: { preset: 'workspace-write' },
})
const custom = freshSession('custom-source')
custom.append('sandbox/mode', { mode: 'read-only' })
custom.append('approval/policy', { policy: 'never' })
const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events })
expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET)
expect(unmatched.events.at(-1)?.type).toBe('session/end-seed')
})
it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => {
const ctx = await mountedStore({ approvalDefault: undefined })
const partial = freshSession('approval-fallback-source')
partial.append('sandbox/mode', { mode: 'workspace-write' })
const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events })
expect(resumed.events.at(-1)).toMatchObject({
type: 'approval/policy',
data: { policy: 'ask' },
})
})
it('rejects a stored default outside the configured preset table', async () => {
const ctx = await mountedStore()
await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'missing',
})).rejects.toThrow()
expect(ctx.permission.defaultPreset).toBe('workspace-write')
})
})

View File

@@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise<Agent> {
}
describe('permissions projection unit', () => {
it('serves the composition-default select at zero events', async () => {
it('serves the pinned new-session default select', async () => {
const { ctx, session } = await harness()
const value = ctx.sessionProjections.snapshot(session).values.permissions
expect(value).toMatchObject({ currentValue: 'workspace-write' })
@@ -103,12 +103,14 @@ describe('/permission command', () => {
kind: 'success',
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1)
})
it('rejects an unknown preset without touching the log', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const before = session.events.filter(event =>
event.type !== 'command/run' && event.type !== 'command/done')
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
// The error text carries the same no-self-labelling rule as the success
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
@@ -117,6 +119,7 @@ describe('/permission command', () => {
kind: 'error',
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
expect(session.events.filter(event =>
event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before)
})
})

View File

@@ -38,6 +38,9 @@
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../settings/settings"
},
{
"path": "../commands"
}

View File

@@ -402,12 +402,15 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
// A generic card's own content, or a web card's fallback to the raw result
// content (the `web` view carries no `content` copy), both render as one dim
// Markdown block below, so links/lists/headings keep the unified dim styling
// rather than reading as bare text. Terminal and diff cards own their body
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic'
// A generic card's own content, or a read card's `content` fallback (the
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
// read renders exactly as before the read card existed), or a web card's
// fallback to the raw result content (the `web` view carries no `content`
// copy), all render as one dim Markdown block below, so links/lists/headings
// keep the unified dim styling rather than reading as bare text. Terminal and
// diff cards own their body styling, so they are excluded (mirrors
// renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic' || view.card === 'read'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
@@ -532,11 +535,12 @@ export class ToolCardComponent implements Component {
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
// The web card carries no `content` copy, so a `web` result view falls back
// to the raw result content here (`view.card === 'generic'` narrows the
// generic union arm; a `web` card takes the same fallback, mirroring the
// `markdownContent` selection in render()).
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
// A generic or read card carries its own envelope-stripped `content`; a `web`
// card carries no `content` copy and falls back to the raw result content
// here. (Mirrors the `markdownContent` selection in render(); a read card has
// no dedicated TUI rendering, so its `content` takes the same body path,
// keeping read output as it was before the read card existed.)
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []
// The presenter title headlines the body now that the header is a fixed