@easbot/terminal 0.3.13 → 0.3.16

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/README.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Introduction
8
8
 
9
- `@easbot/terminal` is a terminal interaction toolkit for the EASBOT ecosystem. It encapsulates the common capabilities required by CLI / TUI scenarios: themed color output, ANSI handling, OSC-8 hyperlinks, table rendering, styled prompts, safe stream writes, progress-line management, terminal-state recovery, an interactive searchable multi-select prompt, a simple CLI input helper, and a lightweight markdown renderer. Every module auto-detects terminal capability (`NO_COLOR` / `FORCE_COLOR` / TTY) and degrades gracefully in non-TTY environments (pipes, redirected files, CI logs).
9
+ `@easbot/terminal` is a terminal interaction toolkit for the EASBOT ecosystem. It encapsulates the common capabilities required by CLI / TUI scenarios: themed color output, ANSI handling, table rendering, command output formatting, styled prompts, JSON contract output, human-output helpers (`printHuman` / `renderLines`), a unified **interactive Prompt namespace**, an interactive searchable multi-select prompt, a readline-based CLI input helper, a lightweight markdown renderer, shared icon / status-symbol constants, a stateless Loader spinner, and terminal-state recovery. Every module auto-detects terminal capability (`NO_COLOR` / `FORCE_COLOR` / TTY) and degrades gracefully in non-TTY environments (pipes, redirected files, CI logs).
10
10
 
11
11
  The package is **vendor-neutral**: it does not hard-code any external doc site or vendor-specific configuration. Helper functions such as `formatRootedLink` / `formatRootLink` accept an explicit root URL, leaving integration choices to the caller.
12
12
 
@@ -19,15 +19,18 @@ The package is **vendor-neutral**: it does not hard-code any external doc site o
19
19
  - **OSC-8 hyperlinks** — cross-platform terminal hyperlinks with graceful fallback
20
20
  - **Table rendering** — `renderTable` with auto-fit columns, Unicode / ASCII / borderless styles, ANSI-safe wrapping
21
21
  - **Command output formatting** — success / error / warning / info / heading / key-value / command / list / status / count helpers
22
- - **Styled prompts** — styled wrappers around `@clack/prompts` (`selectStyled` / `note` / `stylePromptMessage` )
22
+ - **Styled prompts** — low-level styling wrappers (`selectStyled` / `note` / `stylePromptMessage` / `stylePromptTitle` / `stylePromptHint`)
23
+ - **Unified Prompt namespace** — `Prompt.select / groupMultiselect / text / password / confirm / multiselect / intro / outro / cancel / Log.* / spinner` is the **recommended entry point** for any business code (see the "Prompt namespace" section)
23
24
  - **Searchable multi-select** — `Search.multiselect` is an interactive, real-time-filtered, multi-select prompt with full keyboard control, optional locked section, and graceful stdin / signal cleanup
24
25
  - **Loader** — `Loader.create()` returns a stateless spinner instance (no Component / TUI inheritance). Customizable frames, color, message; integrates with `MockTerminalIO` for tests
25
26
  - **Input** — `Input.readText` / `Input.readConfirm` / `Input.readPassword` are readline-based helpers for CLI prompts; inject `input` / `output` streams for test isolation
26
27
  - **Markdown** — `renderMarkdown` parses a minimal markdown subset (heading / list / code-block / bold / italic / link) without pulling in `marked`
28
+ - **Human output helpers** — `printHuman(lines, { title, outro })` packages grouping + summary + outro into a single page; the canonical human-readable output for every CLI subcommand
29
+ - **Icons / status symbols** — every `✓ / ✗ / ⚠ / ℹ / ◆ / ● / ○ / │ / ─ / � / • / ↡ / ⊘` literal lives **only** in `symbols.ts`; callers use `Symbol.statusIcon(...)` / `Symbol.colorize(...)`
30
+ - **CLI JSON contract** — `emitJson / emitJsonOk / emitJsonError / wrapJson / wrapError` implement the EASBOT `--json` protocol (`{ ok, data, meta? }` / `{ ok: false, error: { code, message } }`)
27
31
  - **Safe stream writer** — `createSafeStreamWriter` swallows `EPIPE` / `EIO`, so a closed pipe never throws
28
32
  - **Progress line** — register / clear / unregister an active progress line (TTY-only)
29
33
  - **Terminal-state recovery** — `restoreTerminalState` resets cursor, mouse tracking, and bracketed paste on exit
30
- - **CLI JSON output** — `emitJsonOk` / `emitJsonError` implement the EASBOT CLI `--json` contract (`{ ok, data | error }`)
31
34
  - **Color discipline** — every styled output goes through `isRich()` + `colorize()`, and snapshot / non-TTY runs never leak ANSI sequences
32
35
  - **TTY injection for tests** — interactive components (e.g. `formatTerminalLink`, `Search.multiselect`, `Input.readText`) accept an optional `isTTY` parameter so unit tests can run without depending on `process.stdout.isTTY`
33
36
 
@@ -105,8 +108,8 @@ console.log(
105
108
  { key: 'status', header: 'Status', align: 'center' },
106
109
  ],
107
110
  rows: [
108
- { name: '@easbot/terminal', version: '0.3.11', status: '✓' },
109
- { name: '@easbot/note', version: '0.3.11', status: '✓' },
111
+ { name: '@easbot/terminal', version: '0.3.13', status: '✓' },
112
+ { name: '@easbot/note', version: '0.3.13', status: '✓' },
110
113
  ],
111
114
  width: 80,
112
115
  border: 'unicode',
@@ -114,7 +117,100 @@ console.log(
114
117
  );
115
118
  ```
116
119
 
117
- ### Styled prompts
120
+ ### Human output helpers (`printHuman`)
121
+
122
+ ```typescript
123
+ import { printHuman, renderLines } from '@easbot/terminal';
124
+
125
+ // The canonical "grouped detail + summary + outro" page
126
+ printHuman(
127
+ [' ✓ eas-agent-creation', ' ✓ eas-agent-evolution', ' ✗ eas-legacy-tool'],
128
+ {
129
+ title: 'Project Skills Update',
130
+ outro: 'Run `easbot skills list` to see current state.',
131
+ empty: 'No skills tracked in lock file.',
132
+ },
133
+ );
134
+
135
+ // renderLines: build a grouped tree (list / find / store-list style)
136
+ const lines = renderLines({
137
+ title: 'Project Skills (3)',
138
+ groups: [
139
+ { key: 'universal (.agents/skills)', rows: [' ✓ foo', ' ✓ bar'] },
140
+ { key: 'claude-code', rows: [' ✓ baz'] },
141
+ ],
142
+ });
143
+ ```
144
+
145
+ ### Prompt namespace (the unified entry point)
146
+
147
+ `Prompt` is the unified interactive namespace provided by `@easbot/terminal` — **the only recommended entry point for business code**. Internally it follows the B+A hybrid model:
148
+
149
+ - `select / groupMultiselect / text / password / confirm`: thin wrappers around `@clack/prompts` (battle-tested raw mode / signal / cancel)
150
+ - `multiselect`: custom implementation backed by `Search.multiselect` (fuzzy + locked section + pagination)
151
+ - `intro / outro / cancel / Log.* / spinner`: custom implementation (chalk + theme), colors unified to the terminal `theme`
152
+
153
+ ```typescript
154
+ import { Prompt } from '@easbot/terminal';
155
+
156
+ // Single-select
157
+ const choice = await Prompt.select({
158
+ message: 'Choose install scope',
159
+ options: [
160
+ { value: 'project', label: 'Project', hint: 'Current directory' },
161
+ { value: 'global', label: 'Global', hint: 'Home directory' },
162
+ ],
163
+ });
164
+ if (Prompt.isCancel(choice)) Prompt.cancel('Cancelled by user');
165
+
166
+ // Multi-select
167
+ const selected = await Prompt.multiselect({
168
+ message: 'Pick the agents to enable',
169
+ options: [
170
+ { value: 'claude-code', label: 'Claude Code' },
171
+ { value: 'easbot', label: 'EASBot' },
172
+ ],
173
+ initialValues: ['easbot'], // pre-select
174
+ required: true,
175
+ });
176
+
177
+ // Grouped multi-select
178
+ const skills = await Prompt.groupMultiselect({
179
+ message: 'Pick the skills to install',
180
+ options: {
181
+ General: [{ value: 'foo', label: 'foo' }, { value: 'bar', label: 'bar' }],
182
+ 'Advanced Plugins': [{ value: 'baz', label: 'baz' }],
183
+ },
184
+ });
185
+
186
+ // Flow skeleton
187
+ Prompt.intro(' skills ');
188
+ // ... do something ...
189
+ Prompt.outro('Done!');
190
+
191
+ // Cancellation: Prompt.cancel returns `never` and calls process.exit(0) internally
192
+ if (Prompt.isCancel(selected)) Prompt.cancel('Cancelled by user');
193
+
194
+ // Status logging (colors unified to terminal theme)
195
+ Prompt.Log.info('Operation succeeded');
196
+ Prompt.Log.warn('Warning');
197
+ Prompt.Log.error('Error');
198
+ Prompt.Log.step('Step');
199
+ Prompt.Log.message('Plain message (no prefix)');
200
+
201
+ // Spinner
202
+ const spinner = Prompt.spinner();
203
+ spinner.start('Parsing source...');
204
+ spinner.stop(`Found ${count} skill(s)`);
205
+ ```
206
+
207
+ **Cancellation contract:**
208
+
209
+ - `Prompt.cancelSymbol` — the single sentinel (same reference as `Search.cancelSymbol`)
210
+ - `Prompt.isCancel(value)` — type guard, also accepts clack's `Symbol('')`
211
+ - `Prompt.cancel(message?)` — `never` return + `process.exit(0)`
212
+
213
+ ### Styled prompts (low-level helpers)
118
214
 
119
215
  ```typescript
120
216
  import { selectStyled, note, wrapNoteMessage } from '@easbot/terminal';
@@ -130,6 +226,30 @@ const choice = await selectStyled({
130
226
  note(wrapNoteMessage('Config file saved to ~/.config/app/config.json'), 'Success');
131
227
  ```
132
228
 
229
+ ### Icons / Symbols (the unified source of truth)
230
+
231
+ Business code **must not** write `✓ ✗ ⚠ ℹ ◆ ● ○ │ ─ •` as string literals. All icons must come from the `Symbol` namespace.
232
+
233
+ ```typescript
234
+ import { Symbol as Glyph } from '@easbot/terminal';
235
+
236
+ // Status icons (themed)
237
+ console.log(`${Glyph.statusIcon('success')} Operation succeeded`); // ✓ green
238
+ console.log(`${Glyph.statusIcon('error')} Operation failed`); // ✗ red
239
+ console.log(`${Glyph.statusIcon('warning')} Warning`); // ⚠ yellow
240
+ console.log(`${Glyph.statusIcon('info')} Info`); // ℹ blue
241
+ console.log(`${Glyph.statusIcon('active')} In progress`); // ◆ accent
242
+ console.log(`${Glyph.statusIcon('skipped')} Skipped`); // ↡ muted
243
+ console.log(`${Glyph.statusIcon('blocked')} Blocked`); // ⊘ error
244
+
245
+ // Decoration glyphs
246
+ console.log(Glyph.bar.vertical); // │
247
+ console.log(Glyph.bar.bullet); // •
248
+
249
+ // Wrap any character with a theme color
250
+ console.log(Glyph.colorize('custom', 'success'));
251
+ ```
252
+
133
253
  ### Loader (CLI spinner)
134
254
 
135
255
  ```typescript
@@ -168,12 +288,12 @@ const pw = await Input.readPassword({ message: 'Password:' });
168
288
  ```typescript
169
289
  import { renderMarkdown } from '@easbot/terminal';
170
290
 
171
- console.log(renderMarkdown('# Title\n\nHello **world**.', { stripAnsi: true }).join('\n'));
291
+ console.log(renderMarkdown('# Title\n\nHello **world**.', opts).join('\n'));
172
292
  ```
173
293
 
174
- Supports `# / ## / ###` headings, `-` / `*` / `1.` lists, fenced code blocks, and inline `**bold**` / `*italic*` / `` `code` `` / `[text](url)`. Does not depend on `marked` — keeps the bundle small for CLI output.
294
+ Supports `# / ## / ###` headings, `---` horizontal rules, `-` / `*` / `1.` lists, fenced code blocks, and inline `**bold**` / `*italic*` / `` `code` `` / `[text](url)`. Does not depend on `marked` — keeps the bundle small for CLI output.
175
295
 
176
- ### Searchable multi-select
296
+ ### Searchable multi-select (`Search.multiselect`)
177
297
 
178
298
  ```typescript
179
299
  import { Search, cancelSymbol } from '@easbot/terminal';
@@ -220,13 +340,29 @@ writer.write('second\n');
220
340
  ### CLI `--json` contract
221
341
 
222
342
  ```typescript
223
- import { emitJsonOk, emitJsonError } from '@easbot/terminal';
343
+ import { emitJson, emitJsonOk, emitJsonError, wrapJson, wrapError } from '@easbot/terminal';
224
344
 
345
+ // Low-level: build envelopes
346
+ const env = wrapJson({ items: [{ id: 1 }] }, { count: 1 });
347
+ // { ok: true, data: { items: [{ id: 1 }] }, meta: { count: 1 } }
348
+ const err = wrapError({ reason: 'not found' });
349
+ // { ok: false, data: { reason: 'not found' } }
350
+
351
+ // High-level: emit straight to stdout
225
352
  emitJsonOk({ items: [{ id: 1 }, { id: 2 }] }, { count: 2 });
226
- // {"ok":true,"data":{"items":[{"id":1},{"id":2}]},"meta":{"count":2}}
353
+ // stdout:
354
+ // {
355
+ // "ok": true,
356
+ // "data": { "items": [{ "id": 1 }, { "id": 2 }] },
357
+ // "meta": { "count": 2 }
358
+ // }
227
359
 
228
360
  emitJsonError('E_NOT_FOUND', 'Resource not found');
229
- // {"ok":false,"error":{"code":"E_NOT_FOUND","message":"Resource not found"}}
361
+ // stdout:
362
+ // {
363
+ // "ok": false,
364
+ // "error": { "code": "E_NOT_FOUND", "message": "Resource not found" }
365
+ // }
230
366
  ```
231
367
 
232
368
  ## API Overview
@@ -240,7 +376,7 @@ emitJsonError('E_NOT_FOUND', 'Resource not found');
240
376
 
241
377
  ### ANSI utilities
242
378
 
243
- - `stripAnsi(input)` — strip SGR / OSC-8 sequences from a string
379
+ - `stripAnsi(input)` — strip ANSI SGR / OSC-8 sequences from a string
244
380
  - `visibleWidth(input)` — visible character width after stripping ANSI
245
381
 
246
382
  ### Paths & display
@@ -266,26 +402,50 @@ emitJsonError('E_NOT_FOUND', 'Resource not found');
266
402
  - `TableColumn` / `RenderTableOptions` — column and render-option types
267
403
  - `renderTable(opts)` — render a table string
268
404
 
269
- ### Styled prompts
405
+ ### Human output helpers
406
+
407
+ - `PrintHumanOptions` / `RenderLinesGroup` / `RenderLinesOptions` — types
408
+ - `printHuman(lines, opts)` — render `lines` + `title` + `outro` as a single page (boxed variant goes through `note()`)
409
+ - `formatPrintHuman(lines, opts)` — same as `printHuman` but returns the string (for embedding)
410
+ - `renderLines(opts)` — build a grouped tree (list / find / store-list style)
411
+ - `printRenderLines(opts)` — same as `renderLines` but prints directly
412
+ - `formatListTruncated(items, maxShow?)` / `formatList(items, maxShow?)` — truncate long lists to ≤N with a `"+K more"` suffix
413
+
414
+ ### Styled prompts (low-level)
270
415
 
271
416
  - `stylePromptMessage` / `stylePromptTitle` / `stylePromptHint` — prompt text styling
272
417
  - `selectStyled<T>(params)` — themed select prompt
273
418
  - `note(message, title?, options?)` — themed note box (supports `columns` / `maxWidth` injection)
274
419
  - `wrapNoteMessage(message, options?)` — auto-wrap a note's message
275
420
 
276
- ### Interactive multi-select
277
-
278
- - `Search.multiselect<T>(options)` searchable, real-time-filtered multi-select prompt
279
- - `options.message` — prompt message
280
- - `options.items` — selectable items
281
- - `options.maxVisible?`page size
282
- - `options.initialSelected?`pre-selected values (deduplicated)
283
- - `options.required?`reject empty submission
284
- - `options.lockedSection?`always-included partition
285
- - `options.isTTY?`explicit TTY state (test / embedded injection)
286
- - `Search.cancelSymbol` — sentinel returned on cancel / stdin end
287
- - `Search.approxStringWidth` / `Search.visualRowsForLine` / `Search.countVisualRowsForLines`display-width helpers
288
- - `cancelSymbol` — top-level re-export of `Search.cancelSymbol` (kept for backwards compatibility)
421
+ ### **Prompt namespace (unified entry point)**
422
+
423
+ The single recommended entry point for business code. Details in the "Prompt namespace" section above:
424
+
425
+ - `Prompt.select<T>(opts)` — single-select
426
+ - `Prompt.groupMultiselect<T>(opts)`grouped multi-select
427
+ - `Prompt.multiselect<T>(opts)`searchable multi-select (fuzzy + locked section + pagination)
428
+ - `Prompt.text(opts)`text input
429
+ - `Prompt.password(opts)`hidden input
430
+ - `Prompt.confirm(opts)`y/n confirmation
431
+ - `Prompt.intro(title)` — flow header (heading + decoration line)
432
+ - `Prompt.outro(message?, { success? })` — flow footer (success/error themed)
433
+ - `Prompt.cancel(message?)` — `never`, internal `process.exit(0)`
434
+ - `Prompt.cancelSymbol` — single sentinel (same reference as `Search.cancelSymbol`)
435
+ - `Prompt.isCancel(value)` — type guard
436
+ - `Prompt.spinner(opts?)` — thin wrapper over `Loader.create`, returns `SpinnerInstance`
437
+ - `Prompt.SpinnerInstance` — `{ start, stop, update, line }`
438
+ - `Prompt.Log.info / warn / error / success / step / message(msg, detail?)` — status log
439
+
440
+ ### Icons / Symbols (unified source of truth)
441
+
442
+ - `Symbol` namespace:
443
+ - `Symbol.icons: Record<SymbolKind, string>` — status icon literals (`success/error/warning/info/accent/muted/heading/active/submit/cancel/locked/pending/radioActive/radioInactive/skipped/blocked/cursor`)
444
+ - `Symbol.bar.{ vertical, horizontal, cornerBottom, cornerBottomRight, teeRight, stepActive, bullet }`
445
+ - `Symbol.colorize(char, kind)` — wrap any character with a theme color
446
+ - `Symbol.statusIcon(kind)` — return `Symbol.icons[kind]` themed
447
+ - `Symbol.prefixed(kind, text)` — `"<icon> <text>"` format
448
+ - `SymbolKind` type
289
449
 
290
450
  ### Loader (CLI spinner)
291
451
 
@@ -309,19 +469,24 @@ emitJsonError('E_NOT_FOUND', 'Resource not found');
309
469
  `Input` is a **namespace** with three stateless helpers. Pass `input` / `output` to swap in a mock stream for tests.
310
470
 
311
471
  - `Input.readText(options)` — read a single line. Options: `message`, `hint?`, `defaultValue?`, `hidden?`, `messageColor?`, `hintColor?`, `input?`, `output?`
312
- - `Input.readConfirm(options)` — yes/no prompt; recursively re-prompts on invalid input. Options: `message`, `hint?`, `defaultValue?` (default `true`)
472
+ - `Input.readConfirm(options)` — yes/no prompt; recursively re-prompts on invalid input. Options: `message`, `hint?`, `defaultValue?` (default `true`), `messageColor?`, `hintColor?`, `input?`, `output?`
313
473
  - `Input.readPassword(options)` — alias for `readText` with `hidden: true`
314
474
 
315
475
  ### Markdown (minimal subset)
316
476
 
317
477
  - `renderMarkdown(md, opts?)` → `string[]` (one entry per logical line)
318
- - Supports: `# / ## / ###` headings, `---` horizontal rules, `- / * / 1.` lists, fenced code blocks (` ``` `), inline `**bold**` / `*italic*` / `` `code` `` / `[text](url)`
478
+ - Supports: `# / ## / ###` headings, `---` horizontal rules, `-` / `*` / `1.` lists, fenced code blocks (` ``` `), inline `**bold**` / `*italic*` / `` `code` `` / `[text](url)`
319
479
  - `opts.headingColor?` / `boldColor?` / `italicColor?` / `codeColor?` / `linkColor?` — color functions to override the default theme
320
480
 
321
- ### CLI JSON output
481
+ ### JSON contract output
322
482
 
323
- - `emitJsonOk(data, meta?)` emit `{ ok: true, data, meta? }`
324
- - `emitJsonError(code, message)` emit `{ ok: false, error: { code, message } }`
483
+ - `wrapJson<T>(data, meta?, ok?)` `JsonEnvelope<T>` (build envelope; `ok` defaults to `true`)
484
+ - `wrapError<T>(data, meta?)` `JsonEnvelope<T>` (`ok:false` error variant)
485
+ - `emitJson(envelope, indent?)` — serialize + `console.log`
486
+ - `emitCommandJson(envelope)` — same as above with fixed `indent=2`
487
+ - `emitJsonOk(data, meta?)` — emit `{ ok: true, data, meta? }` directly
488
+ - `emitJsonError(code, message)` — emit `{ ok: false, error: { code, message } }` directly
489
+ - `JsonEnvelope<T>` — `{ ok: boolean; data: T; meta?: Record<string, unknown> }`
325
490
 
326
491
  ### Streams & state
327
492
 
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  # @easbot/terminal
4
4
 
5
- > 终端交互工具库 — 为 EASBOT 生态提供统一的终端输出、格式化、提示与状态恢复能力。
5
+ > 终端交互工具库 — 为 EASBOT 生态提供统一的终端输出、格式化、提示、Prompt 与状态恢复能力。
6
6
 
7
7
  ## 简介
8
8
 
9
- `@easbot/terminal` 是一个面向 EASBOT 生态的终端交互工具库,封装了命令行 / TUI 场景下常用的能力:终端主题色、ANSI 处理、超链接、表格渲染、提示样式、安全流写入、进度行管理、终端状态恢复、一个**可搜索的多选交互 prompt**、一个**CLI 输入 helper** 以及一个**轻量级 markdown 渲染器**。所有能力都基于终端能力自动检测(`NO_COLOR` / `FORCE_COLOR` / TTY)进行降级,可在管道、文件重定向等非 TTY 场景下安全使用。
9
+ `@easbot/terminal` 是一个面向 EASBOT 生态的终端交互工具库,封装了命令行 / TUI 场景下常用的能力:终端主题色、ANSI 处理、表格渲染、命令输出格式化、提示样式化、JSON 规范输出、人类输出统一封装(`printHuman` / `renderLines`)、一个**统一的交互式 Prompt namespace**、一个**可搜索的多选交互 prompt**、一个**CLI 输入 helper**、一个**轻量级 markdown 渲染器**、图标 / 状态符号统一常量、Loader 旋转动画、终端状态恢复等。所有能力都基于终端能力自动检测(`NO_COLOR` / `FORCE_COLOR` / TTY)进行降级,可在管道、文件重定向等非 TTY 场景下安全使用。
10
10
 
11
11
  本包**完全 vendor-neutral**:不绑定任何外部文档站或 vendor-specific 配置。`formatRootedLink` / `formatRootLink` 等 helper 显式接受 `root` 参数,由调用方决定集成方式。
12
12
 
@@ -19,15 +19,18 @@
19
19
  - **OSC-8 超链接**:跨平台终端超链接,自动检测终端能力,提供降级文本
20
20
  - **表格渲染**:`renderTable` 支持自适应列宽、Unicode / ASCII / 无边框三种样式、文本换行、ANSI 样式保持
21
21
  - **命令输出格式化**:成功 / 错误 / 警告 / 信息 / 标题 / 键值对 / 命令示例 / 列表项 / 进度状态 / 计数等
22
- - **提示样式化**:`@clack/prompts` 的样式化封装(`selectStyled` / `note` / `stylePromptMessage` 等)
22
+ - **提示样式化**:纯样式化封装(`selectStyled` / `note` / `stylePromptMessage` / `stylePromptTitle` / `stylePromptHint`)
23
+ - **统一交互 Prompt namespace**:`Prompt.select / groupMultiselect / text / password / confirm / multiselect / intro / outro / cancel / Log.* / spinner` —— 业务方**唯一**交互入口(详见"Prompt namespace"节)
23
24
  - **可搜索多选 prompt**:`Search.multiselect` 是交互式、实时过滤的多选 prompt,支持完整键盘控制、可选锁定分区、对 stdin / 信号优雅清理
24
25
  - **Loader 旋转动画**:`Loader.create()` 返回**stateless** spinner 实例(不继承 tui 组件模型),可定制帧 / 颜色 / 消息
25
26
  - **Input CLI 输入**:`Input.readText` / `Input.readConfirm` / `Input.readPassword` 是 readline 薄包装的 CLI prompt;支持注入 `input` / `output` 用于测试隔离
26
27
  - **Markdown 渲染**:`renderMarkdown` 解析极简 markdown 子集(标题 / 列表 / 代码块 / 加粗 / 斜体 / 链接),**不引 `marked`**,保持 CLI 包体小
28
+ - **人类输出统一封装**:`printHuman(lines, { title, outro })` 把分组细节 + 总结 + outro 收尾一页搞定,所有 CLI 子命令默认人类输出走这条
29
+ - **图标 / 状态符号统一**:所有 `✓ / ✗ / ⚠ / ℹ / ◆ / ● / ○ / │ / ─ / ┘ / • / ↡ / ⊘` 等图标硬编码仅出现在 `symbols.ts`,业务方通过 `Symbol.statusIcon(...)` / `Symbol.colorize(...)` 调用
30
+ - **JSON 规范输出**:`emitJson / emitJsonOk / emitJsonError / wrapJson / wrapError` 实现 CLI `--json` 规范的 `{ ok, data, meta? }` / `{ ok, false, error: { code, message } }` 协议
27
31
  - **安全流写入**:`createSafeStreamWriter` 自动处理 `EPIPE` / `EIO`,流关闭后静默忽略后续写入
28
32
  - **进度行管理**:注册 / 清除 / 注销活动进度行,仅在 TTY 下生效
29
33
  - **终端状态恢复**:`restoreTerminalState` 在退出 / 异常时重置光标、鼠标跟踪、括号粘贴模式
30
- - **JSON 规范输出**:`emitJsonOk` / `emitJsonError` 实现 CLI `--json` 规范的 `{ ok, data | error }` 协议
31
34
  - **色彩一致性**:所有样式化输出统一经过 `isRich()` + `colorize()`,快照 / 非 TTY 场景不会泄漏 ANSI 转义
32
35
  - **TTY 注入**:交互组件(`formatTerminalLink`、`Search.multiselect`、`Input.readText` 等)接受可选 `isTTY` 参数,单元测试不再依赖 `process.stdout.isTTY`
33
36
 
@@ -105,8 +108,8 @@ console.log(
105
108
  { key: 'status', header: '状态', align: 'center' },
106
109
  ],
107
110
  rows: [
108
- { name: '@easbot/terminal', version: '0.3.11', status: '✓' },
109
- { name: '@easbot/note', version: '0.3.11', status: '✓' },
111
+ { name: '@easbot/terminal', version: '0.3.13', status: '✓' },
112
+ { name: '@easbot/note', version: '0.3.13', status: '✓' },
110
113
  ],
111
114
  width: 80,
112
115
  border: 'unicode',
@@ -114,7 +117,99 @@ console.log(
114
117
  );
115
118
  ```
116
119
 
117
- ### 样式化提示
120
+ ### 人类输出统一封装(printHuman)
121
+
122
+ ```typescript
123
+ import { printHuman, renderLines } from '@easbot/terminal';
124
+
125
+ // 业务侧常见的"分组细节 + 总结 + outro"一页输出
126
+ printHuman(
127
+ [' ✓ eas-agent-creation', ' ✓ eas-agent-evolution', ' ✗ eas-legacy-tool'],
128
+ {
129
+ title: 'Project Skills Update',
130
+ outro: 'Run `easbot skills list` to see current state.',
131
+ empty: 'No skills tracked in lock file.',
132
+ },
133
+ );
134
+
135
+ // renderLines:构造 list / find 风格的分组树状
136
+ const lines = renderLines({
137
+ title: 'Project Skills (3)',
138
+ groups: [
139
+ { key: 'universal (.agents/skills)', rows: [' ✓ foo', ' ✓ bar'] },
140
+ { key: 'claude-code', rows: [' ✓ baz'] },
141
+ ],
142
+ });
143
+ ```
144
+
145
+ ### Prompt namespace(**统一交互入口**)
146
+
147
+ `Prompt` 是 `@easbot/terminal` 提供的统一交互 namespace —— **业务方唯一推荐的交互入口**。内部按 B+A 混合方案封装:
148
+
149
+ - `select / groupMultiselect / text / password / confirm`:薄包 `@clack/prompts`(多年踩坑调优过的 raw mode / 信号 / 取消)
150
+ - `multiselect`:自实现,调用本包 `Search.multiselect`(fuzzy + 锁定分区 + 翻页)
151
+ - `intro / outro / cancel / Log.* / spinner`:自实现(chalk + theme),统一配色到本包 `theme`
152
+
153
+ ```typescript
154
+ import { Prompt } from '@easbot/terminal';
155
+
156
+ // 单选
157
+ const choice = await Prompt.select({
158
+ message: '选择安装范围',
159
+ options: [
160
+ { value: 'project', label: 'Project', hint: '当前目录' },
161
+ { value: 'global', label: 'Global', hint: '主目录' },
162
+ ],
163
+ });
164
+ if (Prompt.isCancel(choice)) Prompt.cancel('用户已取消');
165
+
166
+ // 多选
167
+ const selected = await Prompt.multiselect({
168
+ message: '选择要启用的 Agent',
169
+ options: [
170
+ { value: 'claude-code', label: 'Claude Code' },
171
+ { value: 'easbot', label: 'EASBot' },
172
+ ],
173
+ initialValues: ['easbot'], // 预选
174
+ required: true,
175
+ });
176
+
177
+ // 分组多选
178
+ const skills = await Prompt.groupMultiselect({
179
+ message: '选择要安装的技能',
180
+ options: {
181
+ General: [{ value: 'foo', label: 'foo' }, { value: 'bar', label: 'bar' }],
182
+ 'Advanced Plugins': [{ value: 'baz', label: 'baz' }],
183
+ },
184
+ });
185
+
186
+ // 流程骨架
187
+ Prompt.intro(' skills ');
188
+ // ... do something ...
189
+ Prompt.outro(pc.green('Done!'));
190
+
191
+ // 取消:Prompt.cancel 是 `never`,内置 process.exit(0)
192
+ if (Prompt.isCancel(selected)) Prompt.cancel('用户已取消');
193
+
194
+ // 状态流(统一配色到 terminal theme)
195
+ Prompt.Log.info('操作成功');
196
+ Prompt.Log.warn('警告');
197
+ Prompt.Log.error('错误');
198
+ Prompt.Log.step('步骤');
199
+ Prompt.Log.message('普通消息(无前缀)');
200
+
201
+ // Spinner
202
+ const spinner = Prompt.spinner();
203
+ spinner.start('解析源...');
204
+ spinner.stop(`Found ${count} skill(s)`);
205
+ ```
206
+
207
+ **取消语义收口**:
208
+ - `Prompt.cancelSymbol` —— 唯一 sentinel(与 `Search.cancelSymbol` 同引用)
209
+ - `Prompt.isCancel(value)` —— 类型守卫,同时识别 `Prompt.cancelSymbol` 与 clack 的 `Symbol('')`
210
+ - `Prompt.cancel(message?)` —— `never` 返回 + 内置 `process.exit(0)`
211
+
212
+ ### 样式化提示(低阶辅助)
118
213
 
119
214
  ```typescript
120
215
  import { selectStyled, note, wrapNoteMessage } from '@easbot/terminal';
@@ -130,6 +225,30 @@ const choice = await selectStyled({
130
225
  note(wrapNoteMessage('配置文件已成功保存到 ~/.config/app/config.json'), '成功');
131
226
  ```
132
227
 
228
+ ### 图标 / 符号(**统一硬编码源**)
229
+
230
+ 业务方**禁止**直接写 `✓ ✗ ⚠ ℹ ◆ ● ○ │ ─ •` 等字面量。所有图标必须通过 `Symbol` namespace 取得。
231
+
232
+ ```typescript
233
+ import { Symbol as Glyph } from '@easbot/terminal';
234
+
235
+ // 状态图标(自带主题色)
236
+ console.log(`${Glyph.statusIcon('success')} 操作成功`); // ✓ 绿色
237
+ console.log(`${Glyph.statusIcon('error')} 操作失败`); // ✗ 红色
238
+ console.log(`${Glyph.statusIcon('warning')} 警告`); // ⚠ 黄色
239
+ console.log(`${Glyph.statusIcon('info')} 提示`); // ℹ 蓝色
240
+ console.log(`${Glyph.statusIcon('active')} 进行中`); // ◆ 强调色
241
+ console.log(`${Glyph.statusIcon('skipped')} 已跳过`); // ↡ 灰色
242
+ console.log(`${Glyph.statusIcon('blocked')} 已拦截`); // ⊘ 红色
243
+
244
+ // 装饰条
245
+ console.log(Glyph.bar.vertical); // │
246
+ console.log(Glyph.bar.bullet); // •
247
+
248
+ // 任意字符套上主题色
249
+ console.log(Glyph.colorize('自定义字符', 'success'));
250
+ ```
251
+
133
252
  ### Loader 旋转动画
134
253
 
135
254
  ```typescript
@@ -220,13 +339,20 @@ writer.write('second\n');
220
339
  ### CLI `--json` 规范输出
221
340
 
222
341
  ```typescript
223
- import { emitJsonOk, emitJsonError } from '@easbot/terminal';
342
+ import { emitJson, emitJsonOk, emitJsonError, wrapJson, wrapError } from '@easbot/terminal';
224
343
 
344
+ // 低阶:构造 envelope
345
+ const env = wrapJson({ items: [{ id: 1 }] }, { count: 1 });
346
+ // { ok: true, data: { items: [{ id: 1 }] }, meta: { count: 1 } }
347
+ const err = wrapError({ reason: 'not found' });
348
+ // { ok: false, data: { reason: 'not found' } }
349
+
350
+ // 高阶:直接 emit 到 stdout
225
351
  emitJsonOk({ items: [{ id: 1 }, { id: 2 }] }, { count: 2 });
226
- // {"ok":true,"data":{"items":[{"id":1},{"id":2}]},"meta":{"count":2}}
352
+ // stdout: { "ok": true, "data": { "items": [...] }, "meta": { "count": 2 } }
227
353
 
228
354
  emitJsonError('E_NOT_FOUND', '资源不存在');
229
- // {"ok":false,"error":{"code":"E_NOT_FOUND","message":"资源不存在"}}
355
+ // stdout: { "ok": false, "error": { "code": "E_NOT_FOUND", "message": "资源不存在" } }
230
356
  ```
231
357
 
232
358
  ## API 总览
@@ -266,26 +392,50 @@ emitJsonError('E_NOT_FOUND', '资源不存在');
266
392
  - `TableColumn` / `RenderTableOptions` — 表格列配置与渲染选项类型
267
393
  - `renderTable(opts)` — 渲染表格字符串
268
394
 
269
- ### 提示样式化
395
+ ### 人类输出统一封装
396
+
397
+ - `PrintHumanOptions` / `RenderLinesGroup` / `RenderLinesOptions` — 类型
398
+ - `printHuman(lines, opts)` — `lines` + `title` + `outro` 一页输出(boxed 走 `note()`)
399
+ - `formatPrintHuman(lines, opts)` — 返回字符串而非直接打印(用于嵌入更大输出)
400
+ - `renderLines(opts)` — 构造分组树状行(list / find / store list 风格)
401
+ - `printRenderLines(opts)` — 同上但直接打印
402
+ - `formatListTruncated(items, maxShow?)` / `formatList(items, maxShow?)` — 截断列表(≤N 全显 + "N more" 提示)
403
+
404
+ ### 提示样式化(低阶)
270
405
 
271
406
  - `stylePromptMessage` / `stylePromptTitle` / `stylePromptHint` — 提示文本样式化
272
407
  - `selectStyled<T>(params)` — 样式化的选择提示
273
408
  - `note(message, title?, options?)` — 样式化的注释框(支持 `columns` / `maxWidth` 注入)
274
409
  - `wrapNoteMessage(message, options?)` — 注释消息自动换行
275
410
 
276
- ### 交互式多选
277
-
278
- - `Search.multiselect<T>(options)` — 可搜索、实时过滤的多选 prompt
279
- - `options.message` — 提示信息
280
- - `options.items` — 候选项列表
281
- - `options.maxVisible?`每页显示条数
282
- - `options.initialSelected?`预选值集合(去重)
283
- - `options.required?`是否强制至少选一项
284
- - `options.lockedSection?`始终包含的锁定分区
285
- - `options.isTTY?`显式 TTY 状态(测试 / 嵌入式注入)
286
- - `Search.cancelSymbol` — 用户取消或 stdin 关闭时返回的哨兵值
287
- - `Search.approxStringWidth` / `Search.visualRowsForLine` / `Search.countVisualRowsForLines`渲染器用到的显示宽度辅助
288
- - `cancelSymbol` — 顶层再导出,等价于 `Search.cancelSymbol`(保留旧调用点无需改动)
411
+ ### **Prompt namespace(统一交互入口)**
412
+
413
+ 业务方**唯一推荐的交互入口**。细节见上文"Prompt namespace"节:
414
+
415
+ - `Prompt.select<T>(opts)` — 单选
416
+ - `Prompt.groupMultiselect<T>(opts)`分组多选
417
+ - `Prompt.multiselect<T>(opts)`搜索多选(带 fuzzy + 锁定分区 + 翻页)
418
+ - `Prompt.text(opts)`文本输入
419
+ - `Prompt.password(opts)`隐藏输入
420
+ - `Prompt.confirm(opts)`y/n 确认
421
+ - `Prompt.intro(title)` — 流程开篇(heading + 装饰线)
422
+ - `Prompt.outro(message?, { success? })` — 流程收尾(success/error 主题色)
423
+ - `Prompt.cancel(message?)` — `never`,内置 `process.exit(0)`
424
+ - `Prompt.cancelSymbol` — 唯一 sentinel(与 `Search.cancelSymbol` 同引用)
425
+ - `Prompt.isCancel(value)` — 类型守卫
426
+ - `Prompt.spinner(opts?)` — Loader.create 薄包装,返回 `SpinnerInstance`
427
+ - `Prompt.SpinnerInstance` — `{ start, stop, update, line }`
428
+ - `Prompt.Log.info / warn / error / success / step / message(msg, detail?)` — 状态流
429
+
430
+ ### 图标 / 符号(统一硬编码源)
431
+
432
+ - `Symbol` namespace:
433
+ - `Symbol.icons: Record<SymbolKind, string>` — 状态图标裸字符(`success/error/warning/info/accent/muted/heading/active/submit/cancel/locked/pending/radioActive/radioInactive/skipped/blocked/cursor`)
434
+ - `Symbol.bar.{ vertical, horizontal, cornerBottom, cornerBottomRight, teeRight, stepActive, bullet }`
435
+ - `Symbol.colorize(char, kind)` — 任意字符套上主题色
436
+ - `Symbol.statusIcon(kind)` — 返回 `Symbol.icons[kind]` + 主题色
437
+ - `Symbol.prefixed(kind, text)` — `"<icon> <text>"` 形式前缀
438
+ - `SymbolKind` 类型
289
439
 
290
440
  ### Loader 旋转动画
291
441
 
@@ -309,7 +459,7 @@ emitJsonError('E_NOT_FOUND', '资源不存在');
309
459
  `Input` 是 **namespace**,含 3 个 stateless helper。测试时通过 `input` / `output` 注入 mock stream。
310
460
 
311
461
  - `Input.readText(options)` — 读单行。Options: `message` / `hint?` / `defaultValue?` / `hidden?` / `messageColor?` / `hintColor?` / `input?` / `output?`
312
- - `Input.readConfirm(options)` — y/n 确认,无效输入会递归重试。Options: `message` / `hint?` / `defaultValue?`(默认 `true`)
462
+ - `Input.readConfirm(options)` — y/n 确认,无效输入会递归重试。Options: `message` / `hint?` / `defaultValue?`(默认 `true`)/ `messageColor?` / `hintColor?` / `input?` / `output?`
313
463
  - `Input.readPassword(options)` — `readText` 的 `hidden: true` 别名
314
464
 
315
465
  ### Markdown 渲染
@@ -320,8 +470,13 @@ emitJsonError('E_NOT_FOUND', '资源不存在');
320
470
 
321
471
  ### JSON 规范输出
322
472
 
323
- - `emitJsonOk(data, meta?)` 输出 `{ ok: true, data, meta? }` JSON 响应
324
- - `emitJsonError(code, message)` 输出 `{ ok: false, error: { code, message } }` JSON 响应
473
+ - `wrapJson<T>(data, meta?, ok?)` `JsonEnvelope<T>`(构造 envelope;ok 默认 true
474
+ - `wrapError<T>(data, meta?)` `JsonEnvelope<T>`(ok:false 错误态)
475
+ - `emitJson(envelope, indent?)` → 序列化 + console.log
476
+ - `emitCommandJson(envelope)` — 同上固定 indent=2
477
+ - `emitJsonOk(data, meta?)` — 直接 emit `{ ok: true, data, meta? }`
478
+ - `emitJsonError(code, message)` — 直接 emit `{ ok: false, error: { code, message } }`
479
+ - `JsonEnvelope<T>` — `{ ok: boolean; data: T; meta?: Record<string, unknown> }`
325
480
 
326
481
  ### 流与状态
327
482