@breadc/tui 1.0.0-beta.4 → 1.0.0-beta.5

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/dist/index.d.mts CHANGED
@@ -1,7 +1,137 @@
1
- //#region src/progress/types.d.ts
2
- interface ProgressOption {}
1
+ import { Writable } from "node:stream";
2
+
3
+ //#region src/chat/types.d.ts
4
+ type AnyState = Record<string, unknown>;
5
+ type LogLevel = 'log' | 'info' | 'warn' | 'error';
6
+ interface LogEntry {
7
+ level: LogLevel;
8
+ tag?: string;
9
+ message: string;
10
+ createdAt: Date;
11
+ }
12
+ interface OutputStream {
13
+ write(chunk: string): boolean;
14
+ isTTY?: boolean;
15
+ }
3
16
  //#endregion
4
- //#region src/progress/progress.d.ts
5
- declare function progress<T>(iterator: Iterable<T>): Iterable<T>;
17
+ //#region src/chat/widget.d.ts
18
+ interface RenderContext<S extends AnyState = AnyState> {
19
+ tick: number;
20
+ state: S;
21
+ fields: AnyState;
22
+ }
23
+ type WidgetTemplate<S extends AnyState = AnyState> = string | string[] | ((ctx: RenderContext<S>) => string | string[]);
24
+ type WidgetFieldResolver<S extends AnyState = AnyState> = (ctx: RenderContext<S>) => unknown;
25
+ type WidgetFields<S extends AnyState = AnyState> = Record<string, WidgetFieldResolver<S>>;
26
+ interface WidgetSpec<S extends AnyState = AnyState> {
27
+ state: S;
28
+ template: WidgetTemplate<S>;
29
+ fields?: WidgetFields<S>;
30
+ }
31
+ interface WidgetHandle<S extends AnyState = AnyState> {
32
+ readonly id: string;
33
+ setState(next: Partial<S> | ((previous: S) => Partial<S> | S)): WidgetHandle<S>;
34
+ setTemplate(template: WidgetTemplate<S>): WidgetHandle<S>;
35
+ setFields(fields: WidgetFields<S>): WidgetHandle<S>;
36
+ remove(): void;
37
+ }
38
+ interface SpinnerWidgetState {
39
+ message: string;
40
+ }
41
+ interface SpinnerWidgetOptions<S extends AnyState = AnyState> {
42
+ frames?: string[];
43
+ template?: WidgetTemplate<SpinnerWidgetState & S>;
44
+ state?: S;
45
+ fields?: WidgetFields<SpinnerWidgetState & S>;
46
+ }
47
+ interface ProgressWidgetState {
48
+ message: string;
49
+ value: number;
50
+ total: number;
51
+ }
52
+ interface ProgressWidgetOptions<S extends AnyState = AnyState> {
53
+ value?: number;
54
+ total?: number;
55
+ width?: number;
56
+ complete?: string;
57
+ incomplete?: string;
58
+ template?: WidgetTemplate<ProgressWidgetState & S>;
59
+ state?: S;
60
+ fields?: WidgetFields<ProgressWidgetState & S>;
61
+ }
6
62
  //#endregion
7
- export { ProgressOption, progress };
63
+ //#region src/chat/renderer.d.ts
64
+ interface RendererOptions {
65
+ stream: OutputStream;
66
+ isTTY: boolean;
67
+ tickInterval: number;
68
+ nonTTYInterval: number;
69
+ }
70
+ declare class Renderer {
71
+ private readonly stream;
72
+ private readonly isTTY;
73
+ private readonly tickInterval;
74
+ private readonly nonTTYInterval;
75
+ private readonly widgets;
76
+ private tick;
77
+ private disposed;
78
+ private idCounter;
79
+ private prevBottomCount;
80
+ private scheduled;
81
+ private queuedForce;
82
+ private ticker;
83
+ private lastNonTTYRender;
84
+ constructor(options: RendererOptions);
85
+ writeAboveBottom(line: string): void;
86
+ createWidget<S extends AnyState>(spec: WidgetSpec<S>): WidgetHandle<S>;
87
+ createSpinnerWidget<S extends AnyState = AnyState>(message: string, options?: SpinnerWidgetOptions<S>): WidgetHandle<SpinnerWidgetState & S>;
88
+ createProgressWidget<S extends AnyState = AnyState>(message: string, options?: ProgressWidgetOptions<S>): WidgetHandle<ProgressWidgetState & S>;
89
+ render(force?: boolean): void;
90
+ clearBottom(): void;
91
+ dispose(): void;
92
+ private createId;
93
+ private stopTicker;
94
+ private ensureTicker;
95
+ private clearBottomTTY;
96
+ private renderWidgets;
97
+ private drawBottomTTY;
98
+ private drawBottomNonTTY;
99
+ private scheduleRender;
100
+ }
101
+ //#endregion
102
+ //#region src/chat/log.d.ts
103
+ interface LogFormatterOptions {
104
+ tag?: string;
105
+ columns: number;
106
+ isTTY: boolean;
107
+ }
108
+ //#endregion
109
+ //#region src/chat/chat.d.ts
110
+ interface ChatOptions {
111
+ renderer?: Renderer;
112
+ stream?: Writable & {
113
+ isTTY?: boolean;
114
+ columns?: number;
115
+ };
116
+ tickInterval?: number;
117
+ nonTTYInterval?: number;
118
+ log?: {
119
+ tag?: string;
120
+ format?: (entry: LogEntry, options: LogFormatterOptions) => string;
121
+ };
122
+ }
123
+ interface Chat {
124
+ log(...args: unknown[]): void;
125
+ info(...args: unknown[]): void;
126
+ warn(...args: unknown[]): void;
127
+ error(...args: unknown[]): void;
128
+ widget<S extends AnyState>(spec: WidgetSpec<S>): WidgetHandle<S>;
129
+ spinner<S extends AnyState = AnyState>(message: string, options?: SpinnerWidgetOptions<S>): WidgetHandle<SpinnerWidgetState & S>;
130
+ progress<S extends AnyState = AnyState>(message: string, options?: ProgressWidgetOptions<S>): WidgetHandle<ProgressWidgetState & S>;
131
+ render(force?: boolean): void;
132
+ clearBottom(): void;
133
+ dispose(): void;
134
+ }
135
+ declare function chat(options?: ChatOptions): Chat;
136
+ //#endregion
137
+ export { AnyState, LogEntry, LogLevel, OutputStream, chat };
package/dist/index.mjs CHANGED
@@ -1,7 +1,353 @@
1
- //#region src/progress/progress.ts
2
- function* progress(iterator) {
3
- for (const item of iterator) yield item;
1
+ import { format } from "node:util";
2
+ import readline from "node:readline";
3
+ import { cyan, gray, red, yellow } from "@breadc/color";
4
+
5
+ //#region src/chat/helpers.ts
6
+ function renderProgressBar(value, total, barOptions) {
7
+ const width = Math.max(1, barOptions.width);
8
+ const complete = barOptions.complete ?? "=";
9
+ const incomplete = barOptions.incomplete ?? "-";
10
+ const ratio = total > 0 ? clamp(value / total, 0, 1) : 0;
11
+ const completeCount = Math.round(ratio * width);
12
+ return complete.repeat(completeCount) + incomplete.repeat(width - completeCount);
13
+ }
14
+ function renderPercent(value, total) {
15
+ if (total <= 0) return 0;
16
+ return Math.floor(clamp(value / total * 100, 0, 100));
17
+ }
18
+ function renderTemplateLines(template, context, resolvedValues) {
19
+ const rawTemplate = typeof template === "function" ? template(context) : template;
20
+ const templateLines = Array.isArray(rawTemplate) ? rawTemplate : [rawTemplate];
21
+ const lines = [];
22
+ for (const line of templateLines) {
23
+ const text = line.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => {
24
+ const value = resolvedValues[key];
25
+ return stringifyValue(value);
26
+ });
27
+ for (const chunk of text.split(/\r?\n/g)) lines.push(chunk);
28
+ }
29
+ return lines;
30
+ }
31
+ function stringifyValue(value) {
32
+ if (value === void 0 || value === null) return "";
33
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
34
+ try {
35
+ return JSON.stringify(value);
36
+ } catch {
37
+ return String(value);
38
+ }
39
+ }
40
+ function numericOrDefault(value, fallback) {
41
+ const parsed = Number(value);
42
+ return Number.isFinite(parsed) ? parsed : fallback;
43
+ }
44
+ function normalizeProgressValue(value, total) {
45
+ if (total <= 0) return 0;
46
+ return clamp(value, 0, total);
47
+ }
48
+ function clamp(value, min, max) {
49
+ return Math.min(Math.max(value, min), max);
50
+ }
51
+
52
+ //#endregion
53
+ //#region src/chat/renderer.ts
54
+ const DEFAULT_SPINNER_FRAMES = [
55
+ "-",
56
+ "\\",
57
+ "|",
58
+ "/"
59
+ ];
60
+ const DEFAULT_PROGRESS_WIDTH = 24;
61
+ var Renderer = class {
62
+ constructor(options) {
63
+ this.widgets = [];
64
+ this.tick = 0;
65
+ this.disposed = false;
66
+ this.idCounter = 0;
67
+ this.prevBottomCount = 0;
68
+ this.scheduled = false;
69
+ this.queuedForce = false;
70
+ this.lastNonTTYRender = 0;
71
+ this.stream = options.stream;
72
+ this.isTTY = options.isTTY;
73
+ this.tickInterval = options.tickInterval;
74
+ this.nonTTYInterval = options.nonTTYInterval;
75
+ }
76
+ writeAboveBottom(line) {
77
+ if (this.disposed) return;
78
+ if (this.isTTY) this.clearBottomTTY();
79
+ this.stream.write(`${line}\n`);
80
+ if (this.isTTY) this.drawBottomTTY();
81
+ }
82
+ createWidget(spec) {
83
+ const widget = {
84
+ id: this.createId(),
85
+ state: { ...spec.state },
86
+ template: spec.template,
87
+ fields: { ...spec.fields ?? {} }
88
+ };
89
+ this.widgets.push(widget);
90
+ this.ensureTicker();
91
+ this.scheduleRender(true);
92
+ const handle = {
93
+ id: widget.id,
94
+ setState: (next) => {
95
+ if (this.disposed || !this.widgets.includes(widget)) return handle;
96
+ const patch = typeof next === "function" ? next({ ...widget.state }) : next;
97
+ widget.state = {
98
+ ...widget.state,
99
+ ...patch
100
+ };
101
+ this.scheduleRender(false);
102
+ return handle;
103
+ },
104
+ setTemplate: (template) => {
105
+ if (this.disposed || !this.widgets.includes(widget)) return handle;
106
+ widget.template = template;
107
+ this.scheduleRender(false);
108
+ return handle;
109
+ },
110
+ setFields: (fields) => {
111
+ if (this.disposed || !this.widgets.includes(widget)) return handle;
112
+ widget.fields = {
113
+ ...widget.fields,
114
+ ...fields
115
+ };
116
+ this.scheduleRender(false);
117
+ return handle;
118
+ },
119
+ remove: () => {
120
+ if (this.disposed) return;
121
+ const index = this.widgets.indexOf(widget);
122
+ if (index === -1) return;
123
+ this.widgets.splice(index, 1);
124
+ if (this.widgets.length === 0) this.stopTicker();
125
+ this.scheduleRender(true);
126
+ }
127
+ };
128
+ return handle;
129
+ }
130
+ createSpinnerWidget(message, options = {}) {
131
+ const frames = options.frames?.length ? options.frames : DEFAULT_SPINNER_FRAMES;
132
+ const template = options.template ?? "{frame} {message}";
133
+ const fields = {
134
+ frame: (ctx) => {
135
+ if (frames.length === 0) return "";
136
+ return frames[ctx.tick % frames.length];
137
+ },
138
+ ...options.fields ?? {}
139
+ };
140
+ return this.createWidget({
141
+ state: {
142
+ message,
143
+ ...options.state ?? {}
144
+ },
145
+ template,
146
+ fields
147
+ });
148
+ }
149
+ createProgressWidget(message, options = {}) {
150
+ const width = Math.max(1, options.width || DEFAULT_PROGRESS_WIDTH);
151
+ const complete = options.complete ?? "█";
152
+ const incomplete = options.incomplete ?? "░";
153
+ const template = options.template ?? "{message} [{bar}] {percent}% {value}/{total}";
154
+ const fields = {
155
+ bar: (ctx) => {
156
+ const total = numericOrDefault(ctx.state.total, 0);
157
+ return renderProgressBar(normalizeProgressValue(numericOrDefault(ctx.state.value, 0), total), total, {
158
+ width,
159
+ complete,
160
+ incomplete
161
+ });
162
+ },
163
+ percent: (ctx) => {
164
+ const total = numericOrDefault(ctx.state.total, 0);
165
+ return renderPercent(normalizeProgressValue(numericOrDefault(ctx.state.value, 0), total), total);
166
+ },
167
+ ...options.fields ?? {}
168
+ };
169
+ return this.createWidget({
170
+ state: {
171
+ message,
172
+ value: options.value ?? 0,
173
+ total: options.total ?? 100,
174
+ ...options.state ?? {}
175
+ },
176
+ template,
177
+ fields
178
+ });
179
+ }
180
+ render(force = false) {
181
+ if (this.disposed) return;
182
+ if (this.isTTY) {
183
+ this.clearBottomTTY();
184
+ this.drawBottomTTY();
185
+ return;
186
+ }
187
+ this.drawBottomNonTTY(force);
188
+ }
189
+ clearBottom() {
190
+ if (this.disposed || !this.isTTY) return;
191
+ this.clearBottomTTY();
192
+ }
193
+ dispose() {
194
+ if (this.disposed) return;
195
+ this.disposed = true;
196
+ this.stopTicker();
197
+ this.widgets.length = 0;
198
+ if (this.isTTY) this.clearBottomTTY();
199
+ }
200
+ createId() {
201
+ this.idCounter += 1;
202
+ return `widget-${this.idCounter}`;
203
+ }
204
+ stopTicker() {
205
+ if (!this.ticker) return;
206
+ clearInterval(this.ticker);
207
+ this.ticker = void 0;
208
+ }
209
+ ensureTicker() {
210
+ if (this.ticker || this.disposed || this.widgets.length === 0) return;
211
+ this.ticker = setInterval(() => {
212
+ if (this.disposed || this.widgets.length === 0) {
213
+ this.stopTicker();
214
+ return;
215
+ }
216
+ this.tick += 1;
217
+ this.scheduleRender(false);
218
+ }, this.tickInterval);
219
+ }
220
+ clearBottomTTY() {
221
+ if (!this.isTTY || this.prevBottomCount === 0) return;
222
+ const output = this.stream;
223
+ if (this.prevBottomCount > 1) readline.moveCursor(output, 0, -(this.prevBottomCount - 1));
224
+ readline.cursorTo(output, 0);
225
+ readline.clearScreenDown(output);
226
+ this.prevBottomCount = 0;
227
+ }
228
+ renderWidgets() {
229
+ const lines = [];
230
+ for (const widget of this.widgets) {
231
+ const context = {
232
+ tick: this.tick,
233
+ state: widget.state,
234
+ fields: {}
235
+ };
236
+ const resolvedValues = {
237
+ ...widget.state,
238
+ tick: this.tick
239
+ };
240
+ for (const [key, resolver] of Object.entries(widget.fields)) resolvedValues[key] = resolver(context);
241
+ lines.push(...renderTemplateLines(widget.template, context, resolvedValues));
242
+ }
243
+ return lines;
244
+ }
245
+ drawBottomTTY() {
246
+ const lines = this.renderWidgets();
247
+ if (lines.length === 0) {
248
+ this.prevBottomCount = 0;
249
+ return;
250
+ }
251
+ for (let i = 0; i < lines.length; i += 1) {
252
+ this.stream.write(lines[i]);
253
+ if (i < lines.length - 1) this.stream.write("\n");
254
+ }
255
+ this.prevBottomCount = lines.length;
256
+ }
257
+ drawBottomNonTTY(force) {
258
+ const now = Date.now();
259
+ if (!force && now - this.lastNonTTYRender < this.nonTTYInterval) return;
260
+ const lines = this.renderWidgets();
261
+ if (lines.length === 0) return;
262
+ for (const line of lines) this.stream.write(`${line}\n`);
263
+ this.lastNonTTYRender = now;
264
+ }
265
+ scheduleRender(force = false) {
266
+ if (this.disposed) return;
267
+ this.queuedForce = this.queuedForce || force;
268
+ if (this.scheduled) return;
269
+ this.scheduled = true;
270
+ queueMicrotask(() => {
271
+ this.scheduled = false;
272
+ const shouldForce = this.queuedForce;
273
+ this.queuedForce = false;
274
+ this.render(shouldForce);
275
+ });
276
+ }
277
+ };
278
+
279
+ //#endregion
280
+ //#region src/chat/log.ts
281
+ const LOG_LEVEL_PREFIX = {
282
+ log: "LOG",
283
+ info: "INFO",
284
+ warn: "WARN",
285
+ error: "ERROR"
286
+ };
287
+ const LOG_LEVEL_COLOR = {
288
+ log: gray,
289
+ info: cyan,
290
+ warn: yellow,
291
+ error: red
292
+ };
293
+ function defaultLogFormatter(entry, options) {
294
+ if (options.isTTY) {
295
+ if (entry.level === "log") return entry.message;
296
+ const color = LOG_LEVEL_COLOR[entry.level];
297
+ return `${color(`[${LOG_LEVEL_PREFIX[entry.level]}]`)} ${entry.message}`;
298
+ } else return `[${LOG_LEVEL_PREFIX[entry.level]}] ${entry.message}`;
299
+ }
300
+
301
+ //#endregion
302
+ //#region src/chat/chat.ts
303
+ const DEFAULT_TICK_INTERVAL = 80;
304
+ const DEFAULT_NON_TTY_INTERVAL = 1e3;
305
+ function chat(options = {}) {
306
+ const stream = options.stream ?? process.stderr;
307
+ const isTTY = !!stream.isTTY;
308
+ const tickInterval = Math.max(1, options.tickInterval ?? DEFAULT_TICK_INTERVAL);
309
+ const nonTTYInterval = Math.max(0, options.nonTTYInterval ?? DEFAULT_NON_TTY_INTERVAL);
310
+ const renderer = options.renderer ?? new Renderer({
311
+ stream,
312
+ isTTY,
313
+ tickInterval,
314
+ nonTTYInterval
315
+ });
316
+ const writeLog = (level, args) => {
317
+ const entry = {
318
+ level,
319
+ message: format(...args),
320
+ createdAt: /* @__PURE__ */ new Date()
321
+ };
322
+ const opt = {
323
+ tag: options.log?.tag,
324
+ columns: stream.columns || 80,
325
+ isTTY: stream.isTTY || false
326
+ };
327
+ const line = options.log?.format ? options.log.format(entry, opt) : defaultLogFormatter(entry, opt);
328
+ renderer.writeAboveBottom(line);
329
+ };
330
+ return {
331
+ log(...args) {
332
+ writeLog("log", args);
333
+ },
334
+ info(...args) {
335
+ writeLog("info", args);
336
+ },
337
+ warn(...args) {
338
+ writeLog("warn", args);
339
+ },
340
+ error(...args) {
341
+ writeLog("error", args);
342
+ },
343
+ widget: (spec) => renderer.createWidget(spec),
344
+ spinner: (message, spinnerOptions) => renderer.createSpinnerWidget(message, spinnerOptions),
345
+ progress: (message, progressOptions) => renderer.createProgressWidget(message, progressOptions),
346
+ render: (force) => renderer.render(force),
347
+ clearBottom: () => renderer.clearBottom(),
348
+ dispose: () => renderer.dispose()
349
+ };
4
350
  }
5
351
 
6
352
  //#endregion
7
- export { progress };
353
+ export { chat };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breadc/tui",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "TUI for Breadc",
5
5
  "keywords": [
6
6
  "breadc",
@@ -34,7 +34,7 @@
34
34
  "dependencies": {
35
35
  "std-env": "^3.10.0",
36
36
  "string-width": "^8.1.1",
37
- "@breadc/color": "1.0.0-beta.4"
37
+ "@breadc/color": "1.0.0-beta.5"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsdown",