@crazyhappyone/dsh-tui 0.1.0-alpha.4 → 0.1.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crazyhappyone/dsh-tui",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.41",
4
4
  "description": "Source-runtime launcher for the DeepSeek Harness terminal interface",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,9 @@
11
11
  "assets/screenshots/*.png",
12
12
  "bin/dsh-tui.js",
13
13
  "src/**/*.js",
14
+ "dist/**/*.js",
15
+ "cordis.patch.yml",
16
+ "tui/cordis.patch.yml",
14
17
  "README.md",
15
18
  "README.zh.md",
16
19
  "LICENSE"
@@ -24,7 +27,7 @@
24
27
  },
25
28
  "publishConfig": {
26
29
  "access": "public",
27
- "tag": "next"
30
+ "tag": "latest"
28
31
  },
29
32
  "repository": {
30
33
  "type": "git",
package/src/launcher.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Source-runtime launcher for the DeepSeek Harness TUI.
2
+ * Hybrid runtime launcher for the DeepSeek Harness TUI.
3
+ * Supports zero-clone lightweight launch via installed dsh, and source-runtime fallback.
3
4
  * @module @crazyhappyone/dsh-tui/launcher
4
5
  */
5
6
 
@@ -16,6 +17,10 @@ const DEFAULT_SOURCE_REF = 'feat/deepseek-tui'
16
17
  * @property {string} sourceUrl
17
18
  * @property {string} sourceRef
18
19
  * @property {string} packageManager
20
+ * @property {string} [dshBin]
21
+ * @property {'auto' | 'lightweight' | 'source'} [mode]
22
+ * @property {string} [packageRoot]
23
+ * @property {string} [homeDirectory]
19
24
  */
20
25
 
21
26
  /**
@@ -31,62 +36,162 @@ const DEFAULT_SOURCE_REF = 'feat/deepseek-tui'
31
36
  * @property {(path: string) => PathKind} inspectPath
32
37
  * @property {(path: string) => void} makeDirectory
33
38
  * @property {(path: string) => string} readText
39
+ * @property {(path: string, content: string) => void} [writeText]
34
40
  * @property {(command: string, args: string[], options?: {cwd?: string, stdio?: 'inherit', capture?: boolean}) => CommandResult} run
35
41
  * @property {(text: string) => void} writeOut
36
42
  * @property {(text: string) => void} writeError
37
43
  */
38
44
 
39
45
  /**
40
- * Parse the launcher's reserved management commands.
46
+ * Parse the launcher's reserved management commands and flags.
41
47
  * @param {readonly string[]} argv - arguments after the executable.
42
- * @returns {{kind: 'version'} | {kind: 'update'} | {kind: 'launch', args: string[]}}
48
+ * @returns {{kind: 'version'} | {kind: 'update'} | {kind: 'probe'} | {kind: 'launch', args: string[], mode?: 'auto' | 'lightweight' | 'source'}}
43
49
  */
44
50
  export function parseLauncherInvocation(argv) {
45
51
  if (argv[0] === '--') return { kind: 'launch', args: [...argv.slice(1)] }
46
- if (argv[0] === 'version') {
47
- if (argv.length !== 1) throw new Error('version takes no arguments; use `dsh-tui -- version ...` to send it as a task')
52
+ if (argv[0] === 'version' || argv[0] === '--version' || argv[0] === '-v' || argv[0] === '-V') {
53
+ if (argv.length !== 1) throw new Error(`${argv[0]} takes no arguments; use \`dsh-tui -- ${argv[0]} ...\` to send it as a task`)
48
54
  return { kind: 'version' }
49
55
  }
50
56
  if (argv[0] === 'update') {
51
57
  if (argv.length !== 1) throw new Error('update takes no arguments; use `dsh-tui -- update ...` to send it as a task')
52
58
  return { kind: 'update' }
53
59
  }
54
- return { kind: 'launch', args: [...argv] }
60
+ if (argv[0] === 'probe') {
61
+ if (argv.length !== 1) throw new Error('probe takes no arguments; use `dsh-tui -- probe ...` to send it as a task')
62
+ return { kind: 'probe' }
63
+ }
64
+ let mode = undefined
65
+ const args = []
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const item = argv[i]
68
+ if (item === '--source') {
69
+ mode = 'source'
70
+ } else if (item === '--lightweight') {
71
+ mode = 'lightweight'
72
+ } else {
73
+ args.push(item)
74
+ }
75
+ }
76
+ return mode !== undefined ? { kind: 'launch', args, mode } : { kind: 'launch', args }
55
77
  }
56
78
 
57
79
  /**
58
80
  * Resolve deployment settings without touching the filesystem.
59
- * @param {{env: Readonly<Record<string, string | undefined>>, homeDirectory: string}} input
81
+ * @param {{env: Readonly<Record<string, string | undefined>>, homeDirectory: string, packageRoot?: string}} input
60
82
  * @returns {LauncherSettings}
61
83
  */
62
- export function resolveLauncherSettings({ env, homeDirectory }) {
84
+ export function resolveLauncherSettings({ env, homeDirectory, packageRoot }) {
63
85
  const runtimeDirectory = environmentValue(
64
86
  env,
65
87
  'DSH_TUI_RUNTIME_DIR',
66
88
  join(homeDirectory, '.local', 'share', 'dsh-tui', 'runtime'),
67
89
  )
68
90
  if (!isAbsolute(runtimeDirectory)) throw new Error('DSH_TUI_RUNTIME_DIR must be an absolute path')
69
- return {
91
+ const envMode = env['DSH_TUI_MODE']
92
+ if (envMode !== undefined && envMode !== 'auto' && envMode !== 'lightweight' && envMode !== 'source') {
93
+ throw new Error(`DSH_TUI_MODE must be 'auto', 'lightweight', or 'source' (got '${envMode}')`)
94
+ }
95
+ const result = {
70
96
  runtimeDirectory,
71
97
  sourceUrl: environmentValue(env, 'DSH_TUI_SOURCE_URL', DEFAULT_SOURCE_URL),
72
98
  sourceRef: environmentValue(env, 'DSH_TUI_SOURCE_REF', DEFAULT_SOURCE_REF),
73
99
  packageManager: environmentValue(env, 'DSH_TUI_PNPM', 'pnpm'),
100
+ homeDirectory,
101
+ }
102
+ if (env['DSH_BIN']?.trim()) result.dshBin = env['DSH_BIN'].trim()
103
+ if (envMode) result.mode = envMode
104
+ if (packageRoot) result.packageRoot = packageRoot
105
+ return result
106
+ }
107
+
108
+ /**
109
+ * Probe whether a runnable `dsh` command is available on the system.
110
+ * @param {{env?: Readonly<Record<string, string | undefined>>, settings?: LauncherSettings, adapters: LauncherAdapters}} input
111
+ * @returns {{ok: true, binPath: string, version: string} | {ok: false, reason: string}}
112
+ */
113
+ export function probeInstalledDsh({ env = {}, settings, adapters }) {
114
+ const explicit = settings?.dshBin ?? env['DSH_BIN']
115
+ if (explicit && explicit.trim() !== '') {
116
+ const trimmed = explicit.trim()
117
+ const probe = adapters.run(trimmed, ['--version'], { capture: true })
118
+ if (probe.status === 0) {
119
+ return { ok: true, binPath: trimmed, version: probe.stdout.trim() || 'unknown' }
120
+ }
121
+ const err = probe.stderr.trim()
122
+ return { ok: false, reason: `DSH_BIN is set to "${trimmed}" but failed to run${err ? `: ${err}` : ` (exit code ${probe.status})`}` }
123
+ }
124
+
125
+ const pathProbe = adapters.run('dsh', ['--version'], { capture: true })
126
+ if (pathProbe.status === 0) {
127
+ return { ok: true, binPath: 'dsh', version: pathProbe.stdout.trim() || 'unknown' }
128
+ }
129
+
130
+ return {
131
+ ok: false,
132
+ reason: 'dsh executable was not found in PATH or DSH_BIN',
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Locate the bundled cordis.patch.yml within the package root.
138
+ * @param {string | undefined} packageRoot
139
+ * @param {LauncherAdapters} adapters
140
+ * @returns {string | undefined}
141
+ */
142
+ export function findBundledPatch(packageRoot, adapters) {
143
+ if (!packageRoot || typeof packageRoot !== 'string') return undefined
144
+ const candidates = [
145
+ join(packageRoot, 'cordis.patch.yml'),
146
+ join(packageRoot, 'tui', 'cordis.patch.yml'),
147
+ ]
148
+ for (const candidate of candidates) {
149
+ if (adapters.inspectPath(candidate) === 'file') return candidate
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ /**
155
+ * Ensure the tui profile manifest exists in DSH_HOME.
156
+ * @param {{homeDirectory?: string, env?: Readonly<Record<string, string | undefined>>, adapters: LauncherAdapters}} input
157
+ */
158
+ export function ensureTuiProfile({ homeDirectory, env = {}, adapters }) {
159
+ const dshHome = env['DSH_HOME'] || (homeDirectory ? join(homeDirectory, '.dsh') : undefined)
160
+ if (!dshHome) return
161
+ const profileDir = join(dshHome, 'profiles', 'tui')
162
+ const manifestPath = join(profileDir, 'package.json')
163
+ if (adapters.inspectPath(manifestPath) === 'file') return
164
+ adapters.makeDirectory(profileDir)
165
+ const manifest = {
166
+ name: 'dsh-profile-tui',
167
+ private: true,
168
+ dsh: {
169
+ profile: {
170
+ bundles: ['@deepseek-ai/dsh-base'],
171
+ patchReload: 'startup',
172
+ },
173
+ },
174
+ }
175
+ if (typeof adapters.writeText === 'function') {
176
+ adapters.writeText(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
74
177
  }
75
178
  }
76
179
 
77
180
  /**
78
181
  * Run one parsed launcher invocation.
79
- * @param {{invocation: ReturnType<typeof parseLauncherInvocation>, settings: LauncherSettings, packageVersion: string, adapters: LauncherAdapters}} input
182
+ * @param {{invocation: ReturnType<typeof parseLauncherInvocation>, settings: LauncherSettings, packageVersion: string, adapters: LauncherAdapters, env?: Readonly<Record<string, string | undefined>>}} input
80
183
  * @returns {number} process exit status.
81
184
  */
82
- export function runLauncher({ invocation, settings, packageVersion, adapters }) {
185
+ export function runLauncher({ invocation, settings, packageVersion, adapters, env = {} }) {
83
186
  switch (invocation.kind) {
84
187
  case 'version':
85
188
  return showVersion({ settings, packageVersion, adapters })
86
189
  case 'update':
87
190
  return updateRuntime({ settings, adapters })
191
+ case 'probe':
192
+ return showProbe({ settings, packageVersion, adapters, env })
88
193
  case 'launch':
89
- return launchTui({ args: invocation.args, settings, adapters })
194
+ return launchTui({ args: invocation.args, mode: invocation.mode, settings, adapters, env })
90
195
  default:
91
196
  return assertNever(invocation)
92
197
  }
@@ -111,7 +216,57 @@ function runtimeState(settings, adapters) {
111
216
  return { ok: true, initialized: true }
112
217
  }
113
218
 
114
- function launchTui({ args, settings, adapters }) {
219
+ function launchTui({ args, mode, settings, adapters, env = {} }) {
220
+ const effectiveMode = mode ?? settings.mode ?? 'auto'
221
+
222
+ if (effectiveMode === 'source') {
223
+ return launchSourceTui({ args, settings, adapters })
224
+ }
225
+
226
+ const patchPath = findBundledPatch(settings.packageRoot, adapters)
227
+
228
+ if (patchPath !== undefined) {
229
+ const probe = probeInstalledDsh({ env, settings, adapters })
230
+ if (probe.ok) {
231
+ return launchLightweightTui({ args, binPath: probe.binPath, patchPath, adapters, env, settings })
232
+ }
233
+ if (effectiveMode === 'lightweight') {
234
+ return reportError(adapters, `lightweight mode requires installed dsh (${probe.reason}); run \`npm i -g @deepseek-ai/dsh\` or use \`dsh-tui --source\``)
235
+ }
236
+ } else if (effectiveMode === 'lightweight') {
237
+ return reportError(adapters, `bundled TUI patch not found under ${settings.packageRoot}; run \`dsh-tui update\` to initialize source runtime`)
238
+ }
239
+
240
+ // Fallback to source runtime if initialized
241
+ const state = runtimeState(settings, adapters)
242
+ if (state.ok && state.initialized) {
243
+ return launchSourceTui({ args, settings, adapters })
244
+ }
245
+
246
+ if (state.ok && !state.initialized) {
247
+ adapters.writeError('dsh-tui: 未检测到系统已安装的 dsh 核心引擎。\n')
248
+ adapters.writeError('dsh-tui: 推荐通过 npm 极速安装官方运行时(无需克隆完整源码仓库,仅需数十 MB):\n')
249
+ adapters.writeError('dsh-tui: npm install --global @deepseek-ai/dsh\n\n')
250
+ adapters.writeError('dsh-tui: 若您需要进行底层源码开发与测试,可运行:\n')
251
+ adapters.writeError('dsh-tui: dsh-tui update\n')
252
+ adapters.writeError('dsh-tui: dsh-tui --source\n')
253
+ return reportError(adapters, `runtime is not initialized at ${settings.runtimeDirectory}; run \`dsh-tui update\` first`)
254
+ }
255
+
256
+ return launchSourceTui({ args, settings, adapters })
257
+ }
258
+
259
+ function launchLightweightTui({ args, binPath, patchPath, adapters, env = {}, settings }) {
260
+ ensureTuiProfile({ homeDirectory: settings?.homeDirectory, env, adapters })
261
+ const result = adapters.run(
262
+ binPath,
263
+ ['--profile', 'tui', '--patch', patchPath, ...args],
264
+ { stdio: 'inherit' },
265
+ )
266
+ return result.status
267
+ }
268
+
269
+ function launchSourceTui({ args, settings, adapters }) {
115
270
  const state = runtimeState(settings, adapters)
116
271
  if (!state.ok) return reportError(adapters, state.message)
117
272
  if (!state.initialized) {
@@ -125,6 +280,23 @@ function launchTui({ args, settings, adapters }) {
125
280
  return result.status
126
281
  }
127
282
 
283
+ function showProbe({ settings, packageVersion, adapters, env = {} }) {
284
+ if (packageVersion) {
285
+ adapters.writeOut(`dsh-tui: ${packageVersion}\n`)
286
+ }
287
+ const probe = probeInstalledDsh({ env, settings, adapters })
288
+ if (probe.ok) {
289
+ adapters.writeOut(`dsh: installed\n`)
290
+ adapters.writeOut(`executable: ${probe.binPath}\n`)
291
+ adapters.writeOut(`dsh engine version: ${probe.version}\n`)
292
+ const patchPath = findBundledPatch(settings.packageRoot, adapters)
293
+ adapters.writeOut(`bundled patch: ${patchPath ?? 'missing'}\n`)
294
+ return 0
295
+ }
296
+ adapters.writeOut(`dsh: not installed (${probe.reason})\n`)
297
+ return 1
298
+ }
299
+
128
300
  function showVersion({ settings, packageVersion, adapters }) {
129
301
  adapters.writeOut(`dsh-tui ${packageVersion}\n`)
130
302
  adapters.writeOut(`runtime directory: ${settings.runtimeDirectory}\n`)
package/tui/README.md ADDED
@@ -0,0 +1,132 @@
1
+ ---
2
+ description: "The interactive terminal profile layer for users composing deepseek-tui over dsh-base and operating sessions, tools, approvals, and notifications."
3
+ kind: "package-bundle"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-tui
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ The deepseek-tui profile bundle and runtime plugin lets a user run the interactive terminal over `dsh-base`. `dsh --profile deepseek-tui` waits for application readiness, validates the terminal, creates or resumes an Agent, and flushes the owned session before exit. The layer owns terminal lifecycle and user actions while capability packages continue to own agents, persistence, tools, and model behavior.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+
27
+ ## Use this package
28
+
29
+ ### Configuration
30
+
31
+ The bundle patch reads the optional `task` seed, optional `resume`, and optional `cwd` from `tuiStartup`. The startup provider accepts an optional task positional, `--resume <id>`, and `--cwd <dir>`; with no task (or with `--resume <id>` alone) the profile boots the full-screen loop idle with an empty focused composer (placeholder `输入消息` and a one-line operation/status footer), and `Enter` on the empty composer is a no-op.
32
+
33
+ The shipped profile is `{ bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-tui'], patchReload: 'startup' }`. Profile and home patch edits take effect on the next process start; they do not recompose a running terminal, Agent, or session. Settings-file reload remains owned by the settings service.
34
+
35
+ The optional `renderPolicy` config is resolved once at startup and passed explicitly to the renderer. `transcriptOverscan` and `cache.maxRows`/`cache.maxBytes` bound physical-row projection and reconstruction caches; `stream` owns smooth/catch-up queue thresholds and per-frame work; `scroll` owns latest-target cadence, catch-up steps, and the mouse-wheel row count. `tools.previewRows`, `detailPageRows`, `cacheEntries`, and `cacheRows` bound tool previews, detail pages, and derived storage; their defaults are 6, 40, 128, and 2048. The Config schema requires positive frame and cache limits, bounded overscan/cache budgets, and exit thresholds strictly below their matching entry thresholds, so invalid combinations fail during plugin load. The shipped values live in [`cordis.patch.yml`](cordis.patch.yml), and `pnpm run test:tui:perf` runs the strict real-PTY performance lane.
36
+
37
+ Reasoning is hidden by default. Ctrl+O or `/reasoning` shows the complete dim reasoning body in the shared transcript; streaming and settlement do not collapse it. `/scrollbar` hides or shows the right-hand rail without changing reading width or navigation. `/status` toggles detailed input/output metrics; the default single-row footer retains the context meter, percentage and cache-hit rate when authoritative values and width permit. The model name appears only in the input chip. These preferences also appear in `/settings` and persist through restart. `tui.locale` selects zh-CN (default) or en-US for the new settings, status and tool-detail labels; these controls do not change model reasoning or session events.
38
+
39
+ `--frame-stats <path>` opts into per-commit render-cost measurement. The target resolves to an absolute path at startup and must be writable — an unwritable target fails the launch (never silently skipped). On orderly exit the runtime writes one JSON file to that path and nothing else:
40
+
41
+ `renderMs` and `brandRenderMs` contain recent count/mean/p95/p99/max/samples plus `run`, a fixed-storage distribution of every measured commit. Recent samples are capped at 120; full-run quantiles use microsecond histogram buckets with three significant digits. Startup/history hydration is excluded once when the workload begins. These React Profiler costs exclude Ink diff/commit and terminal display time. `pacing` records commit count and elapsed time; `brandRevealTimers` records owned idle-home timeouts at orderly exit. `frameMetrics` separately includes completed-write intervals, input/coalescing counters, drain and scroll latencies, parse work, queue depth/age and cache statistics. The file contains statistics, platform/Node/architecture and its path, not session text, and never reaches stdout.
42
+
43
+ `/tools` opens every recorded call, including generic fallbacks and process failures. Enter opens bounded source pages with the selected action and status kept in the header; n/p move forward/back, d enables raw metadata, y copies the complete original, and e exports a private, non-overwriting `tool-*.txt` file in the launch working directory. Oversized clipboard payloads are explicitly refused with an export hint rather than truncated. Esc returns to the list and then the unchanged conversation. Approval and ask-user dialogs keep input priority.
44
+
45
+ ### Contract
46
+
47
+ - The runtime owns the live Agent handle, session projection and session-directory state; the render package receives only the controller interface. The top-bar title is a folded `session/title` except a `fallback` title that coexists with a human user message; before a provider or rename title the loop shows the compact mount name `DeepSeek · deepseek-tui`. Directory rows still use the first-human-message fallback. An empty idle window paints the generated official FishLogo, `DeepSeek`, and `有什么可以帮忙的` in the conversation column. The environment selects half-block, full-block, ASCII, or plain output independently of color; `brandAnimation` stores `auto | on | off`, and every input, history, overlay, resize, completion, or unmount stop clears the one-shot reveal timer. The runtime answers `approval/request` for that live agent and calls `next()` for every other agent: `y`/`a` resolve `'allowed-once'`, `n`/`d`/Esc resolve `'rejected'`, and abort (including Ctrl+C cancelling generation) resolves `'cancelled'` via the request signal. Policy `'never'` is decided by the host before this listener runs, so no ApprovalPane appears. Ctrl+E flips the current window's tool-card fold (`toolCardsExpanded`; no session event). Empty `/permission` opens the preset overlay; parameterized `/permission <name>` runs `ctx.commands.execute`. Empty `/settings` opens the settings overlay on every top-level `describe()` field (`llm-deepseek · models`; `llm-pi-ai · providers` opens a structured form — a directory name, template index 1–4, single-line `名称 端点 [模型[,模型…]]`, or multi-line `name= baseURL= models= api=`; existing routes expand into child rows for `api` / `baseURL` / `apiKeyEnv` / `displayName` / `models`); Enter apply writes the selected field through `ctx.settings.update`. An empty `models=` line, the literal `auto` in either form, the two-token `名称 端点` form, and an existing route's `providers.<route>.models` row typed as `auto` all defer model assembly to `ctx.llm.discoverModels`; the overlay shows `正在获取模型列表…` while the request is in flight, and any refusal or empty reply is painted on the ✗ pair without writing settings — a 401/403 without a stored credential appends `请先使用 /key 配置 API key`. Browse `e` reports `prepareDocument()` and `r` rereads rows (`✓ 已重载设置`). Empty `/resume` opens the session list. `/reload` is a local command: it flushes the live session, unmounts, disposes that session, then spawns a replacement Node with the same launcher flags plus `--resume <id>`, dropping the original task positional so the first message is not sent again. Overlay `r` only rereads settings rows. Idle boot with no configured `DEEPSEEK_API_KEY` opens `首次设置`; Enter saves through `ctx.credentials.set` and then opens the model pane. Esc skips without opening it. The `tui` section owns `colorTier`, `submitOnEnter` (Enter inserts a newline when false), `notify` (`off | attention | every-turn`, default `attention`), `notifyQuietInputSeconds` (non-negative integer, default 10), `brandAnimation` (shown as `自动 / 开启 / 关闭`), `scrollbar` (boolean, default `true`), `reasoning` and `statusDetails` (booleans, default `false`), `locale` (`zh-CN | en-US`, default `zh-CN`), and the validated `renderPolicy` described above. A `user-questions/request` waterfall listener is registered in the current runtime-root Agent scope; it delegates foreign and child requests with `next()`, while accepted requests paint `AskUserPane`, bind abort/replacement/disposal to one cleanup, and notify exactly once at admission. ↑↓/jk move the highlight, and digit then Enter returns the original option label. The slash directory includes each command's `description`.
48
+ - A referenced `apiKeyEnv` name also gets its own settings row (`凭据 · NAME`) after the declared fields; Enter there stores the secret through `ctx.credentials.set` and keeps the pane open. Typing a literal key into an `apiKeyEnv` field is refused with the command to run instead (`请用 /key NAME <密钥> 配置该变量`), and `/key NAME` accepts a named credential from the command line.
49
+ - `g s` toggles the persisted session directory when the composer buffer is exactly `g`; `/resume` opens the same list and does not close it. Rows with equal folded titles append a stable shortest-unique session-id hint inline; rows with unique titles remain unchanged. Left/right move the composer caret one grapheme. ↑/k shift the conversation toward older rows and ↓/j return toward live; arrows scroll even with composer text, while j/k insert when the buffer is not empty. A detached conversation always shows `↓ 底部 · End/G`, adding the unseen-row count when new output arrives. The mouse wheel uses the same direction as ↑/k on the conversation and as j/k on list panes, moves `scroll.wheelRows` physical rows per report, and combines reports from one stdin chunk into one net request. Ctrl+N starts a new session, and directory actions switch, rename or delete sessions through the owned services. Delete calls the persistence service, which rejects live, reserved, or borrowed identities and removes derived projection-cache state after durable deletion.
50
+ - The terminal, fallback titles, and `/export` Markdown include only direct human `user/message` events (`source.kind === 'user'`) plus assistant replies. Durable model context from agent instructions, plugins, and skill catalogs is intentionally absent from this human transcript.
51
+ - Ctrl+K search requests `literal-substring` matching from `ctx.sessionQuery` and filters to `user`/`assistant` transcript roles, so a query such as `回复` finds visible `只回复` text without surfacing injected model context; other service callers retain the default token-phrase mode and complete semantic corpus.
52
+ - SIGINT follows the interaction state machine; SIGTERM and SIGHUP request exit. Exit flushes the owned session before the process exit request; `--frame-stats` writes its JSON in the same orderly-exit path.
53
+ - A non-TTY output stream or unsupported terminal is rejected before the render tree mounts. On a TTY pair the render layer enables SGR mouse and OSC 8; `/help` lists wheel, click-to-open, and drag-copy.
54
+
55
+ ### Advanced entries and evidence
56
+
57
+ - [`RuntimeController`](src/index.ts) supplies running subagents to the renderer's [`Mention`](../tui-render/src/mention.tsx) and all owned children to [`AgentHubPane`](../tui-render/src/agent-hub-pane.tsx). In mention selection, Up/Down or j/k change the highlighted target, Enter inserts it with exactly one trailing space without submitting, and Escape dismisses the menu. `g a` opens Agent Hub. The session directory marks the controller-owned current parent inline, and Agent Hub renders exact children as `子会话 ID · {childId}` through [`SessionPane`](../tui-render/src/session-pane.tsx) and [`AgentHubPane`](../tui-render/src/agent-hub-pane.tsx); the assembled PTY compares those rows with ownership-derived model-admission markers, and no fixture configuration supplies either id. After this identity conversion, the controller enriches render-only Hub rows from `sessionProjections.snapshot()` for live children. Cold children first use `sessionProjectionCache.cachedSnapshot(header, keys)`; a miss borrows one exact persistence observation, folds its immutable metadata and complete ordered events through `coldSnapshot(meta, events)`, and always disposes the borrow. It sums the four durable token buckets, derives context occupancy only from measured pressure plus capacity, and uses the durable subagent timing projection; a model appears only when a registered child projection provides one, so the current projection set omits it rather than copying the parent's model. Missing services, failed cold reads, and absent values omit their segments without failing the identity table or inventing zeroes. The `Σ 子代理` row aggregates known token/duration fields and reports coverage. `SubagentListEntry` remains unchanged, and base composes the persisted projection cache exactly once.
58
+ - Empty `/plan` opens the plan directory; plan review remains the unique user-questions dialog, where `y` submits the host label `Approve` and `n` submits `Keep planning`. The goal footer, Todo HUD, jobs HUD, and workflow HUD/`g w` overlay are controller projections and leave the composer active. `g t` opens the workspace tree, and `g f` reads and writes message feedback through its sidecar service. Presenter-tagged tool results select generic, terminal, diff, search, read, or web cards; [`stream-view.tsx`](../tui-render/src/stream-view.tsx) displays complete reasoning only when enabled, bounds a closed tool stack to one summary plus three representative cards, displays nonzero terminal outcomes as failures, adds the completion boundary, and derives TurnTail products plus exact turn-local token/cache statistics.
59
+ - [`deepseek-tui-advanced-entry.expected.e2e.ts`](../../../apps/cli/tests/deepseek-tui-advanced-entry.expected.e2e.ts) drives the runnable profile. Its [`session.expected.jsonl`](../../../apps/cli/tests/snapshots/deepseek-tui-advanced-entry/session.expected.jsonl) contains session-owned durable events, [`fixture-audit.expected.jsonl`](../../../apps/cli/tests/snapshots/deepseek-tui-advanced-entry/fixture-audit.expected.jsonl) records message-feedback sidecar reads and transient workflow events, and [`terminal.expected.txt`](../../../apps/cli/tests/snapshots/deepseek-tui-advanced-entry/terminal.expected.txt) records settled 80x24/200x50 cells plus normalized cold-child Hub row and aggregate evidence. The TUI files required by `vitest.expected.config.ts` own terminal transcripts under `test:expected`; top-level recorded-session snapshots separately own model replay and durable session output. The [advanced-capability Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-tui-advanced-capability-entries.md) owns the rationale and verification distinctions.
60
+
61
+ ### Terminal-native interactions
62
+
63
+ - Ctrl+Y copies the latest non-empty assistant message through OSC52 and an available host clipboard helper; the final closed backtick-fenced body wins over the whole message. Ctrl+G resolves `$VISUAL` before `$EDITOR`, prepares a private draft, leaves the alternate screen, runs the editor directly on `/dev/tty`, and remounts the same controller. Missing configuration and failed editor/readback paths keep the original composer text.
64
+ - A bracketed paste containing one local image path, or Ctrl+P with an image clipboard, adds a memory-only `[图片 #N]` segment to the structured draft. Send-time admission commits all unsaved segments through one ordered `saveImages()` batch and appends the interleaved text and durable `ImageBlock` references through the existing `user/message` event. Provider serializers resolve those references to transient request bytes; [`projection.ts`](../tui-render/src/projection.ts) renders only the message-local ordinal and optional path-stripped name, persistence loads the same blocks unchanged, and compaction preserves image-bearing prefixes while rejecting image summary output. The dedicated [`deepseek-tui-image.expected.e2e.ts`](../../../apps/cli/tests/deepseek-tui-image.expected.e2e.ts) proves composer intake, terminal replay, durable reference-only JSONL, and text/image/text provider order through the assembled profile. This path changes neither agent-loop nor `SessionEventMap`, so it adds no TypeScript or Python SDK upload operation or expected-output change.
65
+ - During a running turn, the first submitted immutable structured draft enters the existing `agent.steer()` path; later drafts remain in an in-memory FIFO. The HUD shows only not-yet-handed-off FIFO items as `待发 {n} · ↑ 取出`; Up restores the oldest item only while the composer is empty and no modal owner has captured the key. Inbox claim/discard, matching durable `user/message`, `turn/end`, and Agent idle events own cleanup and one-at-a-time FIFO promotion. Compaction events add a non-accent `✂ 已压缩` divider without deleting projected rows; Ctrl+K toggles the latest divider when present and otherwise keeps its session-search behavior. The [terminal-native interaction Agent Note](../../../.agents/notes/implemented/feature/2026-08-21-tui-terminal-native-polish.md) owns the rationale and trade-offs.
66
+ - Notifications fire on attention events, not every turn end: an approval ask or ask-user question pops the moment it enqueues, and a run settling to `agent/status` `'idle'` pops one summary line picked by the last `turn/end` reason (`✅ 任务完成`, `❌ 回合失败:{error}`, `⚠ 输出达到上限`, `⛔ 回合被策略阻塞`, `⚠ 异常中断收尾`; a user-initiated abort stays silent). Intermediate turn ends never notify, so an orchestration burst collapses into one popup. Input within `tui.notifyQuietInputSeconds` (non-negative integer, default 10s) downgrades the popup to BEL. The body carries `· {session title}` so parallel instances are distinguishable; `notify-send` receives `-u critical|normal`. iTerm2, VTE and Konsole use OSC 99; Windows Terminal uses OSC 9; other terminals receive BEL plus one best-effort literal `notify-send` attempt. Helper absence never changes turn settlement. `tui.notify` selects `off | attention | every-turn` (default `attention`; `every-turn` keeps the legacy per-turn popup).
67
+
68
+ -----
69
+
70
+ <a id="understand-the-implementation"></a>
71
+
72
+ ## Understand the implementation
73
+
74
+ <details>
75
+ <summary>Implementation internals — click to expand</summary>
76
+
77
+ [`cordis.patch.yml`](cordis.patch.yml) adds terminal-owned providers and runtime glue over the base bundle without duplicating base storage services. [`src/index.ts`](src/index.ts) owns controller and lifecycle integration, while [`../tui-render/`](../tui-render/README.md) owns terminal projection and painting.
78
+
79
+ </details>
80
+
81
+ -----
82
+
83
+ <a id="further-exploration"></a>
84
+
85
+ ## Further Exploration
86
+
87
+ - [TUI package map](../README.md) — terminal runtime and renderer ownership.
88
+ - [TUI renderer](../tui-render/README.md) — bounded layout, input rendering, and terminal capabilities.
89
+ - [Terminal subsystem](../../../docs/subsystems/terminal.md) — terminal lifecycle and generated Cordis declarations.
90
+ - [Alpha.1 TUI compatibility decision](../../../.agents/notes/implemented/architecture/2026-08-28-alpha1-tui-compatibility.md) — composition and lifecycle rationale.
91
+
92
+ -----
93
+
94
+ <a id="model-experience"></a>
95
+
96
+ ## Model Experience
97
+
98
+ ### Terminal application request
99
+
100
+ #### What the model sees
101
+
102
+ The profile's system prompt identifies the terminal interface, while the runtime sends user input through the ordinary Agent APIs and reconstructs visible history from the session log. Source filtering affects only terminal presentation and export: non-human context messages remain in the durable log and in model request construction.
103
+
104
+ #### Token effect
105
+
106
+ The runtime adds no model-visible text beyond the profile's `system-prompt` configuration and ordinary user turns.
107
+
108
+ #### KV Cache effect
109
+
110
+ New turns append through the Agent's normal request construction; resuming reconstructs the session history before the next request, so cache reuse remains provider-dependent.
111
+
112
+ ## Known Limitations and Deferred Work
113
+
114
+ <a id="known-limitations-and-deferred-work"></a>
115
+
116
+ - **Interactive terminal required** — non-TTY launches fail before mounting because the alternate-screen interface needs terminal control.
117
+ - **Cross-process session ownership** — one runtime owns one live Agent handle; switching waits for the previous owned handle to settle before another session is resumed.
118
+ - **Provider cache behavior** — the bundle preserves session history but cannot guarantee a provider's KV-cache retention across a resumed process.
119
+ - **`/reload` stacks a waiting Node** — the parent cannot `execve` in-place, so it waits until the child exits. Repeated `/reload` nests processes until you quit the innermost TUI.
120
+ - **Memory evidence is workload-specific** — keyless UI stress records cold activation and stable-stage RSS separately. Its cache and React timing checks do not attribute unrelated provider, subprocess, or fatal out-of-memory failures.
121
+ - **Draft FIFO is process-local** — later-turn drafts are not crash-recovered; only messages handed to the Agent inbox or appended to the session log have durable ownership.
122
+
123
+ <a id="dev-note"></a>
124
+
125
+ ### Dev Note
126
+
127
+ <details>
128
+ <summary>Working context for maintainers — click to expand</summary>
129
+
130
+ None.
131
+
132
+ </details>
@@ -0,0 +1,132 @@
1
+ ---
2
+ description: "交互式终端 profile 层:面向在 dsh-base 上组合 deepseek-tui 并操作会话、工具、批准与通知的用户。"
3
+ kind: "package-bundle"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-tui
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ deepseek-tui 的 profile bundle(配置包)与运行时插件让用户在 `dsh-base` 上运行交互式终端。`dsh --profile deepseek-tui` 等待应用就绪,验证终端,创建或恢复 Agent,并在退出前 flush(刷新)所拥有的会话。该层拥有终端生命周期与用户操作,能力包继续拥有 agent、持久化、工具与模型行为。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延后工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+
27
+ ## 使用本包
28
+
29
+ ### 配置
30
+
31
+ bundle patch 从 `tuiStartup` 读取可选的 `task` 首条消息种子、可选的 `resume` 和可选的 `cwd`。启动 provider 接受可选的任务位置参数、`--resume <id>` 与 `--cwd <dir>`;无任务(或仅 `--resume <id>`)时 profile 以空闲的全屏循环启动,焦点位于空 composer(占位符 `输入消息`,以及一行操作/状态页脚),空 composer 上按 Enter 是无操作。
32
+
33
+ 随产品交付的 profile 为 `{ bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-tui'], patchReload: 'startup' }`。profile 与 home patch 的修改在下次进程启动时生效,不会重新组合正在运行的终端、Agent 或会话。设置文件热更新仍由 settings 服务拥有。
34
+
35
+ 可选 `renderPolicy` 配置在启动时解析一次,并显式传给渲染器。`transcriptOverscan` 与 `cache.maxRows`/`cache.maxBytes` 限制物理行投影和重建缓存;`stream` 拥有 smooth/catch-up 队列阈值与单帧工作量;`scroll` 拥有 latest-target 节奏、追赶步长和鼠标滚轮行数。`tools.previewRows`、`detailPageRows`、`cacheEntries` 和 `cacheRows` 分别限制工具预览、详情分页与派生存储,默认值为 6、40、128 和 2048。Config schema 要求帧间隔和缓存上限为正、overscan/缓存预算有界,且退出阈值严格小于对应进入阈值,因此无效组合会在插件加载时失败。随产品交付的值位于 [`cordis.patch.yml`](cordis.patch.yml),`pnpm run test:tui:perf` 运行严格的真实 PTY 性能门禁。
36
+
37
+ 思考默认隐藏。Ctrl+O 或 `/reasoning` 在共享 transcript 中显示完整的淡化思考正文;流式生成与结束不会将其折叠。`/scrollbar` 显示或隐藏右侧轨道,不改变阅读宽度与导航。`/status` 切换输入/输出等详细指标;默认单行页脚在权威值与宽度允许时保留上下文用量条、百分比和缓存命中率。模型名称只出现在输入区标签中。这些偏好也可在 `/settings` 中设置,并在重启后保留。`tui.locale` 为新增设置、状态与工具详情文案选择 zh-CN(默认)或 en-US;这些控制不改变模型推理或会话事件。
38
+
39
+ `--frame-stats <path>` 选择提交级渲染成本测量。目标在启动时解析为绝对路径且必须可写——不可写目标会使启动失败(绝不静默跳过)。有序退出时运行时向该路径写入一个 JSON 文件,且只写这个文件:
40
+
41
+ `renderMs` 与 `brandRenderMs` 包含最近的 count/mean/p95/p99/max/samples,以及记录每次已测提交的固定存储 `run` 分布。最近样本最多保留 120 个;全程分位数使用微秒直方图桶与三位有效数字。工作负载开始时只排除一次启动/历史恢复样本。这些 React Profiler 成本不包含 Ink diff/commit 或终端显示时间。`pacing` 记录提交数与经过时间;`brandRevealTimers` 记录有序退出时自有的空闲首页 timeout。`frameMetrics` 分别包含完成写出间隔、输入/合并计数、drain 与滚动延迟、解析工作量、队列深度/年龄及缓存统计。文件只含统计、平台/Node/架构及路径,不含会话文本,也不进入 stdout。
42
+
43
+ `/tools` 打开全部已记录调用,包括通用回退卡和进程失败。Enter 打开有界原文分页,页首保留所选操作与状态;n/p 前后翻页,d 开启原始元数据,y 复制完整原文,e 在启动工作目录中导出私有且不覆盖已有文件的 `tool-*.txt`。超大剪贴板载荷会被明确拒绝并提示导出,不会截断。Esc 先返回列表,再回到原对话。批准与用户提问对话始终保留输入优先级。
44
+
45
+ ### 约定
46
+
47
+ - 运行时拥有 live Agent 句柄、会话投影和会话目录状态;渲染包只接收 controller 接口。顶栏标题是已折叠的 `session/title`,但与人类用户消息并存的 `fallback` 标题除外;在出现 provider 或重命名标题之前循环显示紧凑挂载名 `DeepSeek · deepseek-tui`。目录行仍使用第一条人类消息作为回退。空闲无消息时,对话列绘制生成的官方 FishLogo、`DeepSeek` 与 `有什么可以帮忙的`。环境独立于颜色选择 half-block、full-block、ASCII 或纯字标输出;`brandAnimation` 保存 `auto | on | off`,任何输入、历史、覆盖层、resize、播放完成或卸载 stop 都会清除一次性 reveal timer。运行时只为已绑定的 live agent 回答 `approval/request`,其他 agent 调用 `next()`。`y`/`a` 得到 `'allowed-once'`,`n`/`d`/Esc 得到 `'rejected'`,中止(含 Ctrl+C 停止生成)经请求 signal 得到 `'cancelled'`。策略 `'never'` 由 host 在该 listener 之前决定,因此不会出现 ApprovalPane。Ctrl+E 翻转当前窗口的工具卡折叠(`toolCardsExpanded`;不写 session 事件)。空 `/permission` 打开预设覆盖层;带参 `/permission <name>` 走 `ctx.commands.execute`。空 `/settings` 打开设置覆盖层,列出 `describe()` 的每个顶层字段(`llm-deepseek · models`;`llm-pi-ai · providers` 打开一个结构化表单——目录名、模板序号 1–4、单行 `名称 端点 [模型[,模型…]]`,或多行 `name= baseURL= models= api=`;已有路由展开为 `api` / `baseURL` / `apiKeyEnv` / `displayName` / `models` 的子行);Enter 应用经 `ctx.settings.update` 写入所选字段。`models=` 留空、任一表单中的字面量 `auto`、两 token 的 `名称 端点` 形式,以及已有路由 `providers.<route>.models` 行被填成 `auto` 时,都把模型列表组装交给 `ctx.llm.discoverModels`;面板在请求进行中显示 `正在获取模型列表…`,任何拒绝或空响应都画在 ✗ 行上且不写任何设置——未存凭据时的 401/403 会追加 `请先使用 /key 配置 API key`。浏览态 `e` 报告 `prepareDocument()`,`r` 重读行(`✓ 已重载设置`)。空 `/resume` 打开会话列表。`/reload` 是本地命令:flush 当前会话、卸载终端、dispose 该会话,再以相同启动器标志加上 `--resume <id>` 拉起新的 Node,并丢掉原来的任务位置参数,以免首条消息再发一次。覆盖层上的 `r` 只重读设置行。空闲启动且未配置 `DEEPSEEK_API_KEY` 时打开 `首次设置`;Enter 经 `ctx.credentials.set` 保存后打开模型面板,Esc 跳过且不打开。`tui` 分节拥有 `colorTier`、`submitOnEnter`(为 false 时 Enter 插入换行)、`notify`(`off | attention | every-turn`,默认 `attention`)、`notifyQuietInputSeconds`(非负整数,默认 10)、`brandAnimation`(显示为 `自动 / 开启 / 关闭`)、`scrollbar`(布尔值,默认 `true`)、`reasoning` 与 `statusDetails`(布尔值,默认 `false`)、`locale`(`zh-CN | en-US`,默认 `zh-CN`)以及上文所述已验证的 `renderPolicy`。当前 runtime root Agent 的 scope 注册 `user-questions/request` waterfall listener;它用 `next()` 委托外部 root 与 child 请求,被接纳的请求则绘制 `AskUserPane`,把中止、Agent 替换与释放归并到同一清理,并只在准入时通知一次。↑↓/jk 移动高亮,数字键再 Enter 返回选项的原始 label。斜杠目录包含每条命令的 `description`。
48
+ - 被引用的 `apiKeyEnv` 名称会在已声明字段之后各自获得一行设置行(`凭据 · NAME`);在该行回车,经 `ctx.credentials.set` 存储密钥并保持面板打开。向 `apiKeyEnv` 字段输入真实密钥会被拒绝,并给出应改用的命令(`请用 /key NAME <密钥> 配置该变量`);`/key NAME` 可从命令行接受命名凭据。
49
+ - `g s` 在组合器缓冲恰好为 `g` 时切换已持久化的会话目录;`/resume` 打开同一列表且不会关掉已打开的列表。折叠标题相同的行会在行内追加稳定的最短唯一 session-id 提示;标题唯一的行保持不变。左右方向键按字素移动组合器 caret。↑/k 把对话移向更旧的行,↓/j 回到 live 边缘;有输入时方向键仍滚动,j/k 则插入字母。对话脱离底部时始终显示 `↓ 底部 · End/G`,有新输出时还会添加未读行数。鼠标滚轮在对话上与 ↑/k 同向,在列表面板上与 j/k 同向;每个报告移动 `scroll.wheelRows` 个物理行,同一 stdin 数据块内的报告合并为一个净请求。Ctrl+N 新建会话,目录动作通过所拥有的服务切换、重命名或删除会话。删除调用 persistence 服务;该服务拒绝 live、已预留或已借用的身份,并在持久删除后移除派生的投影缓存状态。
50
+ - 终端、回退标题和 `/export` Markdown 只包含直接人类输入的 `user/message` 事件(`source.kind === 'user'`)与 assistant 回复。来自 agent 指令、插件和 skill 目录的持久模型上下文有意不进入这份人类 transcript(文本记录)。
51
+ - Ctrl+K 搜索向 `ctx.sessionQuery` 请求 `literal-substring` 匹配,并过滤到 `user`/`assistant` transcript 角色,因此查询 `回复` 可以找到可见的 `只回复` 文本,同时不会显示注入的模型上下文;其他服务调用方仍保持默认的 token-phrase 模式与完整语义语料库。
52
+ - SIGINT 遵循交互状态机;SIGTERM 与 SIGHUP 请求退出。退出会在请求进程退出前 flush 所拥有的会话;`--frame-stats` 在同一有序退出路径写入其 JSON。
53
+ - 非 TTY 输出流或不受支持的终端会在渲染树挂载前被拒绝。TTY 对上由渲染层启用 SGR 鼠标和 OSC 8;`/help` 列出滚轮、点击打开与拖选复制。
54
+
55
+ ### 高级入口与证据
56
+
57
+ - [`RuntimeController`](src/index.ts) 向渲染器的 [`Mention`](../tui-render/src/mention.tsx) 提供运行中的 subagent,并向 [`AgentHubPane`](../tui-render/src/agent-hub-pane.tsx) 提供全部所属子会话。提及选择中,Up/Down 或 j/k 改变高亮目标,Enter 插入该目标并带恰好一个 trailing space(尾随空格)但不提交,Escape 关闭菜单。`g a` 打开 Agent Hub。会话目录通过 [`SessionPane`](../tui-render/src/session-pane.tsx) 在行内标记控制器拥有的当前父会话,Agent Hub 通过 [`AgentHubPane`](../tui-render/src/agent-hub-pane.tsx) 把精确子会话渲染为 `子会话 ID · {childId}`;组装 PTY 将这些行与从所有权推导出的模型准入标记比较,fixture(测试前置数据)配置不提供任何一侧 id。完成这一身份转换后,控制器从 `sessionProjections.snapshot()` 为 live 子会话补充只用于渲染的 Hub 指标。cold 子会话先调用 `sessionProjectionCache.cachedSnapshot(header, keys)`;miss 时借用一次精确的持久化 observation,把其中不可变的 metadata 与完整有序 events 交给 `coldSnapshot(meta, events)` 折叠,并总是释放 borrow。它求和四个持久 token 桶,只根据已测压力与容量推导上下文占用,并使用持久 subagent 计时投影;只有已注册的子会话投影提供模型时才显示,因此当前投影集会省略模型,而不会复制父会话模型。服务缺失、cold 读取失败和值缺失都只省略对应分段,不会使身份表失败,也不会虚构零值。`Σ 子代理` 行汇总已知 token/耗时字段并报告覆盖数。`SubagentListEntry` 保持不变,base 恰好组合一次持久投影缓存。
58
+ - 空 `/plan` 打开计划目录;计划评审仍是唯一的用户提问 Dialog,其中 `y` 提交 host 标签 `Approve`,`n` 提交 `Keep planning`。goal 底栏、Todo HUD、后台任务 HUD 与工作流 HUD/`g w` 覆盖层都来自控制器投影,并保持组合器可用。`g t` 打开工作区树,`g f` 通过消息反馈伴随服务读写反馈。带展示转换器标签的工具结果选择 generic、terminal、diff、search、read 或 web 卡;[`stream-view.tsx`](../tui-render/src/stream-view.tsx) 仅在开启时显示完整思考,把关闭的工具堆栈限制为一张摘要加三张代表性卡片,把非零终端结果显示为失败,添加完成边界,并从精确本轮 token/cache 统计生成 TurnTail 产物与指标。
59
+ - [`deepseek-tui-advanced-entry.expected.e2e.ts`](../../../apps/cli/tests/deepseek-tui-advanced-entry.expected.e2e.ts) 驱动可运行 profile。其中 [`session.expected.jsonl`](../../../apps/cli/tests/snapshots/deepseek-tui-advanced-entry/session.expected.jsonl) 只包含会话拥有的持久事件,[`fixture-audit.expected.jsonl`](../../../apps/cli/tests/snapshots/deepseek-tui-advanced-entry/fixture-audit.expected.jsonl) 记录消息反馈 sidecar(伴随文件)读取与瞬态工作流事件,[`terminal.expected.txt`](../../../apps/cli/tests/snapshots/deepseek-tui-advanced-entry/terminal.expected.txt) 记录 80x24/200x50 的稳定单元格,以及归一化的 cold 子会话 Hub 行与汇总证据。`vitest.expected.config.ts` 要求的 TUI 文件在 `test:expected` 下拥有终端 transcript;顶层录制会话 snapshot 则分别拥有模型回放与持久会话输出。[高级能力 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-tui-advanced-capability-entries.zh.md)拥有理由和验证边界。
60
+
61
+ ### 终端原生交互
62
+
63
+ - Ctrl+Y 通过 OSC52 与可用的宿主剪贴板 helper 复制最新一条非空 assistant 消息;若存在闭合的反引号围栏代码块,则复制最后一个代码块正文。Ctrl+G 先解析 `$VISUAL`、再解析 `$EDITOR`,准备私有 draft,离开备用屏幕,直接在 `/dev/tty` 上运行编辑器,再用同一控制器重新挂载。未配置、编辑器失败或读回失败都保留原组合器文本。
64
+ - 只包含一个本地图片路径的 bracketed paste,或剪贴板含图片时的 Ctrl+P,会向 structured draft 添加仅驻留内存的 `[图片 #N]` segment。发送时准入通过一个有序的 `saveImages()` 批次提交全部尚未保存的 segment,并经既有 `user/message` 事件追加交错的文本与持久 `ImageBlock` 引用。Provider 序列化器把这些引用解析为瞬态请求字节;[`projection.ts`](../tui-render/src/projection.ts) 只渲染消息内序号与可选的已去路径名称,持久化原样加载相同 block,压缩保留含图片的前缀并拒绝图片摘要输出。独立的 [`deepseek-tui-image.expected.e2e.ts`](../../../apps/cli/tests/deepseek-tui-image.expected.e2e.ts) 通过组装 profile 证明组合器准入、终端 replay、只含持久引用的 JSONL,以及 text/image/text provider 顺序。这条路径既不更改 agent-loop,也不更改 `SessionEventMap`,因此不添加 TypeScript 或 Python SDK 上传操作,也不更改其预期输出。
65
+ - 轮次运行期间,第一份 immutable structured draft(不可变结构化草稿)进入现有 `agent.steer()` 路径,后续 draft 留在进程内 FIFO。HUD 只把尚未 handoff(移交)的 FIFO 项显示为 `待发 {n} · ↑ 取出`;只有组合器为空且没有模态输入所有者时,Up 才恢复最旧项。Inbox claim/discard、匹配的持久 `user/message`、`turn/end` 与 Agent idle 事件共同拥有清理和每次一项的 FIFO 提升。压缩事件在不删除已投影行的情况下添加非 accent 的 `✂ 已压缩` 分割线;存在分割线时 Ctrl+K 切换最新一条,否则保留会话搜索行为。[终端原生交互 Agent Note](../../../.agents/notes/implemented/feature/2026-08-21-tui-terminal-native-polish.zh.md)拥有理由与取舍。
66
+ - 通知只在注意力事件触发,而非每个回合结束:批准请求或 ask-user 问题入队时立即弹出;run 排空(`agent/status` 变为 `'idle'`)时按最后一个 `turn/end` reason 弹出一条摘要(`✅ 任务完成`、`❌ 回合失败:{error}`、`⚠ 输出达到上限`、`⛔ 回合被策略阻塞`、`⚠ 异常中断收尾`;用户自己发起的取消不弹)。中间回合结束从不通知,编排任务的连发合并为一次弹出。`tui.notifyQuietInputSeconds`(非负整数,默认 10 秒)内的本地输入把弹出降级为 BEL。正文携带 `· {session 标题}` 以区分并行实例;`notify-send` 收到 `-u critical|normal`。iTerm2、VTE 与 Konsole 使用 OSC 99,Windows Terminal 使用 OSC 9,其他终端接收 BEL 与一次尽力而为的字面 `notify-send` 尝试。helper 缺失绝不改变回合结算。`tui.notify` 选择 `off | attention | every-turn`(默认 `attention`;`every-turn` 保留旧的每回合弹出)。
67
+
68
+ -----
69
+
70
+ <a id="understand-the-implementation"></a>
71
+
72
+ ## 理解实现
73
+
74
+ <details>
75
+ <summary>实现内部 — 点击展开</summary>
76
+
77
+ [`cordis.patch.yml`](cordis.patch.yml) 在 base bundle 上添加终端所属提供方与运行时 glue,不重复 base 存储服务。[`src/index.ts`](src/index.ts) 拥有控制器与生命周期集成,[`../tui-render/`](../tui-render/README.zh.md) 拥有终端投影与绘制。
78
+
79
+ </details>
80
+
81
+ -----
82
+
83
+ <a id="further-exploration"></a>
84
+
85
+ ## 进一步探索
86
+
87
+ - [TUI 包映射](../README.zh.md) — 终端运行时与渲染器所有权。
88
+ - [TUI 渲染器](../tui-render/README.zh.md) — 有界布局、输入渲染与终端能力。
89
+ - [终端子系统](../../../docs/subsystems/terminal.zh.md) — 终端生命周期与生成的 Cordis 声明。
90
+ - [Alpha.1 TUI 兼容性决策](../../../.agents/notes/implemented/architecture/2026-08-28-alpha1-tui-compatibility.zh.md) — 组合与生命周期理由。
91
+
92
+ -----
93
+
94
+ <a id="model-experience"></a>
95
+
96
+ ## 模型体验
97
+
98
+ ### 终端应用请求
99
+
100
+ #### 模型看到的内容
101
+
102
+ profile 的系统提示词标识终端界面,运行时通过常规 Agent API 发送用户输入,并从会话日志重建可见历史。来源过滤只影响终端呈现和导出:非人类上下文消息仍留在持久日志与模型请求构造中。
103
+
104
+ #### Token 影响
105
+
106
+ 运行时不会在 profile 的 `system-prompt` 配置与常规用户轮次之外添加模型可见文本。
107
+
108
+ #### KV Cache 影响
109
+
110
+ 新轮次通过 Agent 的常规请求构造追加;恢复会在下一次请求前重建会话历史,因此缓存复用仍取决于 provider。
111
+
112
+ ## 已知限制与延后工作
113
+
114
+ <a id="known-limitations-and-deferred-work"></a>
115
+
116
+ - **需要交互式终端** — 非 TTY 启动会在挂载前失败,因为备用屏幕界面需要终端控制。
117
+ - **跨进程会话所有权** — 一个运行时拥有一个 live Agent 句柄;切换会等待前一个所拥有句柄停稳,再恢复另一个会话。
118
+ - **Provider 缓存行为** — bundle 会保留会话历史,但不能保证 provider 在恢复进程后的 KV Cache 保留。
119
+ - **`/reload` 会堆叠一个等待中的 Node** — 父进程无法原地 `execve`,因此会等到子进程退出。反复 `/reload` 会嵌套进程,直到退出最内层 TUI。
120
+ - **内存证据只适用于对应负载** — keyless UI 压测分别记录首次激活与稳定阶段的 RSS。其缓存与 React 计时检查不会为无关提供方、子进程或致命内存耗尽失败归因。
121
+ - **Draft FIFO 仅在进程内存在** — 后续轮次 draft 不参与崩溃恢复;只有已移交 Agent inbox 或已追加到会话日志的消息具有持久所有权。
122
+
123
+ <a id="dev-note"></a>
124
+
125
+ ### 开发备注
126
+
127
+ <details>
128
+ <summary>维护者工作上下文 — 点击展开</summary>
129
+
130
+ 无。
131
+
132
+ </details>