dsh-neotui 0.4.3 → 0.4.4
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/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/src/config.js +31 -0
- package/src/file-picker.js +5 -3
- package/src/keybindings.js +13 -10
- package/src/panels.js +419 -11
- package/src/side-question.mjs +75 -0
- package/src/term.js +21 -0
- package/src/views.js +1083 -87
- package/src/widgets.js +109 -1
package/src/widgets.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// widgets.js — Minimal widget layer: hit-testing, lists, scroll views, input,
|
|
2
2
|
// popups, context menus, status bar. Keyboard-first, with mouse hit-testing as an auxiliary path.
|
|
3
3
|
import { truncate, pad, strWidth, graphemes } from "./text.js";
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, isAbsolute } from "node:path";
|
|
4
7
|
import { T } from "./theme.js";
|
|
5
8
|
|
|
6
9
|
/** Circular cursor movement shared by every finite choice list. Text cursors
|
|
@@ -276,6 +279,12 @@ export class Input extends Widget {
|
|
|
276
279
|
this.cmdOpen = false; // the candidate bar is showing
|
|
277
280
|
this.cmdIdx = 0; // highlighted candidate
|
|
278
281
|
this.cmds = []; // filtered candidates
|
|
282
|
+
this.fileRoot = null; // completion base dir (session cwd)
|
|
283
|
+
this.fileCands = []; // file-path completion candidates
|
|
284
|
+
this.atCands = []; // @-mention candidates
|
|
285
|
+
this.atIdx = 0;
|
|
286
|
+
this.atLast = null;
|
|
287
|
+
this.fileIdx = 0;
|
|
279
288
|
this.onChange = opts.onChange ?? null;
|
|
280
289
|
this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
|
|
281
290
|
this.history = [];
|
|
@@ -339,6 +348,8 @@ export class Input extends Widget {
|
|
|
339
348
|
/** Rendered height: 1, or wrapped rows capped at maxLines when multi. */
|
|
340
349
|
height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#visualRows().length)) : 1; }
|
|
341
350
|
setValue(v, opts = {}) {
|
|
351
|
+
this.fileLast = null; // any value change resets file-completion cycling
|
|
352
|
+
this.atLast = null;
|
|
342
353
|
this.#touch();
|
|
343
354
|
this.value = String(v);
|
|
344
355
|
this.cursor = this.#cps().length;
|
|
@@ -366,6 +377,101 @@ export class Input extends Widget {
|
|
|
366
377
|
}
|
|
367
378
|
this.#edit(at, at, text);
|
|
368
379
|
}
|
|
380
|
+
/** @-mention completion: the token after "@" is completed against the
|
|
381
|
+
* session cwd (dir scans stay bounded); Tab cycles the candidates. */
|
|
382
|
+
#completeAtMention() {
|
|
383
|
+
const cps = this.#cps();
|
|
384
|
+
const before = cps.slice(0, this.cursor).join("");
|
|
385
|
+
const m = /(?<=^|\s)@([^\s]*)$/.exec(before);
|
|
386
|
+
if (!m) return false;
|
|
387
|
+
const token = m[1];
|
|
388
|
+
if (!token) return false;
|
|
389
|
+
const root = this.fileRoot ?? process.cwd();
|
|
390
|
+
const slash = token.lastIndexOf("/");
|
|
391
|
+
const dirPart = slash < 0 ? root : (token.startsWith("/") || token.startsWith(".") || token.startsWith("~") ? token.slice(0, slash) : join(root, token.slice(0, slash)));
|
|
392
|
+
const base = token.slice(slash + 1);
|
|
393
|
+
const dirResolved = token.startsWith("~") ? join(homedir(), dirPart.slice(1)) : dirPart;
|
|
394
|
+
let names = [];
|
|
395
|
+
try {
|
|
396
|
+
names = readdirSync(dirResolved, { withFileTypes: true })
|
|
397
|
+
.filter((e) => e.name.startsWith(base) && (base.startsWith(".") ? e.name.startsWith(".") : !e.name.startsWith(".")))
|
|
398
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
399
|
+
.slice(0, 12)
|
|
400
|
+
.map((e) => `${e.isDirectory() ? e.name + "/" : e.name}`);
|
|
401
|
+
} catch { names = []; }
|
|
402
|
+
if (!names.length) { this.atCands = []; return false; }
|
|
403
|
+
if (this.atLast === this.value && this.atCands.length > 1) this.atIdx = (this.atIdx + 1) % this.atCands.length;
|
|
404
|
+
else { this.atCands = names; this.atIdx = 0; }
|
|
405
|
+
const name = this.atCands[this.atIdx];
|
|
406
|
+
const dirDisplay = slash >= 0 ? token.slice(0, slash + 1) : "";
|
|
407
|
+
const repl = `@${dirDisplay}${name}`;
|
|
408
|
+
const head = before.slice(0, m.index) + "@"; // keep the space before the @
|
|
409
|
+
const tail = cps.slice(this.cursor).join("");
|
|
410
|
+
const next = head + dirDisplay + name + tail;
|
|
411
|
+
this.setValue(next);
|
|
412
|
+
this.cursor = graphemes(head + dirDisplay + name).length;
|
|
413
|
+
this.atLast = next;
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** File-path completion: Tab completes the token before the caret when it
|
|
418
|
+
* looks like a path (contains "/" or starts with "."/"~"). Repeated Tab
|
|
419
|
+
* cycles the candidates; directories complete with a trailing "/". */
|
|
420
|
+
#completeFile() {
|
|
421
|
+
const cps = this.#cps();
|
|
422
|
+
const before = cps.slice(0, this.cursor).join("");
|
|
423
|
+
const m = /([^\s\n]*)$/.exec(before);
|
|
424
|
+
const token = m ? m[1] : "";
|
|
425
|
+
if (!token || token.startsWith("/") || token.includes("\n")) return false;
|
|
426
|
+
const pathy = token.includes("/") || token.startsWith(".") || token.startsWith("~");
|
|
427
|
+
if (!pathy) return false;
|
|
428
|
+
const root = this.fileRoot ?? process.cwd();
|
|
429
|
+
let dirPart, base;
|
|
430
|
+
if (token.startsWith("~")) {
|
|
431
|
+
const tail = token.slice(1);
|
|
432
|
+
const cut = tail.indexOf("/");
|
|
433
|
+
dirPart = cut < 0 ? (base = "", homedir()) : (base = tail.slice(cut + 1), join(homedir(), tail.slice(0, cut)));
|
|
434
|
+
} else if (token.startsWith("/")) {
|
|
435
|
+
const cut = token.lastIndexOf("/");
|
|
436
|
+
dirPart = cut <= 0 ? "/" : token.slice(0, cut);
|
|
437
|
+
base = token.slice(cut + 1);
|
|
438
|
+
} else {
|
|
439
|
+
const cut = token.lastIndexOf("/");
|
|
440
|
+
dirPart = cut < 0 ? root : (cut === 0 ? "/" : token.slice(0, cut));
|
|
441
|
+
base = token.slice(cut + 1);
|
|
442
|
+
if (cut < 0) {} else if (!isAbsolute(dirPart)) dirPart = join(root, dirPart);
|
|
443
|
+
}
|
|
444
|
+
if (dirPart.startsWith("~")) dirPart = join(homedir(), dirPart.slice(1));
|
|
445
|
+
if (!isAbsolute(dirPart)) dirPart = join(root, dirPart);
|
|
446
|
+
let names = [];
|
|
447
|
+
try {
|
|
448
|
+
names = readdirSync(dirPart, { withFileTypes: true })
|
|
449
|
+
.filter((e) => e.name.startsWith(base) && (base.startsWith(".") ? e.name.startsWith(".") : !e.name.startsWith(".")))
|
|
450
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
451
|
+
.map((e) => e.isDirectory() ? e.name + "/" : e.name);
|
|
452
|
+
} catch { names = []; }
|
|
453
|
+
if (!names.length) { this.fileCands = []; this.fileLast = null; return false; }
|
|
454
|
+
if (this.fileLast === this.value && this.fileCands.length > 1) {
|
|
455
|
+
// unedited: cycle through the already-listed candidates
|
|
456
|
+
this.fileIdx = (this.fileIdx + 1) % this.fileCands.length;
|
|
457
|
+
} else {
|
|
458
|
+
this.fileCands = names;
|
|
459
|
+
this.fileIdx = 0;
|
|
460
|
+
}
|
|
461
|
+
const name = this.fileCands[this.fileIdx];
|
|
462
|
+
// keep the user's own spelling ("./" stays "./", "~/x" stays "~/x")
|
|
463
|
+
const slash = token.lastIndexOf("/");
|
|
464
|
+
const dirDisplay = slash >= 0 ? token.slice(0, slash + 1) : "";
|
|
465
|
+
const replace = `${dirDisplay}${name}`;
|
|
466
|
+
const head = before.slice(0, before.length - token.length);
|
|
467
|
+
const tail = cps.slice(this.cursor).join("");
|
|
468
|
+
const next = head + replace + tail;
|
|
469
|
+
this.setValue(next);
|
|
470
|
+
this.cursor = graphemes(head + replace).length;
|
|
471
|
+
this.fileLast = next; // built by completion: unchanged → cycle on Tab
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
369
475
|
/** The / command candidate bar opens while the value is a bare "/…" prefix. */
|
|
370
476
|
#updateCmds() {
|
|
371
477
|
const v = this.value;
|
|
@@ -386,6 +492,7 @@ export class Input extends Widget {
|
|
|
386
492
|
* replaces it, edits elsewhere just shift it. Always notifies onChange —
|
|
387
493
|
* the second-paste swap must reflow the layout just like typing. */
|
|
388
494
|
#edit(from, to, text = "") {
|
|
495
|
+
this.fileLast = null;
|
|
389
496
|
this.#pushUndo();
|
|
390
497
|
this.selStart = this.selEnd = null; // edits consume the selection
|
|
391
498
|
const cps = this.#cps();
|
|
@@ -725,7 +832,8 @@ export class Input extends Widget {
|
|
|
725
832
|
this.cmdOpen = false;
|
|
726
833
|
return true;
|
|
727
834
|
}
|
|
728
|
-
return
|
|
835
|
+
if (this.#completeAtMention()) return true;
|
|
836
|
+
return this.#completeFile();
|
|
729
837
|
case "char":
|
|
730
838
|
if (ev.ctrl) {
|
|
731
839
|
switch (ev.key) {
|