decorated-pi 0.7.3 → 0.8.1

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/ui/ask.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { Theme } from "@earendil-works/pi-coding-agent";
21
- import { Container, Spacer, Text, type TUI, type Component, getKeybindings } from "@earendil-works/pi-tui";
21
+ import { Container, Spacer, Text, matchesKey, Key, type TUI, type Component, getKeybindings } from "@earendil-works/pi-tui";
22
22
 
23
23
  export type AskQuestionType = "text" | "single" | "multi";
24
24
 
@@ -37,25 +37,30 @@ export interface AskAnswer {
37
37
 
38
38
  interface QuestionState {
39
39
  value: string | string[];
40
+ /** Cursor position within value for text type (0 = before first char). */
41
+ textCursor: number;
40
42
  cursor: number; // option cursor; opts.length maps to the "Other" row
41
43
  /** Typed text when the cursor sits on the "Other" row (single or multi). */
42
44
  customText: string;
45
+ /** Cursor position within customText (0 = before first char). */
46
+ customCursor: number;
43
47
  /** Multi only: whether "Other" is currently toggled into the selection. */
44
48
  customSelected: boolean;
45
49
  }
46
50
 
47
51
  function parseDefault(type: AskQuestionType, options: string[] | undefined, defaultValue: string | undefined): QuestionState {
48
52
  if (type === "text") {
49
- return { value: defaultValue ?? "", cursor: 0, customText: "", customSelected: false };
53
+ const v = defaultValue ?? "";
54
+ return { value: v, textCursor: v.length, cursor: 0, customText: "", customCursor: 0, customSelected: false };
50
55
  }
51
56
  const opts = options ?? [];
52
57
  if (type === "single") {
53
58
  const idx = defaultValue ? Math.max(0, opts.indexOf(defaultValue)) : 0;
54
- return { value: opts[idx] ?? "", cursor: idx, customText: "", customSelected: false };
59
+ return { value: opts[idx] ?? "", textCursor: 0, cursor: idx, customText: "", customCursor: 0, customSelected: false };
55
60
  }
56
61
  // multi: comma-separated default, or empty
57
62
  const selected = defaultValue ? defaultValue.split(",").map(s => s.trim()).filter(Boolean) : [];
58
- return { value: selected, cursor: 0, customText: "", customSelected: false };
63
+ return { value: selected, textCursor: 0, cursor: 0, customText: "", customCursor: 0, customSelected: false };
59
64
  }
60
65
 
61
66
  /** Returns true for single/multi choice questions, which always get an
@@ -307,13 +312,23 @@ export class AskComponent extends Container {
307
312
  const state = this.currentState();
308
313
 
309
314
  if (q.type === "text") {
315
+ const tc = state.textCursor;
316
+ const val = state.value as string;
310
317
  if (data === "\x7f" || data === "backspace") {
311
- state.value = (state.value as string).slice(0, -1);
318
+ if (tc > 0) {
319
+ state.value = val.slice(0, tc - 1) + val.slice(tc);
320
+ state.textCursor--;
321
+ }
322
+ } else if (matchesKey(data, Key.left)) {
323
+ if (tc > 0) state.textCursor--;
324
+ } else if (matchesKey(data, Key.right)) {
325
+ if (tc < val.length) state.textCursor++;
312
326
  } else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
313
327
  // Accept any printable character: ASCII, Latin-1, CJK, emoji
314
328
  // surrogate pairs. The earlier `>= " " && <= "~"` filter
315
329
  // rejected every code point above U+007F (i.e. all Chinese).
316
- state.value = (state.value as string) + data;
330
+ state.value = val.slice(0, tc) + data + val.slice(tc);
331
+ state.textCursor += data.length;
317
332
  }
318
333
  } else if (q.type === "single" && q.options) {
319
334
  const opts = q.options;
@@ -328,18 +343,26 @@ export class AskComponent extends Container {
328
343
  const next = (state.cursor + 1) % totalLen;
329
344
  state.cursor = next;
330
345
  state.value = next < opts.length ? opts[next] : "";
346
+ } else if (onCustomRow) {
347
+ if (data === "\x7f" || data === "backspace") {
348
+ if (state.customCursor > 0) {
349
+ state.customText = state.customText.slice(0, state.customCursor - 1) + state.customText.slice(state.customCursor);
350
+ state.customCursor--;
351
+ }
352
+ } else if (matchesKey(data, Key.left)) {
353
+ if (state.customCursor > 0) state.customCursor--;
354
+ } else if (matchesKey(data, Key.right)) {
355
+ if (state.customCursor < state.customText.length) state.customCursor++;
356
+ } else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
357
+ state.customText = state.customText.slice(0, state.customCursor) + data + state.customText.slice(state.customCursor);
358
+ state.customCursor += data.length;
359
+ }
331
360
  } else if (data >= "1" && data <= "9") {
332
361
  const idx = parseInt(data, 10) - 1;
333
362
  if (idx < totalLen) {
334
363
  state.cursor = idx;
335
364
  state.value = idx < opts.length ? opts[idx] : "";
336
365
  }
337
- } else if (onCustomRow) {
338
- if (data === "\x7f" || data === "backspace") {
339
- state.customText = state.customText.slice(0, -1);
340
- } else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
341
- state.customText += data;
342
- }
343
366
  }
344
367
  } else if (q.type === "multi" && q.options) {
345
368
  const opts = q.options;
@@ -360,6 +383,21 @@ export class AskComponent extends Container {
360
383
  else selected.add(opt);
361
384
  state.value = Array.from(selected);
362
385
  }
386
+ } else if (onCustomRow) {
387
+ if (data === "\x7f" || data === "backspace") {
388
+ if (state.customCursor > 0) {
389
+ state.customText = state.customText.slice(0, state.customCursor - 1) + state.customText.slice(state.customCursor);
390
+ state.customCursor--;
391
+ }
392
+ } else if (matchesKey(data, Key.left)) {
393
+ if (state.customCursor > 0) state.customCursor--;
394
+ } else if (matchesKey(data, Key.right)) {
395
+ if (state.customCursor < state.customText.length) state.customCursor++;
396
+ } else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
397
+ state.customSelected = true;
398
+ state.customText = state.customText.slice(0, state.customCursor) + data + state.customText.slice(state.customCursor);
399
+ state.customCursor += data.length;
400
+ }
363
401
  } else if (data >= "1" && data <= "9") {
364
402
  const idx = parseInt(data, 10) - 1;
365
403
  if (idx < totalLen) {
@@ -372,15 +410,6 @@ export class AskComponent extends Container {
372
410
  state.value = Array.from(selected);
373
411
  }
374
412
  }
375
- } else if (onCustomRow && data.length > 0 && data.charCodeAt(0) >= 0x20) {
376
- // Typing on the "Other" row auto-selects it and feeds customText.
377
- // Backspace never enters this branch (handled below).
378
- if (data !== "\x7f" && data !== "backspace") {
379
- state.customSelected = true;
380
- state.customText += data;
381
- } else {
382
- state.customText = state.customText.slice(0, -1);
383
- }
384
413
  }
385
414
  }
386
415
 
@@ -415,8 +444,11 @@ export class AskComponent extends Container {
415
444
 
416
445
  if (q.type === "text") {
417
446
  const value = state.value as string;
418
- const cursor = this.theme.fg("accent", "_");
419
- lines.push(value + cursor);
447
+ const tc = state.textCursor;
448
+ const before = value.slice(0, tc);
449
+ const after = value.slice(tc);
450
+ const cursor = this.theme.fg("accent", "|");
451
+ lines.push(`${before}${cursor}${after}`);
420
452
  } else if (q.type === "single" && q.options) {
421
453
  const opts = q.options;
422
454
  const totalLen = effectiveOptionCount(q);
@@ -432,12 +464,16 @@ export class AskComponent extends Container {
432
464
  let line: string;
433
465
  if (isCustomRow) {
434
466
  const text = state.customText;
435
- const cmark = atCursor ? "_" : "";
436
- line = ` ${icon} ${optLabel}: ${text}${cmark}`;
467
+ const cc = state.customCursor;
468
+ const before = text.slice(0, cc);
469
+ const after = text.slice(cc);
470
+ const cmark = atCursor ? this.theme.fg("accent", "|") : "";
471
+ const hl = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}: `) : ` ${icon} ${optLabel}: `;
472
+ line = `${hl}${before}${cmark}${after}`;
437
473
  } else {
438
- line = ` ${icon} ${optLabel}`;
474
+ line = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}`) : ` ${icon} ${optLabel}`;
439
475
  }
440
- lines.push(atCursor ? this.theme.fg("accent", line) : line);
476
+ lines.push(line);
441
477
  }
442
478
  } else if (q.type === "multi" && q.options) {
443
479
  const opts = q.options;
@@ -452,12 +488,16 @@ export class AskComponent extends Container {
452
488
  let line: string;
453
489
  if (isCustomRow) {
454
490
  const text = state.customText;
455
- const cmark = atCursor ? "_" : "";
456
- line = ` ${icon} ${optLabel}: ${text}${cmark}`;
491
+ const cc = state.customCursor;
492
+ const before = text.slice(0, cc);
493
+ const after = text.slice(cc);
494
+ const cmark = atCursor ? this.theme.fg("accent", "|") : "";
495
+ const hl = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}: `) : ` ${icon} ${optLabel}: `;
496
+ line = `${hl}${before}${cmark}${after}`;
457
497
  } else {
458
- line = ` ${icon} ${optLabel}`;
498
+ line = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}`) : ` ${icon} ${optLabel}`;
459
499
  }
460
- lines.push(atCursor ? this.theme.fg("accent", line) : line);
500
+ lines.push(line);
461
501
  }
462
502
  }
463
503