@easbot/terminal 0.3.13 → 0.3.14
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 +195 -32
- package/README.md +180 -27
- package/package.json +3 -3
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,
|
|
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** —
|
|
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.
|
|
109
|
-
{ name: '@easbot/note', version: '0.3.
|
|
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
|
-
###
|
|
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,28 @@ 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
|
+
|
|
243
|
+
// Decoration glyphs
|
|
244
|
+
console.log(Glyph.bar.vertical); // │
|
|
245
|
+
console.log(Glyph.bar.bullet); // •
|
|
246
|
+
|
|
247
|
+
// Wrap any character with a theme color
|
|
248
|
+
console.log(Glyph.colorize('custom', 'success'));
|
|
249
|
+
```
|
|
250
|
+
|
|
133
251
|
### Loader (CLI spinner)
|
|
134
252
|
|
|
135
253
|
```typescript
|
|
@@ -168,12 +286,12 @@ const pw = await Input.readPassword({ message: 'Password:' });
|
|
|
168
286
|
```typescript
|
|
169
287
|
import { renderMarkdown } from '@easbot/terminal';
|
|
170
288
|
|
|
171
|
-
console.log(renderMarkdown('# Title\n\nHello **world**.',
|
|
289
|
+
console.log(renderMarkdown('# Title\n\nHello **world**.', opts).join('\n'));
|
|
172
290
|
```
|
|
173
291
|
|
|
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.
|
|
292
|
+
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
293
|
|
|
176
|
-
### Searchable multi-select
|
|
294
|
+
### Searchable multi-select (`Search.multiselect`)
|
|
177
295
|
|
|
178
296
|
```typescript
|
|
179
297
|
import { Search, cancelSymbol } from '@easbot/terminal';
|
|
@@ -220,13 +338,29 @@ writer.write('second\n');
|
|
|
220
338
|
### CLI `--json` contract
|
|
221
339
|
|
|
222
340
|
```typescript
|
|
223
|
-
import { emitJsonOk, emitJsonError } from '@easbot/terminal';
|
|
341
|
+
import { emitJson, emitJsonOk, emitJsonError, wrapJson, wrapError } from '@easbot/terminal';
|
|
342
|
+
|
|
343
|
+
// Low-level: build envelopes
|
|
344
|
+
const env = wrapJson({ items: [{ id: 1 }] }, { count: 1 });
|
|
345
|
+
// { ok: true, data: { items: [{ id: 1 }] }, meta: { count: 1 } }
|
|
346
|
+
const err = wrapError({ reason: 'not found' });
|
|
347
|
+
// { ok: false, data: { reason: 'not found' } }
|
|
224
348
|
|
|
349
|
+
// High-level: emit straight to stdout
|
|
225
350
|
emitJsonOk({ items: [{ id: 1 }, { id: 2 }] }, { count: 2 });
|
|
226
|
-
//
|
|
351
|
+
// stdout:
|
|
352
|
+
// {
|
|
353
|
+
// "ok": true,
|
|
354
|
+
// "data": { "items": [{ "id": 1 }, { "id": 2 }] },
|
|
355
|
+
// "meta": { "count": 2 }
|
|
356
|
+
// }
|
|
227
357
|
|
|
228
358
|
emitJsonError('E_NOT_FOUND', 'Resource not found');
|
|
229
|
-
//
|
|
359
|
+
// stdout:
|
|
360
|
+
// {
|
|
361
|
+
// "ok": false,
|
|
362
|
+
// "error": { "code": "E_NOT_FOUND", "message": "Resource not found" }
|
|
363
|
+
// }
|
|
230
364
|
```
|
|
231
365
|
|
|
232
366
|
## API Overview
|
|
@@ -240,7 +374,7 @@ emitJsonError('E_NOT_FOUND', 'Resource not found');
|
|
|
240
374
|
|
|
241
375
|
### ANSI utilities
|
|
242
376
|
|
|
243
|
-
- `stripAnsi(input)` — strip SGR / OSC-8 sequences from a string
|
|
377
|
+
- `stripAnsi(input)` — strip ANSI SGR / OSC-8 sequences from a string
|
|
244
378
|
- `visibleWidth(input)` — visible character width after stripping ANSI
|
|
245
379
|
|
|
246
380
|
### Paths & display
|
|
@@ -266,26 +400,50 @@ emitJsonError('E_NOT_FOUND', 'Resource not found');
|
|
|
266
400
|
- `TableColumn` / `RenderTableOptions` — column and render-option types
|
|
267
401
|
- `renderTable(opts)` — render a table string
|
|
268
402
|
|
|
269
|
-
###
|
|
403
|
+
### Human output helpers
|
|
404
|
+
|
|
405
|
+
- `PrintHumanOptions` / `RenderLinesGroup` / `RenderLinesOptions` — types
|
|
406
|
+
- `printHuman(lines, opts)` — render `lines` + `title` + `outro` as a single page (boxed variant goes through `note()`)
|
|
407
|
+
- `formatPrintHuman(lines, opts)` — same as `printHuman` but returns the string (for embedding)
|
|
408
|
+
- `renderLines(opts)` — build a grouped tree (list / find / store-list style)
|
|
409
|
+
- `printRenderLines(opts)` — same as `renderLines` but prints directly
|
|
410
|
+
- `formatListTruncated(items, maxShow?)` / `formatList(items, maxShow?)` — truncate long lists to ≤N with a `"+K more"` suffix
|
|
411
|
+
|
|
412
|
+
### Styled prompts (low-level)
|
|
270
413
|
|
|
271
414
|
- `stylePromptMessage` / `stylePromptTitle` / `stylePromptHint` — prompt text styling
|
|
272
415
|
- `selectStyled<T>(params)` — themed select prompt
|
|
273
416
|
- `note(message, title?, options?)` — themed note box (supports `columns` / `maxWidth` injection)
|
|
274
417
|
- `wrapNoteMessage(message, options?)` — auto-wrap a note's message
|
|
275
418
|
|
|
276
|
-
###
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
- `
|
|
287
|
-
- `
|
|
288
|
-
- `
|
|
419
|
+
### **Prompt namespace (unified entry point)**
|
|
420
|
+
|
|
421
|
+
The single recommended entry point for business code. Details in the "Prompt namespace" section above:
|
|
422
|
+
|
|
423
|
+
- `Prompt.select<T>(opts)` — single-select
|
|
424
|
+
- `Prompt.groupMultiselect<T>(opts)` — grouped multi-select
|
|
425
|
+
- `Prompt.multiselect<T>(opts)` — searchable multi-select (fuzzy + locked section + pagination)
|
|
426
|
+
- `Prompt.text(opts)` — text input
|
|
427
|
+
- `Prompt.password(opts)` — hidden input
|
|
428
|
+
- `Prompt.confirm(opts)` — y/n confirmation
|
|
429
|
+
- `Prompt.intro(title)` — flow header (heading + decoration line)
|
|
430
|
+
- `Prompt.outro(message?, { success? })` — flow footer (success/error themed)
|
|
431
|
+
- `Prompt.cancel(message?)` — `never`, internal `process.exit(0)`
|
|
432
|
+
- `Prompt.cancelSymbol` — single sentinel (same reference as `Search.cancelSymbol`)
|
|
433
|
+
- `Prompt.isCancel(value)` — type guard
|
|
434
|
+
- `Prompt.spinner(opts?)` — thin wrapper over `Loader.create`, returns `SpinnerInstance`
|
|
435
|
+
- `Prompt.SpinnerInstance` — `{ start, stop, update, line }`
|
|
436
|
+
- `Prompt.Log.info / warn / error / success / step / message(msg, detail?)` — status log
|
|
437
|
+
|
|
438
|
+
### Icons / Symbols (unified source of truth)
|
|
439
|
+
|
|
440
|
+
- `Symbol` namespace:
|
|
441
|
+
- `Symbol.icons: Record<SymbolKind, string>` — status icon literals (`success/error/warning/info/accent/muted/heading/active/submit/cancel/locked/pending/radioActive/radioInactive/skipped/cursor/accentBright`)
|
|
442
|
+
- `Symbol.bar.{ vertical, horizontal, cornerBottom, cornerBottomRight, teeRight, stepActive, bullet }`
|
|
443
|
+
- `Symbol.colorize(char, kind)` — wrap any character with a theme color
|
|
444
|
+
- `Symbol.statusIcon(kind)` — return `Symbol.icons[kind]` themed
|
|
445
|
+
- `Symbol.prefixed(kind, text)` — `"<icon> <text>"` format
|
|
446
|
+
- `SymbolKind` type
|
|
289
447
|
|
|
290
448
|
### Loader (CLI spinner)
|
|
291
449
|
|
|
@@ -309,19 +467,24 @@ emitJsonError('E_NOT_FOUND', 'Resource not found');
|
|
|
309
467
|
`Input` is a **namespace** with three stateless helpers. Pass `input` / `output` to swap in a mock stream for tests.
|
|
310
468
|
|
|
311
469
|
- `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`)
|
|
470
|
+
- `Input.readConfirm(options)` — yes/no prompt; recursively re-prompts on invalid input. Options: `message`, `hint?`, `defaultValue?` (default `true`), `messageColor?`, `hintColor?`, `input?`, `output?`
|
|
313
471
|
- `Input.readPassword(options)` — alias for `readText` with `hidden: true`
|
|
314
472
|
|
|
315
473
|
### Markdown (minimal subset)
|
|
316
474
|
|
|
317
475
|
- `renderMarkdown(md, opts?)` → `string[]` (one entry per logical line)
|
|
318
|
-
- Supports: `# / ## / ###` headings, `---` horizontal rules,
|
|
476
|
+
- Supports: `# / ## / ###` headings, `---` horizontal rules, `-` / `*` / `1.` lists, fenced code blocks (` ``` `), inline `**bold**` / `*italic*` / `` `code` `` / `[text](url)`
|
|
319
477
|
- `opts.headingColor?` / `boldColor?` / `italicColor?` / `codeColor?` / `linkColor?` — color functions to override the default theme
|
|
320
478
|
|
|
321
|
-
###
|
|
479
|
+
### JSON contract output
|
|
322
480
|
|
|
323
|
-
- `
|
|
324
|
-
- `
|
|
481
|
+
- `wrapJson<T>(data, meta?, ok?)` → `JsonEnvelope<T>` (build envelope; `ok` defaults to `true`)
|
|
482
|
+
- `wrapError<T>(data, meta?)` → `JsonEnvelope<T>` (`ok:false` error variant)
|
|
483
|
+
- `emitJson(envelope, indent?)` — serialize + `console.log`
|
|
484
|
+
- `emitCommandJson(envelope)` — same as above with fixed `indent=2`
|
|
485
|
+
- `emitJsonOk(data, meta?)` — emit `{ ok: true, data, meta? }` directly
|
|
486
|
+
- `emitJsonError(code, message)` — emit `{ ok: false, error: { code, message } }` directly
|
|
487
|
+
- `JsonEnvelope<T>` — `{ ok: boolean; data: T; meta?: Record<string, unknown> }`
|
|
325
488
|
|
|
326
489
|
### Streams & state
|
|
327
490
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
109
|
-
{ name: '@easbot/note', version: '0.3.
|
|
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,28 @@ 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
|
+
|
|
242
|
+
// 装饰条
|
|
243
|
+
console.log(Glyph.bar.vertical); // │
|
|
244
|
+
console.log(Glyph.bar.bullet); // •
|
|
245
|
+
|
|
246
|
+
// 任意字符套上主题色
|
|
247
|
+
console.log(Glyph.colorize('自定义字符', 'success'));
|
|
248
|
+
```
|
|
249
|
+
|
|
133
250
|
### Loader 旋转动画
|
|
134
251
|
|
|
135
252
|
```typescript
|
|
@@ -220,13 +337,20 @@ writer.write('second\n');
|
|
|
220
337
|
### CLI `--json` 规范输出
|
|
221
338
|
|
|
222
339
|
```typescript
|
|
223
|
-
import { emitJsonOk, emitJsonError } from '@easbot/terminal';
|
|
340
|
+
import { emitJson, emitJsonOk, emitJsonError, wrapJson, wrapError } from '@easbot/terminal';
|
|
341
|
+
|
|
342
|
+
// 低阶:构造 envelope
|
|
343
|
+
const env = wrapJson({ items: [{ id: 1 }] }, { count: 1 });
|
|
344
|
+
// { ok: true, data: { items: [{ id: 1 }] }, meta: { count: 1 } }
|
|
345
|
+
const err = wrapError({ reason: 'not found' });
|
|
346
|
+
// { ok: false, data: { reason: 'not found' } }
|
|
224
347
|
|
|
348
|
+
// 高阶:直接 emit 到 stdout
|
|
225
349
|
emitJsonOk({ items: [{ id: 1 }, { id: 2 }] }, { count: 2 });
|
|
226
|
-
// {"ok":true,"data":{"items":[
|
|
350
|
+
// stdout: { "ok": true, "data": { "items": [...] }, "meta": { "count": 2 } }
|
|
227
351
|
|
|
228
352
|
emitJsonError('E_NOT_FOUND', '资源不存在');
|
|
229
|
-
// {"ok":false,"error":{"code":"E_NOT_FOUND","message":"资源不存在"}}
|
|
353
|
+
// stdout: { "ok": false, "error": { "code": "E_NOT_FOUND", "message": "资源不存在" } }
|
|
230
354
|
```
|
|
231
355
|
|
|
232
356
|
## API 总览
|
|
@@ -266,26 +390,50 @@ emitJsonError('E_NOT_FOUND', '资源不存在');
|
|
|
266
390
|
- `TableColumn` / `RenderTableOptions` — 表格列配置与渲染选项类型
|
|
267
391
|
- `renderTable(opts)` — 渲染表格字符串
|
|
268
392
|
|
|
269
|
-
###
|
|
393
|
+
### 人类输出统一封装
|
|
394
|
+
|
|
395
|
+
- `PrintHumanOptions` / `RenderLinesGroup` / `RenderLinesOptions` — 类型
|
|
396
|
+
- `printHuman(lines, opts)` — `lines` + `title` + `outro` 一页输出(boxed 走 `note()`)
|
|
397
|
+
- `formatPrintHuman(lines, opts)` — 返回字符串而非直接打印(用于嵌入更大输出)
|
|
398
|
+
- `renderLines(opts)` — 构造分组树状行(list / find / store list 风格)
|
|
399
|
+
- `printRenderLines(opts)` — 同上但直接打印
|
|
400
|
+
- `formatListTruncated(items, maxShow?)` / `formatList(items, maxShow?)` — 截断列表(≤N 全显 + "N more" 提示)
|
|
401
|
+
|
|
402
|
+
### 提示样式化(低阶)
|
|
270
403
|
|
|
271
404
|
- `stylePromptMessage` / `stylePromptTitle` / `stylePromptHint` — 提示文本样式化
|
|
272
405
|
- `selectStyled<T>(params)` — 样式化的选择提示
|
|
273
406
|
- `note(message, title?, options?)` — 样式化的注释框(支持 `columns` / `maxWidth` 注入)
|
|
274
407
|
- `wrapNoteMessage(message, options?)` — 注释消息自动换行
|
|
275
408
|
|
|
276
|
-
###
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
- `
|
|
287
|
-
- `
|
|
288
|
-
- `
|
|
409
|
+
### **Prompt namespace(统一交互入口)**
|
|
410
|
+
|
|
411
|
+
业务方**唯一推荐的交互入口**。细节见上文"Prompt namespace"节:
|
|
412
|
+
|
|
413
|
+
- `Prompt.select<T>(opts)` — 单选
|
|
414
|
+
- `Prompt.groupMultiselect<T>(opts)` — 分组多选
|
|
415
|
+
- `Prompt.multiselect<T>(opts)` — 搜索多选(带 fuzzy + 锁定分区 + 翻页)
|
|
416
|
+
- `Prompt.text(opts)` — 文本输入
|
|
417
|
+
- `Prompt.password(opts)` — 隐藏输入
|
|
418
|
+
- `Prompt.confirm(opts)` — y/n 确认
|
|
419
|
+
- `Prompt.intro(title)` — 流程开篇(heading + 装饰线)
|
|
420
|
+
- `Prompt.outro(message?, { success? })` — 流程收尾(success/error 主题色)
|
|
421
|
+
- `Prompt.cancel(message?)` — `never`,内置 `process.exit(0)`
|
|
422
|
+
- `Prompt.cancelSymbol` — 唯一 sentinel(与 `Search.cancelSymbol` 同引用)
|
|
423
|
+
- `Prompt.isCancel(value)` — 类型守卫
|
|
424
|
+
- `Prompt.spinner(opts?)` — Loader.create 薄包装,返回 `SpinnerInstance`
|
|
425
|
+
- `Prompt.SpinnerInstance` — `{ start, stop, update, line }`
|
|
426
|
+
- `Prompt.Log.info / warn / error / success / step / message(msg, detail?)` — 状态流
|
|
427
|
+
|
|
428
|
+
### 图标 / 符号(统一硬编码源)
|
|
429
|
+
|
|
430
|
+
- `Symbol` namespace:
|
|
431
|
+
- `Symbol.icons: Record<SymbolKind, string>` — 状态图标裸字符(`success/error/warning/info/accent/muted/heading/active/submit/cancel/locked/pending/radioActive/radioInactive/skipped/cursor/accentBright`)
|
|
432
|
+
- `Symbol.bar.{ vertical, horizontal, cornerBottom, cornerBottomRight, teeRight, stepActive, bullet }`
|
|
433
|
+
- `Symbol.colorize(char, kind)` — 任意字符套上主题色
|
|
434
|
+
- `Symbol.statusIcon(kind)` — 返回 `Symbol.icons[kind]` + 主题色
|
|
435
|
+
- `Symbol.prefixed(kind, text)` — `"<icon> <text>"` 形式前缀
|
|
436
|
+
- `SymbolKind` 类型
|
|
289
437
|
|
|
290
438
|
### Loader 旋转动画
|
|
291
439
|
|
|
@@ -309,7 +457,7 @@ emitJsonError('E_NOT_FOUND', '资源不存在');
|
|
|
309
457
|
`Input` 是 **namespace**,含 3 个 stateless helper。测试时通过 `input` / `output` 注入 mock stream。
|
|
310
458
|
|
|
311
459
|
- `Input.readText(options)` — 读单行。Options: `message` / `hint?` / `defaultValue?` / `hidden?` / `messageColor?` / `hintColor?` / `input?` / `output?`
|
|
312
|
-
- `Input.readConfirm(options)` — y/n 确认,无效输入会递归重试。Options: `message` / `hint?` / `defaultValue?`(默认 `true
|
|
460
|
+
- `Input.readConfirm(options)` — y/n 确认,无效输入会递归重试。Options: `message` / `hint?` / `defaultValue?`(默认 `true`)/ `messageColor?` / `hintColor?` / `input?` / `output?`
|
|
313
461
|
- `Input.readPassword(options)` — `readText` 的 `hidden: true` 别名
|
|
314
462
|
|
|
315
463
|
### Markdown 渲染
|
|
@@ -320,8 +468,13 @@ emitJsonError('E_NOT_FOUND', '资源不存在');
|
|
|
320
468
|
|
|
321
469
|
### JSON 规范输出
|
|
322
470
|
|
|
323
|
-
- `
|
|
324
|
-
- `
|
|
471
|
+
- `wrapJson<T>(data, meta?, ok?)` → `JsonEnvelope<T>`(构造 envelope;ok 默认 true)
|
|
472
|
+
- `wrapError<T>(data, meta?)` → `JsonEnvelope<T>`(ok:false 错误态)
|
|
473
|
+
- `emitJson(envelope, indent?)` → 序列化 + console.log
|
|
474
|
+
- `emitCommandJson(envelope)` — 同上固定 indent=2
|
|
475
|
+
- `emitJsonOk(data, meta?)` — 直接 emit `{ ok: true, data, meta? }`
|
|
476
|
+
- `emitJsonError(code, message)` — 直接 emit `{ ok: false, error: { code, message } }`
|
|
477
|
+
- `JsonEnvelope<T>` — `{ ok: boolean; data: T; meta?: Record<string, unknown> }`
|
|
325
478
|
|
|
326
479
|
### 流与状态
|
|
327
480
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@easbot/terminal",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Terminal interaction toolkit for the EASBOT ecosystem — themed colors, ANSI utilities, OSC-8 hyperlinks, command formatting, table rendering, styled prompts, safe stream writes, and terminal-state recovery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@clack/prompts": "^1.7.0",
|
|
49
49
|
"chalk": "^6.0.0",
|
|
50
|
-
"@easbot/
|
|
51
|
-
"@easbot/
|
|
50
|
+
"@easbot/types": "0.3.14",
|
|
51
|
+
"@easbot/utils": "0.3.14"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^25.6.2",
|