@hraness/dawg 0.0.0-stage → 0.3.0

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
package/tui/prompt.ts ADDED
@@ -0,0 +1,609 @@
1
+ /** A bounded, dependency-free multiline prompt model for the dawg TUI. */
2
+
3
+ import { graphemes, type Grapheme } from "./text.ts";
4
+
5
+ export type PromptMode = "steer" | "queue";
6
+
7
+ export interface PromptOptions {
8
+ width: number;
9
+ maxLines?: number;
10
+ maxChars?: number;
11
+ maxVisualRows?: number;
12
+ }
13
+
14
+ export interface PromptState {
15
+ /** Logical text, including newlines. */
16
+ text: string;
17
+ /** Cursor offset in Unicode code points. */
18
+ cursor: number;
19
+ mode: PromptMode;
20
+ /** Retained for natural vertical movement across wrapped rows. */
21
+ desiredColumn: number | null;
22
+ }
23
+
24
+ export interface PromptVisualLine {
25
+ text: string;
26
+ /** Code-point offsets into the logical text. */
27
+ start: number;
28
+ end: number;
29
+ logicalLine: number;
30
+ /** Display width in terminal cells. */
31
+ width: number;
32
+ /** True when the row ends at a soft wrap rather than a newline or EOF. */
33
+ soft: boolean;
34
+ }
35
+
36
+ export type PromptKey = string | { type: "text" | "paste"; text: string };
37
+
38
+ export type PromptActionKind =
39
+ "edit" | "submit" | "queue" | "cancel" | "exit" | "noop";
40
+
41
+ export interface PromptAction {
42
+ kind: PromptActionKind;
43
+ state: PromptState;
44
+ /** Submitted text for submit and queue actions. */
45
+ value?: string;
46
+ }
47
+
48
+ const DEFAULTS = { width: 80, maxLines: 32, maxChars: 8_000, maxVisualRows: 8 };
49
+
50
+ function optionsWithDefaults(options: PromptOptions): Required<PromptOptions> {
51
+ return {
52
+ width: Math.max(1, Math.floor(options.width || DEFAULTS.width)),
53
+ maxLines: Math.max(1, Math.floor(options.maxLines ?? DEFAULTS.maxLines)),
54
+ maxChars: Math.max(1, Math.floor(options.maxChars ?? DEFAULTS.maxChars)),
55
+ maxVisualRows: Math.max(
56
+ 1,
57
+ Math.floor(options.maxVisualRows ?? DEFAULTS.maxVisualRows),
58
+ ),
59
+ };
60
+ }
61
+
62
+ function points(value: string): string[] {
63
+ return Array.from(value);
64
+ }
65
+
66
+ function limitText(value: string, options: Required<PromptOptions>): string {
67
+ const chars = points(value).slice(0, options.maxChars);
68
+ const lines = chars.join("").split("\n");
69
+ if (lines.length > options.maxLines)
70
+ return lines.slice(0, options.maxLines).join("\n");
71
+ return lines.join("\n");
72
+ }
73
+
74
+ export function createPromptState(
75
+ text = "",
76
+ mode: PromptMode = "steer",
77
+ options: PromptOptions = DEFAULTS,
78
+ ): PromptState {
79
+ const bounded = limitText(
80
+ text.replaceAll("\r\n", "\n").replaceAll("\r", "\n"),
81
+ optionsWithDefaults(options),
82
+ );
83
+ return {
84
+ text: bounded,
85
+ cursor: points(bounded).length,
86
+ mode,
87
+ desiredColumn: null,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Return visual rows with offsets into the logical code-point sequence.
93
+ * Wrapping is grapheme- and width-aware: a wide CJK character or an emoji ZWJ
94
+ * sequence is never split, and rows prefer to break after whitespace.
95
+ */
96
+ export function wrapPrompt(
97
+ stateOrText: PromptState | string,
98
+ width: number,
99
+ ): PromptVisualLine[] {
100
+ const text = typeof stateOrText === "string" ? stateOrText : stateOrText.text;
101
+ const maxWidth = Math.max(1, Math.floor(width));
102
+ const result: PromptVisualLine[] = [];
103
+ let logicalStart = 0;
104
+ let logicalLine = 0;
105
+ const push = (row: Grapheme[], start: number, soft: boolean): void => {
106
+ const last = row[row.length - 1];
107
+ result.push({
108
+ text: row.map((cluster) => cluster.text).join(""),
109
+ start,
110
+ end: last ? last.start + last.length : start,
111
+ logicalLine,
112
+ width: row.reduce((sum, cluster) => sum + cluster.width, 0),
113
+ soft,
114
+ });
115
+ };
116
+ for (const segment of text.split("\n")) {
117
+ const clusters = graphemes(segment).map((cluster) => ({
118
+ ...cluster,
119
+ start: cluster.start + logicalStart,
120
+ width: Math.min(cluster.width, maxWidth),
121
+ }));
122
+ let row: Grapheme[] = [];
123
+ let used = 0;
124
+ let rowStart = logicalStart;
125
+ for (const cluster of clusters) {
126
+ if (used + cluster.width > maxWidth && row.length > 0) {
127
+ // Prefer a word boundary when one exists past the row's first cell.
128
+ let breakAt = row.length;
129
+ for (let index = row.length - 1; index > 0; index -= 1) {
130
+ if (/\s/.test(row[index]!.text)) {
131
+ breakAt = index + 1;
132
+ break;
133
+ }
134
+ }
135
+ if (/\s/.test(cluster.text)) breakAt = row.length;
136
+ const carry = row.slice(breakAt);
137
+ push(row.slice(0, breakAt), rowStart, true);
138
+ row = carry;
139
+ used = carry.reduce((sum, item) => sum + item.width, 0);
140
+ rowStart = carry[0]?.start ?? cluster.start;
141
+ }
142
+ row.push(cluster);
143
+ used += cluster.width;
144
+ }
145
+ push(row, rowStart, false);
146
+ logicalStart += Array.from(segment).length + 1;
147
+ logicalLine += 1;
148
+ }
149
+ return result;
150
+ }
151
+
152
+ function boundaryBefore(text: string, cursor: number): number {
153
+ let previous = 0;
154
+ for (const cluster of graphemes(text)) {
155
+ if (cluster.start >= cursor) break;
156
+ previous = cluster.start;
157
+ }
158
+ return previous;
159
+ }
160
+
161
+ function boundaryAfter(text: string, cursor: number): number {
162
+ for (const cluster of graphemes(text)) {
163
+ const end = cluster.start + cluster.length;
164
+ if (end > cursor) return end;
165
+ }
166
+ return points(text).length;
167
+ }
168
+
169
+ /** Display column of a code-point offset within a visual row. */
170
+ function columnOf(line: PromptVisualLine, cursor: number): number {
171
+ let column = 0;
172
+ for (const cluster of graphemes(line.text)) {
173
+ if (line.start + cluster.start >= cursor) break;
174
+ column += cluster.width;
175
+ }
176
+ return column;
177
+ }
178
+
179
+ /** Code-point offset nearest to (not past) a display column in a row. */
180
+ function offsetAt(line: PromptVisualLine, column: number): number {
181
+ let used = 0;
182
+ for (const cluster of graphemes(line.text)) {
183
+ if (used + cluster.width > column) return line.start + cluster.start;
184
+ used += cluster.width;
185
+ }
186
+ // A soft-wrapped row's end belongs to the next row; stay before its last
187
+ // trailing space so vertical movement never jumps rows unexpectedly.
188
+ return line.soft ? Math.max(line.start, line.end - 1) : line.end;
189
+ }
190
+
191
+ function normalized(
192
+ state: PromptState,
193
+ options: Required<PromptOptions>,
194
+ ): PromptState {
195
+ const text = limitText(state.text, options);
196
+ const cursor = Math.max(
197
+ 0,
198
+ Math.min(points(text).length, Math.floor(state.cursor)),
199
+ );
200
+ return { text, cursor, mode: state.mode, desiredColumn: state.desiredColumn };
201
+ }
202
+
203
+ function visualCursor(
204
+ state: PromptState,
205
+ options: Required<PromptOptions>,
206
+ ): { row: number; column: number; lines: PromptVisualLine[] } {
207
+ const lines = wrapPrompt(state, options.width);
208
+ let row = lines.length - 1;
209
+ for (let index = 0; index < lines.length; index += 1) {
210
+ const line = lines[index]!;
211
+ const inside = line.soft
212
+ ? state.cursor >= line.start && state.cursor < line.end
213
+ : state.cursor >= line.start && state.cursor <= line.end;
214
+ if (inside) {
215
+ row = index;
216
+ break;
217
+ }
218
+ }
219
+ const line = lines[row]!;
220
+ return { row, column: columnOf(line, state.cursor), lines };
221
+ }
222
+
223
+ function stateWithCursor(
224
+ state: PromptState,
225
+ cursor: number,
226
+ desiredColumn: number | null = null,
227
+ ): PromptState {
228
+ return { text: state.text, cursor, mode: state.mode, desiredColumn };
229
+ }
230
+
231
+ function textAction(
232
+ state: PromptState,
233
+ key: string,
234
+ options: Required<PromptOptions>,
235
+ ): PromptAction {
236
+ const chars = points(state.text);
237
+ const before = chars.slice(0, state.cursor);
238
+ const after = chars.slice(state.cursor);
239
+ const inserted = key.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
240
+ const combined = limitText(
241
+ [...before, ...points(inserted), ...after].join(""),
242
+ options,
243
+ );
244
+ const cursor = Math.min(
245
+ before.length + points(inserted).length,
246
+ points(combined).length,
247
+ );
248
+ const changed = combined !== state.text || cursor !== state.cursor;
249
+ return {
250
+ kind: changed ? "edit" : "noop",
251
+ state: stateWithCursor({ ...state, text: combined }, cursor),
252
+ };
253
+ }
254
+
255
+ function moveWord(state: PromptState, direction: -1 | 1): PromptState {
256
+ const chars = points(state.text);
257
+ let cursor = state.cursor;
258
+ if (direction < 0) {
259
+ while (cursor > 0 && /\s/.test(chars[cursor - 1]!)) cursor -= 1;
260
+ while (cursor > 0 && !/\s/.test(chars[cursor - 1]!)) cursor -= 1;
261
+ } else {
262
+ while (cursor < chars.length && /\s/.test(chars[cursor]!)) cursor += 1;
263
+ while (cursor < chars.length && !/\s/.test(chars[cursor]!)) cursor += 1;
264
+ }
265
+ return stateWithCursor(state, cursor);
266
+ }
267
+
268
+ function editAtCursor(
269
+ state: PromptState,
270
+ operation: (
271
+ chars: string[],
272
+ cursor: number,
273
+ ) => { chars: string[]; cursor: number },
274
+ ): PromptState {
275
+ const result = operation(points(state.text), state.cursor);
276
+ return stateWithCursor(
277
+ { ...state, text: result.chars.join("") },
278
+ result.cursor,
279
+ );
280
+ }
281
+
282
+ function submit(state: PromptState, kind: "submit" | "queue"): PromptAction {
283
+ const value = state.text;
284
+ if (value.trim().length === 0) return { kind: "noop", state };
285
+ return { kind, state: createPromptState("", state.mode), value };
286
+ }
287
+
288
+ /** Apply one terminal key or a text/paste event. */
289
+ export function applyPromptKey(
290
+ stateInput: PromptState,
291
+ keyInput: PromptKey,
292
+ optionsInput: PromptOptions,
293
+ ): PromptAction {
294
+ const options = optionsWithDefaults(optionsInput);
295
+ const state = normalized(stateInput, options);
296
+ if (typeof keyInput === "object")
297
+ return textAction(state, keyInput.text, options);
298
+ if (
299
+ keyInput.length > 1 &&
300
+ !keyInput.includes("+") &&
301
+ ![
302
+ "ENTER",
303
+ "BACKSPACE",
304
+ "DELETE",
305
+ "LEFT",
306
+ "RIGHT",
307
+ "UP",
308
+ "DOWN",
309
+ "HOME",
310
+ "END",
311
+ "ESC",
312
+ "TAB",
313
+ ].includes(keyInput)
314
+ ) {
315
+ return textAction(state, keyInput, options);
316
+ }
317
+ switch (keyInput.toUpperCase()) {
318
+ case "ENTER":
319
+ // The mode pill decides what Enter does: STEER runs now, QUEUE waits.
320
+ return submit(state, state.mode === "queue" ? "queue" : "submit");
321
+ case "ALT+ENTER":
322
+ return submit(state, "queue");
323
+ case "SHIFT+ENTER":
324
+ case "CTRL+J":
325
+ case "NEWLINE":
326
+ return textAction(state, "\n", options);
327
+ case "ESC":
328
+ return { kind: "cancel", state: createPromptState("", state.mode) };
329
+ case "CTRL+C":
330
+ return { kind: "exit", state };
331
+ case "CTRL+Q":
332
+ return {
333
+ kind: "edit",
334
+ state: { ...state, mode: state.mode === "steer" ? "queue" : "steer" },
335
+ };
336
+ case "LEFT":
337
+ return {
338
+ kind: "edit",
339
+ state: stateWithCursor(state, boundaryBefore(state.text, state.cursor)),
340
+ };
341
+ case "RIGHT":
342
+ return {
343
+ kind: "edit",
344
+ state: stateWithCursor(state, boundaryAfter(state.text, state.cursor)),
345
+ };
346
+ case "ALT+LEFT":
347
+ case "CTRL+LEFT":
348
+ return { kind: "edit", state: moveWord(state, -1) };
349
+ case "ALT+RIGHT":
350
+ case "CTRL+RIGHT":
351
+ return { kind: "edit", state: moveWord(state, 1) };
352
+ case "HOME": {
353
+ const { lines, row } = visualCursor(state, options);
354
+ return {
355
+ kind: "edit",
356
+ state: stateWithCursor(state, lines[row]!.start, 0),
357
+ };
358
+ }
359
+ case "END": {
360
+ const { lines, row } = visualCursor(state, options);
361
+ return {
362
+ kind: "edit",
363
+ state: stateWithCursor(
364
+ state,
365
+ offsetAt(lines[row]!, Number.POSITIVE_INFINITY),
366
+ lines[row]!.width,
367
+ ),
368
+ };
369
+ }
370
+ case "CTRL+HOME":
371
+ return { kind: "edit", state: stateWithCursor(state, 0, 0) };
372
+ case "CTRL+END":
373
+ return {
374
+ kind: "edit",
375
+ state: stateWithCursor(state, points(state.text).length, null),
376
+ };
377
+ case "UP": {
378
+ const current = visualCursor(state, options);
379
+ const targetRow = Math.max(0, current.row - 1);
380
+ const desired = state.desiredColumn ?? current.column;
381
+ const target = current.lines[targetRow]!;
382
+ return {
383
+ kind: "edit",
384
+ state: stateWithCursor(state, offsetAt(target, desired), desired),
385
+ };
386
+ }
387
+ case "DOWN": {
388
+ const current = visualCursor(state, options);
389
+ const targetRow = Math.min(current.lines.length - 1, current.row + 1);
390
+ const desired = state.desiredColumn ?? current.column;
391
+ const target = current.lines[targetRow]!;
392
+ return {
393
+ kind: "edit",
394
+ state: stateWithCursor(state, offsetAt(target, desired), desired),
395
+ };
396
+ }
397
+ case "BACKSPACE": {
398
+ if (state.cursor === 0) return { kind: "noop", state };
399
+ return {
400
+ kind: "edit",
401
+ state: editAtCursor(state, (chars, cursor) => {
402
+ const from = boundaryBefore(state.text, cursor);
403
+ return {
404
+ chars: [...chars.slice(0, from), ...chars.slice(cursor)],
405
+ cursor: from,
406
+ };
407
+ }),
408
+ };
409
+ }
410
+ case "DELETE": {
411
+ const chars = points(state.text);
412
+ if (state.cursor >= chars.length) return { kind: "noop", state };
413
+ return {
414
+ kind: "edit",
415
+ state: editAtCursor(state, (all, cursor) => ({
416
+ chars: [
417
+ ...all.slice(0, cursor),
418
+ ...all.slice(boundaryAfter(state.text, cursor)),
419
+ ],
420
+ cursor,
421
+ })),
422
+ };
423
+ }
424
+ case "CTRL+A":
425
+ return { kind: "edit", state: stateWithCursor(state, 0, 0) };
426
+ case "CTRL+E":
427
+ return {
428
+ kind: "edit",
429
+ state: stateWithCursor(state, points(state.text).length, null),
430
+ };
431
+ case "CTRL+U":
432
+ return {
433
+ kind: "edit",
434
+ state: stateWithCursor(
435
+ { ...state, text: points(state.text).slice(state.cursor).join("") },
436
+ 0,
437
+ 0,
438
+ ),
439
+ };
440
+ case "CTRL+K":
441
+ return {
442
+ kind: "edit",
443
+ state: stateWithCursor(
444
+ {
445
+ ...state,
446
+ text: points(state.text).slice(0, state.cursor).join(""),
447
+ },
448
+ state.cursor,
449
+ null,
450
+ ),
451
+ };
452
+ case "CTRL+W": {
453
+ const start = moveWord(state, -1).cursor;
454
+ return {
455
+ kind: "edit",
456
+ state: stateWithCursor(
457
+ {
458
+ ...state,
459
+ text: `${points(state.text).slice(0, start).join("")}${points(state.text).slice(state.cursor).join("")}`,
460
+ },
461
+ start,
462
+ ),
463
+ };
464
+ }
465
+ default:
466
+ return textAction(state, keyInput, options);
467
+ }
468
+ }
469
+
470
+ export interface PromptRenderOptions extends PromptOptions {
471
+ prefix?: string;
472
+ cursorGlyph?: string;
473
+ }
474
+
475
+ export interface PromptLayout {
476
+ /** Visible rows after internal scrolling. */
477
+ rows: PromptVisualLine[];
478
+ /** Index of the first visible row within all wrapped rows. */
479
+ first: number;
480
+ /** Total wrapped rows before scrolling. */
481
+ total: number;
482
+ /** Cursor row relative to `rows`, and its display column. */
483
+ cursorRow: number;
484
+ cursorColumn: number;
485
+ }
486
+
487
+ /** Wrap and scroll the prompt so the cursor row is always visible. */
488
+ export function layoutPrompt(
489
+ stateInput: PromptState,
490
+ optionsInput: PromptOptions,
491
+ ): PromptLayout {
492
+ const options = optionsWithDefaults(optionsInput);
493
+ const state = normalized(stateInput, options);
494
+ const cursor = visualCursor(state, options);
495
+ const lines = cursor.lines;
496
+ const first = Math.min(
497
+ Math.max(0, cursor.row - options.maxVisualRows + 1),
498
+ Math.max(0, lines.length - options.maxVisualRows),
499
+ );
500
+ return {
501
+ rows: lines.slice(first, first + options.maxVisualRows),
502
+ first,
503
+ total: lines.length,
504
+ cursorRow: cursor.row - first,
505
+ cursorColumn: cursor.column,
506
+ };
507
+ }
508
+
509
+ /** Render wrapped rows, keeping the cursor visible and scrolling only the editor. */
510
+ export function renderPrompt(
511
+ stateInput: PromptState,
512
+ optionsInput: PromptRenderOptions,
513
+ ): string[] {
514
+ const prefix = optionsInput.prefix ?? "> ";
515
+ const cursorGlyph = optionsInput.cursorGlyph ?? "▌";
516
+ const state = normalized(stateInput, optionsWithDefaults(optionsInput));
517
+ const layout = layoutPrompt(state, optionsInput);
518
+ return layout.rows.map((line, index) => {
519
+ const lineChars = points(line.text);
520
+ const split = Math.max(0, state.cursor - line.start);
521
+ const text =
522
+ index === layout.cursorRow
523
+ ? `${lineChars.slice(0, split).join("")}${cursorGlyph}${lineChars.slice(split).join("")}`
524
+ : line.text;
525
+ return `${index === 0 ? prefix : " "}${text}`;
526
+ });
527
+ }
528
+
529
+ /** Mutable convenience wrapper for a TUI event loop. */
530
+ export class PromptModel {
531
+ private state: PromptState;
532
+ private options: Required<PromptOptions>;
533
+ private history: string[] = [];
534
+ private historyIndex = -1;
535
+ private draftBeforeHistory = "";
536
+
537
+ public constructor(options: PromptOptions, initial = "") {
538
+ this.options = optionsWithDefaults(options);
539
+ this.state = createPromptState(initial, "steer", this.options);
540
+ }
541
+
542
+ public get value(): string {
543
+ return this.state.text;
544
+ }
545
+ public get snapshot(): PromptState {
546
+ return { ...this.state };
547
+ }
548
+ public get width(): number {
549
+ return this.options.width;
550
+ }
551
+ public layout(maxVisualRows = this.options.maxVisualRows): PromptLayout {
552
+ return layoutPrompt(this.state, { ...this.options, maxVisualRows });
553
+ }
554
+ /** Number of wrapped rows the current draft needs at the current width. */
555
+ public get wrappedRows(): number {
556
+ return wrapPrompt(this.state, this.options.width).length;
557
+ }
558
+ public setWidth(width: number): void {
559
+ this.options = optionsWithDefaults({ ...this.options, width });
560
+ }
561
+ public handle(key: PromptKey): PromptAction {
562
+ if (typeof key === "string" && (key === "UP" || key === "DOWN")) {
563
+ const atBoundary = this.state.cursor === points(this.state.text).length;
564
+ if (atBoundary && (this.history.length > 0 || this.historyIndex >= 0)) {
565
+ if (key === "UP") {
566
+ if (this.historyIndex < 0) this.draftBeforeHistory = this.state.text;
567
+ this.historyIndex = Math.min(
568
+ this.history.length - 1,
569
+ this.historyIndex + 1,
570
+ );
571
+ } else if (this.historyIndex >= 0) {
572
+ this.historyIndex -= 1;
573
+ }
574
+ const recalled =
575
+ this.historyIndex < 0
576
+ ? this.draftBeforeHistory
577
+ : (this.history[this.history.length - 1 - this.historyIndex] ?? "");
578
+ this.state = createPromptState(recalled, this.state.mode, this.options);
579
+ return { kind: "edit", state: this.state };
580
+ }
581
+ }
582
+ const action = applyPromptKey(this.state, key, this.options);
583
+ this.state = action.state;
584
+ if (action.kind === "submit" || action.kind === "queue") {
585
+ const value = action.value?.trim();
586
+ if (value) {
587
+ this.history = [
588
+ ...this.history.filter((entry) => entry !== value),
589
+ value,
590
+ ].slice(-100);
591
+ }
592
+ this.historyIndex = -1;
593
+ this.draftBeforeHistory = "";
594
+ }
595
+ return action;
596
+ }
597
+ public render(prefix?: string): string[] {
598
+ const options: PromptRenderOptions =
599
+ prefix === undefined ? { ...this.options } : { ...this.options, prefix };
600
+ return renderPrompt(this.state, options);
601
+ }
602
+ }
603
+
604
+ export const handlePromptKey = applyPromptKey;
605
+ export const createPromptEditor = (
606
+ options: PromptOptions,
607
+ initial = "",
608
+ ): PromptModel => new PromptModel(options, initial);
609
+ export const PromptEditor = PromptModel;