Merge remote-tracking branch 'origin/master' into worktree/session-reference
# Conflicts: # docs/capability-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/ui/acp/README.md # packages/ui/acp/package.json # packages/ui/acp/tsconfig.json # packages/ui/tui/package.json # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts # packages/ui/tui/tsconfig.json # pnpm-lock.yaml # python/sdk-runtime/package.json # scripts/gen-doc-graphs.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -4,13 +4,13 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `acp/` | Agent Client Protocol bridge: serves agents, commands, and live/replayed title updates to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
|---|---|---|
|
||||
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands |
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
|
||||
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
|
||||
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents |
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
|
||||
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
@@ -55,6 +55,8 @@ The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictabl
|
||||
|
||||
ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events.
|
||||
|
||||
A log-only `session/title` event maps to ACP `session_info_update` with `title` and the event timestamp as `updatedAt`. The same mapping runs for live events and `session/load` replay, so an asynchronously generated late title and a restored persisted title have one wire representation without entering model history.
|
||||
|
||||
## Per-session cwd
|
||||
|
||||
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.
|
||||
@@ -132,7 +134,7 @@ Command discovery, dispatch, and direct output never enter a model request and d
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
|
||||
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, title updates, and other streamed session updates are UI-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -59,6 +59,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: adds the log-only session/title event translated below.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
@@ -1271,6 +1273,17 @@ export function streamSessionEventUpdate(
|
||||
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
|
||||
return
|
||||
}
|
||||
case 'session/title': {
|
||||
notify({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'session_info_update',
|
||||
title: event.data.title,
|
||||
updatedAt: new Date(event.time).toISOString(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return
|
||||
const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
@@ -56,6 +57,31 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(userText).toBe('remember this')
|
||||
})
|
||||
|
||||
it('streams and replays the same persisted session_info_update for a title event', async () => {
|
||||
live = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const session = live.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const event = await live.ctx.sessions.appendOutOfBand(session, 'session/title', {
|
||||
title: 'Durable ACP title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
}, { kind: 'session-title' })
|
||||
const expected = {
|
||||
sessionUpdate: 'session_info_update' as const,
|
||||
title: 'Durable ACP title',
|
||||
updatedAt: new Date(event.time).toISOString(),
|
||||
}
|
||||
expect(live.updates).toContainEqual(expected)
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loader.updates).toContainEqual(expected)
|
||||
})
|
||||
|
||||
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
|
||||
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
|
||||
// call and result in log order so replay uses the shipping tool's same cards as live streaming.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -50,6 +51,23 @@ function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent
|
||||
}
|
||||
|
||||
describe('streamSessionEventUpdate', () => {
|
||||
it('maps a title event to session_info_update with the event timestamp', () => {
|
||||
expect(updatesFor({
|
||||
type: 'session/title',
|
||||
seq: 3,
|
||||
time: 1_725_000_000_000,
|
||||
data: {
|
||||
title: 'Log-backed titles',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})).toEqual([{
|
||||
sessionUpdate: 'session_info_update',
|
||||
title: 'Log-backed titles',
|
||||
updatedAt: new Date(1_725_000_000_000).toISOString(),
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps assistant/chunk text-delta to agent_message_chunk', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })))
|
||||
.toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }])
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
|
||||
|
||||
## Wire notes
|
||||
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`.
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later injection or plugin-owned zero-step turns still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolve } from 'node:path'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -93,7 +93,9 @@ export class HarnessSdkServer {
|
||||
this.disposers.push(ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'turn/end') {
|
||||
const rec = this.sessions.get(String(session.id))
|
||||
if (rec) rec.lastTurnEnd = event.data.reason
|
||||
if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) {
|
||||
rec.lastTurnEnd = event.data.reason
|
||||
}
|
||||
}
|
||||
this.transport.notify('session.event', { sessionId: String(session.id), event })
|
||||
}))
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -215,6 +215,64 @@ describe('HarnessSdkServer', () => {
|
||||
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport) as unknown as {
|
||||
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
|
||||
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
const session = ctx.sessions.create(SessionId('message-outcome'))
|
||||
const agent = {
|
||||
session,
|
||||
send(content: { type: 'text'; text: string }[]) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
} as unknown as Agent
|
||||
server.sessions.set('message-outcome', {
|
||||
handle: { agent, dispose: () => Promise.resolve() },
|
||||
lastTurnEnd: undefined,
|
||||
activePrompt: false,
|
||||
})
|
||||
|
||||
await server.prompt({
|
||||
sessionId: 'message-outcome',
|
||||
contentBlocks: [{ type: 'text', text: 'bounded prompt' }],
|
||||
})
|
||||
|
||||
expect(transport.notifications.findLast(notification => notification.method === 'session.finished'))
|
||||
.toEqual({
|
||||
method: 'session.finished',
|
||||
params: {
|
||||
sessionId: 'message-outcome',
|
||||
status: 'error',
|
||||
reason: { kind: 'max-tokens' },
|
||||
},
|
||||
})
|
||||
await server.shutdown()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('notifies the host when a child session is created with parent lineage', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
|
||||
@@ -8,7 +8,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
@@ -20,7 +20,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `welcome` | `ready.` | Header subtitle |
|
||||
| `welcome` | `ready.` | Header subtitle until the session has a logged title. |
|
||||
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
|
||||
| `showReasoning` | `true` | Render reasoning blocks |
|
||||
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
|
||||
@@ -32,7 +32,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Terminal window title |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
@@ -60,7 +60,7 @@ Each non-empty ordinary editor submission becomes one text block, sent with `age
|
||||
|
||||
#### Token effect
|
||||
|
||||
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens.
|
||||
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, the logged title, cards, Markdown rendering, status lines, plans, and help text add no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -56,6 +57,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
parseSessionReferenceText,
|
||||
type SessionReferenceService,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
FileDiff,
|
||||
TerminalCallView,
|
||||
@@ -383,7 +384,7 @@ async function readModelChoices(
|
||||
class HeaderComponent implements Component {
|
||||
constructor(
|
||||
private readonly agent: Agent,
|
||||
private readonly welcome: string,
|
||||
private readonly subtitle: () => string,
|
||||
private readonly palette: Palette,
|
||||
private readonly currentModel: () => string | undefined,
|
||||
) {}
|
||||
@@ -397,7 +398,7 @@ class HeaderComponent implements Component {
|
||||
const detail = `${model} • ${displayText(this.agent.session.id)}`
|
||||
const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`)
|
||||
const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)
|
||||
const lines = [title, this.palette.muted(displayText(this.welcome)), this.palette.dim(detail)]
|
||||
const lines = [title, this.palette.muted(displayText(this.subtitle())), this.palette.dim(detail)]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
.map((line) => {
|
||||
const clipped = truncateToWidth(line, usable, '')
|
||||
@@ -1010,7 +1011,7 @@ interface PendingQuestion {
|
||||
overlay: OverlayHandle | undefined
|
||||
}
|
||||
|
||||
/** Add metadata-only session candidates to pi-tui's existing command/file provider. */
|
||||
/** Add session candidates to pi-tui's existing command/file provider. */
|
||||
class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
@@ -1040,10 +1041,13 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
if (options.signal.aborted) return base
|
||||
const items: AutocompleteItem[] = candidates.map((candidate) => {
|
||||
const mentionLabel = displayInlineText(candidate.label)
|
||||
const sessionId = displayInlineText(candidate.sessionId)
|
||||
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
|
||||
const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
|
||||
return {
|
||||
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
|
||||
label: `Session · ${displayInlineText(candidate.sessionId)}`,
|
||||
description: `${candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)} · ${new Date(candidate.createdAt).toISOString()}`,
|
||||
label: `Session · ${mentionLabel}`,
|
||||
description,
|
||||
}
|
||||
})
|
||||
if (items.length === 0) return base
|
||||
@@ -1155,7 +1159,8 @@ export function createTuiChat(
|
||||
const now = (): number => runtime.now?.() ?? Date.now()
|
||||
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model)
|
||||
let sessionTitle = foldSessionTitle(agent.session.events)?.title
|
||||
const header = new HeaderComponent(agent, () => sessionTitle ?? welcome, palette, () => target.current?.model)
|
||||
const footer = new FooterComponent(
|
||||
agent,
|
||||
palette,
|
||||
@@ -1175,7 +1180,12 @@ export function createTuiChat(
|
||||
ui.addChild(editor)
|
||||
ui.addChild(footer)
|
||||
ui.setFocus(editor)
|
||||
runtime.terminal.setTitle(displayText(resolved.title))
|
||||
const updateTerminalTitle = (): void => {
|
||||
runtime.terminal.setTitle(displayText(
|
||||
sessionTitle === undefined ? resolved.title : `${sessionTitle} — ${resolved.title}`,
|
||||
))
|
||||
}
|
||||
updateTerminalTitle()
|
||||
|
||||
const requestRender = (): void => {
|
||||
footer.invalidate()
|
||||
@@ -1421,6 +1431,11 @@ export function createTuiChat(
|
||||
case 'todo/write':
|
||||
todo.update(event.data.todos)
|
||||
break
|
||||
case 'session/title':
|
||||
sessionTitle = event.data.title
|
||||
header.invalidate()
|
||||
updateTerminalTitle()
|
||||
break
|
||||
case 'turn/end':
|
||||
clearStreaming()
|
||||
if (event.data.reason.kind === 'error') {
|
||||
|
||||
@@ -7,6 +7,7 @@ import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@dee
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
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'
|
||||
@@ -167,6 +168,34 @@ describe('TUI config', () => {
|
||||
})
|
||||
|
||||
describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
session.append('session/title', {
|
||||
title: 'Restored session title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.title).toBe('Restored session title — DeepSeek Harness')
|
||||
expect(result.terminal.output).toContain('Restored session title')
|
||||
expect(result.terminal.output).not.toContain('Coding agent ready.')
|
||||
|
||||
result.session.append('session/title', {
|
||||
title: 'Live title \u001B]0;unsafe\u0007',
|
||||
messageSeqs: [1, 5],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.title).toContain('Live title \\x1b]0;unsafe\\x07 — DeepSeek Harness')
|
||||
expect(result.terminal.title).not.toContain('\u001B')
|
||||
expect(result.terminal.output).toContain('Live title \\x1b]0;unsafe\\x07')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
||||
let now = 0
|
||||
const result = await setup({
|
||||
@@ -550,6 +579,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
sourceId = source.id
|
||||
appendUser(source, 'source background')
|
||||
source.append('session/title', {
|
||||
title: 'Source chat',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
|
||||
},
|
||||
})
|
||||
@@ -560,12 +594,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('@source-session')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · source-session') })
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
|
||||
expect(result.terminal.output).toContain('source-session')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@source-session' }]])
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toHaveLength(1)
|
||||
|
||||
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user