dsh-neotui 0.0.2 → 0.0.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/package.json +1 -1
- package/src/panels.js +137 -12
- package/src/term.js +2 -1
- package/src/views.js +106 -13
- package/src/widgets.js +137 -64
package/package.json
CHANGED
package/src/panels.js
CHANGED
|
@@ -272,7 +272,7 @@ export class WorkspacePanel extends Widget {
|
|
|
272
272
|
this.workspaces = items;
|
|
273
273
|
const tree = [];
|
|
274
274
|
for (const ws of items) {
|
|
275
|
-
tree.push({ depth: 0, name: `▣ ${ws.title}`, path: ws.path, isDir: true, open: false, ws: true });
|
|
275
|
+
tree.push({ depth: 0, name: `▣ ${ws.title}`, title: ws.title, path: ws.path, isDir: true, open: false, ws: true, workspaceId: ws.workspaceId });
|
|
276
276
|
}
|
|
277
277
|
this.tree = tree;
|
|
278
278
|
this.rebuildTree();
|
|
@@ -319,6 +319,25 @@ export class WorkspacePanel extends Widget {
|
|
|
319
319
|
} catch { node.children = []; }
|
|
320
320
|
}
|
|
321
321
|
onMouse(ev) {
|
|
322
|
+
if (ev.kind === "press" && ev.button === 2) {
|
|
323
|
+
// Right-click anywhere in the panel: workspace actions (add is the
|
|
324
|
+
// primary one; tree rows also offer move/rename).
|
|
325
|
+
const idx = this.treeScroll.scrollY + (ev.y - this.treeScroll.y);
|
|
326
|
+
const node = this.treeLinesNode(idx);
|
|
327
|
+
if (node?.ws) {
|
|
328
|
+
this.app.openMenu([
|
|
329
|
+
{ label: "添加工作区…", action: () => this.app.addWorkspace() },
|
|
330
|
+
{ label: "重命名工作区", action: () => this.app.renameWorkspace(node) },
|
|
331
|
+
{ label: "上移工作区", action: () => this.app.moveWorkspace(node, -1) },
|
|
332
|
+
{ label: "下移工作区", action: () => this.app.moveWorkspace(node, 1) },
|
|
333
|
+
], ev);
|
|
334
|
+
} else {
|
|
335
|
+
this.app.openMenu([
|
|
336
|
+
{ label: "添加工作区…", action: () => this.app.addWorkspace() },
|
|
337
|
+
], ev);
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
322
341
|
if (ev.x >= this.x + 1 && ev.x < this.x + Math.floor(this.w / 2)) {
|
|
323
342
|
const idx = this.treeScroll.scrollY + (ev.y - this.treeScroll.y);
|
|
324
343
|
const node = this.treeLinesNode(idx);
|
|
@@ -378,11 +397,11 @@ export class WorkspacePanel extends Widget {
|
|
|
378
397
|
}
|
|
379
398
|
}
|
|
380
399
|
render(screen) {
|
|
381
|
-
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
400
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
382
401
|
const mid = this.x + Math.floor(this.w / 2);
|
|
383
|
-
screen.put(mid, this.y, "┬", { fg: T.BORDER });
|
|
402
|
+
screen.put(mid, this.y, "┬", { fg: T.BORDER, bg: T.BG2 });
|
|
384
403
|
screen.vline(mid, this.y + 1, this.y + this.h - 1);
|
|
385
|
-
screen.text(this.x + 1, this.y, ` 工作区 (${this.workspaces.length}) — 点击目录展开,/
|
|
404
|
+
screen.text(this.x + 1, this.y, ` 工作区 (${this.workspaces.length}) — 点击目录展开,/ 搜索文件,右键添加工作区`, { fg: K.DIM, bg: T.BG2 });
|
|
386
405
|
if (this.query) {
|
|
387
406
|
const results = [];
|
|
388
407
|
const walk = (nodes) => {
|
|
@@ -393,10 +412,16 @@ export class WorkspacePanel extends Widget {
|
|
|
393
412
|
};
|
|
394
413
|
walk(this.tree);
|
|
395
414
|
this.searchResults = results;
|
|
396
|
-
|
|
397
|
-
|
|
415
|
+
const visH = Math.max(1, this.h - 2);
|
|
416
|
+
if (this.searchSel < (this.searchScroll ?? 0)) this.searchScroll = this.searchSel;
|
|
417
|
+
else if (this.searchSel >= (this.searchScroll ?? 0) + visH) this.searchScroll = this.searchSel - visH + 1;
|
|
418
|
+
this.searchScroll = Math.max(0, Math.min(Math.max(0, results.length - visH), this.searchScroll ?? 0));
|
|
419
|
+
for (let i = 0; i < visH; i++) {
|
|
420
|
+
const idx = this.searchScroll + i;
|
|
421
|
+
if (idx >= results.length) break;
|
|
422
|
+
const sel = idx === this.searchSel;
|
|
398
423
|
screen.fillRect(this.x + 1, this.y + 2 + i, mid - 2, this.y + 2 + i, " ", { bg: sel ? K.MENUSEL : -1 });
|
|
399
|
-
screen.text(this.x + 2, this.y + 2 + i, truncate("⚲ " + basename(results[
|
|
424
|
+
screen.text(this.x + 2, this.y + 2 + i, truncate("⚲ " + basename(results[idx]), mid - 6), { fg: sel ? K.BOLD : K.TXT, bg: sel ? K.MENUSEL : -1 });
|
|
400
425
|
}
|
|
401
426
|
screen.text(this.x + 1, this.y + this.h - 1, ` 匹配 ${results.length} 个文件 · Esc 退出搜索`, { fg: K.FAINT });
|
|
402
427
|
return;
|
|
@@ -421,6 +446,99 @@ export class WorkspacePanel extends Widget {
|
|
|
421
446
|
}
|
|
422
447
|
}
|
|
423
448
|
|
|
449
|
+
// ---- DirPicker: yazi-style folder selection buffer ----
|
|
450
|
+
|
|
451
|
+
export class DirPicker extends Widget {
|
|
452
|
+
constructor(app, { startPath, onPick, onCancel }) {
|
|
453
|
+
const w = Math.min(60, app.screen.w - 4), h = Math.min(20, app.screen.h - 4);
|
|
454
|
+
super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
|
|
455
|
+
this.app = app;
|
|
456
|
+
this.path = startPath ?? process.cwd();
|
|
457
|
+
this.onPick = onPick;
|
|
458
|
+
this.onCancel = onCancel;
|
|
459
|
+
this.entries = [];
|
|
460
|
+
this.sel = 0;
|
|
461
|
+
this.scroll = 0;
|
|
462
|
+
this.load();
|
|
463
|
+
}
|
|
464
|
+
load() {
|
|
465
|
+
try {
|
|
466
|
+
this.entries = readdirSync(this.path, { withFileTypes: true })
|
|
467
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
468
|
+
.map((d) => d.name)
|
|
469
|
+
.sort((a, b) => a.localeCompare(b));
|
|
470
|
+
} catch { this.entries = []; }
|
|
471
|
+
this.sel = 0;
|
|
472
|
+
this.scroll = 0;
|
|
473
|
+
}
|
|
474
|
+
#items() { return ["✓ 选择此目录", "..", ...this.entries]; }
|
|
475
|
+
render(screen) {
|
|
476
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
477
|
+
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, "选择文件夹");
|
|
478
|
+
screen.text(this.x + 2, this.y + 1, truncate("📁 " + this.path, this.w - 4), { fg: K.TXT, bg: T.BG2 });
|
|
479
|
+
const items = this.#items();
|
|
480
|
+
const lh = Math.max(1, this.h - 3);
|
|
481
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
482
|
+
else if (this.sel >= this.scroll + lh) this.scroll = this.sel - lh + 1;
|
|
483
|
+
this.scroll = Math.max(0, Math.min(Math.max(0, items.length - lh), this.scroll));
|
|
484
|
+
for (let i = 0; i < lh; i++) {
|
|
485
|
+
const idx = this.scroll + i;
|
|
486
|
+
const it = items[idx];
|
|
487
|
+
const y = this.y + 2 + i;
|
|
488
|
+
if (it === undefined) { screen.hline(this.x + 1, this.x + this.w - 2, y, " ", { bg: T.BG2 }); continue; }
|
|
489
|
+
const sel = idx === this.sel;
|
|
490
|
+
const label = it === "✓ 选择此目录" ? it : it === ".." ? ".. (上级目录)" : "▸ " + it + "/";
|
|
491
|
+
const fg = it === "✓ 选择此目录" ? K.OK : it === ".." ? K.DIM : K.TXT;
|
|
492
|
+
screen.fillRect(this.x + 1, y, this.x + this.w - 2, y, " ", { bg: sel ? T.MENUSEL : T.BG2 });
|
|
493
|
+
screen.text(this.x + 2, y, truncate(label, this.w - 4), { fg: sel ? 0xffffff : fg, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 });
|
|
494
|
+
}
|
|
495
|
+
screen.text(this.x + 2, this.y + this.h - 1, "↑↓/jk 移动 · Enter 进入/选择 · h/Backspace 上级 · Esc 取消", { fg: K.FAINT, bg: T.BG2 });
|
|
496
|
+
}
|
|
497
|
+
onKey(ev) {
|
|
498
|
+
if (ev.type !== "key") return false;
|
|
499
|
+
const items = this.#items();
|
|
500
|
+
switch (ev.name) {
|
|
501
|
+
case "up": this.sel = Math.max(0, this.sel - 1); return true;
|
|
502
|
+
case "down": this.sel = Math.min(items.length - 1, this.sel + 1); return true;
|
|
503
|
+
case "enter": {
|
|
504
|
+
const it = items[this.sel];
|
|
505
|
+
if (it === "✓ 选择此目录") { this.onPick?.(this.path); return true; }
|
|
506
|
+
if (it === "..") { this.path = dirname(this.path); this.load(); return true; }
|
|
507
|
+
this.path = join(this.path, it); this.load(); return true;
|
|
508
|
+
}
|
|
509
|
+
case "backspace": this.path = dirname(this.path); this.load(); return true;
|
|
510
|
+
case "char":
|
|
511
|
+
if (ev.key === "j" && !ev.ctrl) { this.sel = Math.min(items.length - 1, this.sel + 1); return true; }
|
|
512
|
+
if (ev.key === "k" && !ev.ctrl) { this.sel = Math.max(0, this.sel - 1); return true; }
|
|
513
|
+
if (ev.key === "h" && !ev.ctrl) { this.path = dirname(this.path); this.load(); return true; }
|
|
514
|
+
if (ev.key === "l" && !ev.ctrl) {
|
|
515
|
+
const it = items[this.sel];
|
|
516
|
+
if (it && it !== "✓ 选择此目录" && it !== "..") { this.path = join(this.path, it); this.load(); }
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
case "escape": this.onCancel?.(); return true;
|
|
521
|
+
}
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
onMouse(ev) {
|
|
525
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
526
|
+
const idx = this.scroll + (ev.y - this.y - 2);
|
|
527
|
+
const items = this.#items();
|
|
528
|
+
if (idx >= 0 && idx < items.length) {
|
|
529
|
+
const it = items[idx];
|
|
530
|
+
if (it === "✓ 选择此目录") { this.onPick?.(this.path); return true; }
|
|
531
|
+
if (it === "..") { this.path = dirname(this.path); this.load(); return true; }
|
|
532
|
+
this.path = join(this.path, it); this.load(); return true;
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); return true; }
|
|
537
|
+
if (ev.kind === "wheel-down") { this.sel = Math.min(this.#items().length - 1, this.sel + 1); return true; }
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
424
542
|
// ---- Trajectory view ----
|
|
425
543
|
|
|
426
544
|
export class TrajectoryPanel extends Widget {
|
|
@@ -781,6 +899,7 @@ export class ControlPanel extends Widget {
|
|
|
781
899
|
this.subPages = ["常规", "插件"];
|
|
782
900
|
this.subPage = 0;
|
|
783
901
|
this.sel = 0;
|
|
902
|
+
this.scroll = 0;
|
|
784
903
|
this.commands = DEFAULT_COMMANDS;
|
|
785
904
|
this.plugins = null;
|
|
786
905
|
this.pluginError = null;
|
|
@@ -884,9 +1003,15 @@ export class ControlPanel extends Widget {
|
|
|
884
1003
|
}
|
|
885
1004
|
const items = this.items();
|
|
886
1005
|
if (this.sel >= items.length) this.sel = Math.max(0, items.length - 1);
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1006
|
+
const visible = Math.max(1, this.h - 3);
|
|
1007
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
1008
|
+
else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
|
|
1009
|
+
this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
|
|
1010
|
+
for (let i = 0; i < visible; i++) {
|
|
1011
|
+
const idx = this.scroll + i;
|
|
1012
|
+
const it = items[idx];
|
|
1013
|
+
if (!it) { s.hline(this.x + 1, this.x + this.w - 2, this.y + 2 + i, " ", { bg: T.PANEL }); continue; }
|
|
1014
|
+
const sel = idx === this.sel;
|
|
890
1015
|
s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
|
|
891
1016
|
const label = it[0];
|
|
892
1017
|
s.text(this.x + 2, this.y + 2 + i, truncate(label, this.w - 34), { fg: sel ? T.BOLD : (this.page === 0 ? T.ACCENT : T.TXT), bg: sel ? T.MENUSEL : T.PANEL, attrs: sel ? 1 : 0 });
|
|
@@ -947,9 +1072,9 @@ export class ControlPanel extends Widget {
|
|
|
947
1072
|
}
|
|
948
1073
|
return true;
|
|
949
1074
|
}
|
|
950
|
-
const idx = ev.y - this.y - 2;
|
|
1075
|
+
const idx = this.scroll + (ev.y - this.y - 2);
|
|
951
1076
|
const items = this.items();
|
|
952
|
-
if (idx >= 0 && idx < items.length &&
|
|
1077
|
+
if (idx >= 0 && idx < items.length && (ev.y - this.y - 2) < this.h - 3) {
|
|
953
1078
|
this.sel = idx;
|
|
954
1079
|
const it = items[idx];
|
|
955
1080
|
if (it && it[2]) { it[2](); this.app.redraw(); }
|
package/src/term.js
CHANGED
|
@@ -44,7 +44,8 @@ export class Term {
|
|
|
44
44
|
this.input.on("data", (chunk) => this.#feed(this.decoder.write(chunk)));
|
|
45
45
|
const o = this.output;
|
|
46
46
|
o.write("\x1b[?1049h"); // alt screen
|
|
47
|
-
o.write("\x1b[?25l"); // hide cursor
|
|
47
|
+
o.write("\x1b[?25l"); // hide cursor (shown only over the focused input)
|
|
48
|
+
o.write("\x1b[5 q"); // DECSCUSR: blinking vertical bar caret
|
|
48
49
|
o.write("\x1b[?1000h"); // mouse: clicks
|
|
49
50
|
o.write("\x1b[?1002h"); // mouse: drag
|
|
50
51
|
o.write("\x1b[?1003h"); // mouse: all motion
|
package/src/views.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
|
|
7
7
|
import {
|
|
8
8
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
9
|
-
modeName, permName, WorkspacePanel, TrajectoryPanel,
|
|
9
|
+
modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
|
|
10
10
|
ImagePopup, kittyCapable, buildJobsPopup, buildGoalPopup, SettingsPanel, SubagentPanel,
|
|
11
11
|
SkillsPanel, ControlPanel,
|
|
12
12
|
} from "./panels.js";
|
|
@@ -528,6 +528,7 @@ export class ChatView extends Widget {
|
|
|
528
528
|
this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
|
|
529
529
|
this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
|
|
530
530
|
this.bashMode = "expanded"; // tool blocks: expanded | collapsed (b toggles)
|
|
531
|
+
this.todosVisible = true; // todo block above the input (Shift+T toggles)
|
|
531
532
|
this.running = false;
|
|
532
533
|
this.hasMore = false;
|
|
533
534
|
this.loadingOlder = false;
|
|
@@ -540,9 +541,10 @@ export class ChatView extends Widget {
|
|
|
540
541
|
this.input = new Input({
|
|
541
542
|
x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
|
|
542
543
|
multi: true, maxLines: 6,
|
|
544
|
+
bg: T.BG2, border: T.BORDER2,
|
|
543
545
|
placeholder: "输入消息…(Ctrl+J 换行,Enter 发送)",
|
|
544
546
|
onEnter: (v) => this.send(v),
|
|
545
|
-
onChange: () => this
|
|
547
|
+
onChange: () => this.inputChanged(),
|
|
546
548
|
});
|
|
547
549
|
this.contextNode = null;
|
|
548
550
|
this.rebuildQueued = false;
|
|
@@ -679,12 +681,20 @@ export class ChatView extends Widget {
|
|
|
679
681
|
}
|
|
680
682
|
}
|
|
681
683
|
|
|
682
|
-
|
|
684
|
+
/** Height of the collapsible todo block (0 when empty or collapsed). */
|
|
685
|
+
todoHeight() {
|
|
686
|
+
const todos = this.app.todos;
|
|
687
|
+
if (!this.todosVisible || !todos || todos.length === 0) return 0;
|
|
688
|
+
return Math.min(todos.length, 6) + 1; // 1 header row + up to 6 todos
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
inputChanged() {
|
|
683
692
|
// Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
|
|
684
693
|
const ih = this.input.height();
|
|
685
694
|
const prevIh = this.input.h;
|
|
686
695
|
this.input.h = ih;
|
|
687
|
-
|
|
696
|
+
const th = this.todoHeight();
|
|
697
|
+
this.view.h = this.h - ih - th - 1;
|
|
688
698
|
this.input.y = this.y + this.h - ih;
|
|
689
699
|
if (ih !== prevIh) this.app.layout();
|
|
690
700
|
this.app.redraw();
|
|
@@ -694,7 +704,8 @@ export class ChatView extends Widget {
|
|
|
694
704
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
695
705
|
const ih = this.input.height();
|
|
696
706
|
this.input.h = ih;
|
|
697
|
-
|
|
707
|
+
const th = this.todoHeight();
|
|
708
|
+
this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
|
|
698
709
|
this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
|
|
699
710
|
this.cache.clear();
|
|
700
711
|
this.#rebuild();
|
|
@@ -955,6 +966,7 @@ export class ChatView extends Widget {
|
|
|
955
966
|
{ t: open ? "▾ " : "▸ ", fg: K.ACCENT },
|
|
956
967
|
{ t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
|
|
957
968
|
{ t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
|
|
969
|
+
{ t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
|
|
958
970
|
]);
|
|
959
971
|
mark(realIdx, bi);
|
|
960
972
|
if (!open) {
|
|
@@ -1107,10 +1119,27 @@ export class ChatView extends Widget {
|
|
|
1107
1119
|
screen.invertRect(this.view.x, this.view.y + (y0 - this.view.scrollY), this.view.x + this.view.w - 2, this.view.y + (y1 - this.view.scrollY));
|
|
1108
1120
|
}
|
|
1109
1121
|
}
|
|
1122
|
+
this.#renderTodos(screen);
|
|
1110
1123
|
screen.hline(this.x, this.x + this.w - 1, this.input.y - 1, "─", { fg: T.BORDER2 });
|
|
1111
1124
|
this.input.render(screen);
|
|
1112
1125
|
}
|
|
1113
1126
|
|
|
1127
|
+
/** Collapsible todo block between the view and the input (Shift+T toggles). */
|
|
1128
|
+
#renderTodos(screen) {
|
|
1129
|
+
const th = this.todoHeight();
|
|
1130
|
+
if (th === 0) return;
|
|
1131
|
+
const todos = this.app.todos ?? [];
|
|
1132
|
+
const y = this.input.y - th - 1;
|
|
1133
|
+
screen.fillRect(this.x, y, this.x + this.w - 1, y + th - 1, " ", { bg: T.THINKBG });
|
|
1134
|
+
screen.text(this.x, y, " ☐ 任务清单(Shift+T 折叠)", { fg: K.FAINT });
|
|
1135
|
+
for (let i = 0; i < Math.min(todos.length, th - 1); i++) {
|
|
1136
|
+
const t = todos[i];
|
|
1137
|
+
const icon = t.status === "completed" ? "✓" : t.status === "in_progress" ? "◉" : "○";
|
|
1138
|
+
const color = t.status === "completed" ? T.FAINT : t.status === "in_progress" ? T.WARN : T.DIM;
|
|
1139
|
+
screen.text(this.x + 2, y + 1 + i, `${icon} ${truncate(t.content ?? String(t), this.w - 6)}`, { fg: color });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1114
1143
|
onMouse(ev) {
|
|
1115
1144
|
if (this.input.inside(ev.x, ev.y)) {
|
|
1116
1145
|
// INSERT mode is keyboard-only; only position the cursor if already there.
|
|
@@ -1213,6 +1242,12 @@ export class ChatView extends Widget {
|
|
|
1213
1242
|
return true;
|
|
1214
1243
|
}
|
|
1215
1244
|
if (ev.name === "char" && ev.key === "t" && !ev.ctrl) {
|
|
1245
|
+
if (ev.shift) {
|
|
1246
|
+
this.todosVisible = !this.todosVisible;
|
|
1247
|
+
this.app.toast(this.todosVisible ? "任务块:显示(Shift+T 折叠)" : "任务块:折叠(Shift+T 展开)");
|
|
1248
|
+
this.inputChanged();
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1216
1251
|
this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
|
|
1217
1252
|
this.expanded.clear();
|
|
1218
1253
|
this.collapsedBlocks.clear();
|
|
@@ -1227,12 +1262,16 @@ export class ChatView extends Widget {
|
|
|
1227
1262
|
function toolSummary(b) {
|
|
1228
1263
|
// Prefer the human description of what the tool did (the agent's `description`
|
|
1229
1264
|
// argument), then the host card title, then the raw command — mirrors the web.
|
|
1265
|
+
// File tools get a path-based summary so read/edit collapse as concisely as bash.
|
|
1230
1266
|
if (b.args) {
|
|
1231
1267
|
try {
|
|
1232
1268
|
const a = JSON.parse(b.args);
|
|
1233
1269
|
if (typeof a === "object" && a !== null) {
|
|
1234
|
-
const desc = a.description ?? a.summary ?? a.title ?? a.
|
|
1270
|
+
const desc = a.description ?? a.summary ?? a.title ?? a.query ?? a.content ?? a.name;
|
|
1235
1271
|
if (desc) return String(desc).slice(0, 120);
|
|
1272
|
+
if (a.file_path) return `read ${String(a.file_path)}`.slice(0, 120);
|
|
1273
|
+
if (a.path && a.command) return `${a.command} ${String(a.path)}`.slice(0, 120);
|
|
1274
|
+
if (a.path) return String(a.path).slice(0, 120);
|
|
1236
1275
|
return a.command ?? null;
|
|
1237
1276
|
}
|
|
1238
1277
|
return String(a).slice(0, 120);
|
|
@@ -1532,6 +1571,12 @@ export class App {
|
|
|
1532
1571
|
this.model = host.model ?? "";
|
|
1533
1572
|
} catch (e) { this.log(`[app] host.describe: ${e.message}`); }
|
|
1534
1573
|
await this.refreshSessions();
|
|
1574
|
+
// Open on a blank session at the launch directory (reusing an existing
|
|
1575
|
+
// draft when available) so the blank-session homepage shows immediately
|
|
1576
|
+
// instead of a fully empty chat area.
|
|
1577
|
+
if (!this.currentSession) {
|
|
1578
|
+
await this.newSessionIn(null);
|
|
1579
|
+
}
|
|
1535
1580
|
this.api.connectMux();
|
|
1536
1581
|
this.api.connectHost();
|
|
1537
1582
|
this.api.onFrame = (frame) => this.#onFrame(frame);
|
|
@@ -1584,6 +1629,8 @@ export class App {
|
|
|
1584
1629
|
case "session/projection":
|
|
1585
1630
|
this.projections[frame.key] = frame.value;
|
|
1586
1631
|
if (frame.key === "tokenUsage") this.tokenUsage = frame.value;
|
|
1632
|
+
// the todo block's height depends on the todos projection → reflow
|
|
1633
|
+
if (frame.key === "todos") this.chat.inputChanged();
|
|
1587
1634
|
break;
|
|
1588
1635
|
case "approval/resolved":
|
|
1589
1636
|
case "question/resolved":
|
|
@@ -1638,6 +1685,43 @@ export class App {
|
|
|
1638
1685
|
} catch (e) { this.toast(`移动失败: ${e.message}`); }
|
|
1639
1686
|
}
|
|
1640
1687
|
|
|
1688
|
+
/** Move a workspace up/down in the durable display order. */
|
|
1689
|
+
async moveWorkspace(node, delta) {
|
|
1690
|
+
const ws = this.workspaceItems?.find((w) => w.workspaceId === node.workspaceId);
|
|
1691
|
+
if (!ws || !this.workspaceItems) { this.toast("找不到工作区"); return; }
|
|
1692
|
+
const ids = this.workspaceItems.map((w) => w.workspaceId);
|
|
1693
|
+
const idx = ids.indexOf(ws.workspaceId);
|
|
1694
|
+
if (idx < 0) return;
|
|
1695
|
+
const target = idx + delta;
|
|
1696
|
+
if (target < 0 || target >= ids.length) return;
|
|
1697
|
+
const beforeWorkspaceId = delta === -1 ? ids[target] : (target + 1 < ids.length ? ids[target + 1] : undefined);
|
|
1698
|
+
try {
|
|
1699
|
+
await this.api.call("workspace.insertBefore", {
|
|
1700
|
+
workspaceId: ws.workspaceId,
|
|
1701
|
+
...(beforeWorkspaceId !== undefined ? { beforeWorkspaceId } : {}),
|
|
1702
|
+
});
|
|
1703
|
+
await this.refreshSessions();
|
|
1704
|
+
} catch (e) { this.toast(`移动工作区失败: ${e.message}`); }
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/** Yazi-style folder picker → workspace.create. */
|
|
1708
|
+
addWorkspace() {
|
|
1709
|
+
this.overlay = new DirPicker(this, {
|
|
1710
|
+
startPath: process.cwd(),
|
|
1711
|
+
onPick: async (path) => {
|
|
1712
|
+
this.overlay = null;
|
|
1713
|
+
try {
|
|
1714
|
+
await this.api.call("workspace.create", { path });
|
|
1715
|
+
await this.refreshSessions();
|
|
1716
|
+
this.toast(`已添加工作区: ${path}`);
|
|
1717
|
+
} catch (e) { this.toast(`添加失败: ${e.message}`); }
|
|
1718
|
+
this.redraw();
|
|
1719
|
+
},
|
|
1720
|
+
onCancel: () => { this.overlay = null; this.redraw(); },
|
|
1721
|
+
});
|
|
1722
|
+
this.redraw();
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1641
1725
|
renameSession(s) {
|
|
1642
1726
|
this.closeOverlay();
|
|
1643
1727
|
const input = new Input({ x: 2, y: this.screen.h - 3, w: this.screen.w - 4, h: 1, prompt: "标题: ", allowEmptyEnter: true, onEnter: () => this.#commitRename(s, input) });
|
|
@@ -1780,8 +1864,10 @@ export class App {
|
|
|
1780
1864
|
|
|
1781
1865
|
async newSessionIn(group = null) {
|
|
1782
1866
|
// Reuse an existing empty draft instead of minting a fresh blank session on
|
|
1783
|
-
// every "new session" click (this is how the meaningless blank sessions pile
|
|
1784
|
-
|
|
1867
|
+
// every "new session" click (this is how the meaningless blank sessions pile
|
|
1868
|
+
// up). Prefer a draft at the same directory (e.g. the launch cwd on open).
|
|
1869
|
+
const blanks = this.sessions.filter((s) => s.blank && !s.running && s.sessionId !== this.currentSession);
|
|
1870
|
+
const blank = blanks.find((s) => s.cwd === process.cwd()) ?? blanks[0];
|
|
1785
1871
|
if (blank && (group?.workspaceId == null || this.#sessionInWorkspace(blank.sessionId, group.workspaceId))) {
|
|
1786
1872
|
await this.refreshSessions();
|
|
1787
1873
|
this.openSession(blank.sessionId);
|
|
@@ -2396,11 +2482,13 @@ export class App {
|
|
|
2396
2482
|
if (this.menu) this.menu.render(s);
|
|
2397
2483
|
if (this.overlay) this.overlay.render(s);
|
|
2398
2484
|
if (this.toastMsg) {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
const
|
|
2402
|
-
|
|
2403
|
-
|
|
2485
|
+
// Toasts land in the LOWER half (just above the input/footer) where the
|
|
2486
|
+
// user's attention is while pressing shortcuts — a solid color block.
|
|
2487
|
+
const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
|
|
2488
|
+
const x0 = Math.max(2, Math.floor((s.w - w) / 2));
|
|
2489
|
+
const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
|
|
2490
|
+
s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
|
|
2491
|
+
s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
2404
2492
|
}
|
|
2405
2493
|
if (this.renameInput && this.popup) this.renameInput.render(s);
|
|
2406
2494
|
|
|
@@ -2409,6 +2497,11 @@ export class App {
|
|
|
2409
2497
|
if (this.overlay && typeof this.overlay.kittyTransmit === "function" && kittyCapable()) {
|
|
2410
2498
|
tail = this.overlay.kittyTransmit();
|
|
2411
2499
|
}
|
|
2500
|
+
// Native terminal caret: a blinking vertical bar (text-editor style) at
|
|
2501
|
+
// the focused input's cursor cell; hidden everywhere else.
|
|
2502
|
+
const cell = this.focused?.cursorCell;
|
|
2503
|
+
if (cell) tail = `\x1b[?25h\x1b[${cell.y + 1};${cell.x + 1}H` + tail;
|
|
2504
|
+
else tail = "\x1b[?25l" + tail;
|
|
2412
2505
|
this.term.output.write(out + tail);
|
|
2413
2506
|
}
|
|
2414
2507
|
|
package/src/widgets.js
CHANGED
|
@@ -247,9 +247,59 @@ export class Input extends Widget {
|
|
|
247
247
|
this.histIdx = -1;
|
|
248
248
|
}
|
|
249
249
|
#cps() { return Array.from(this.value); } // code points
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
250
|
+
/** Visual rows for multi-line input: logical lines wrapped at the width. */
|
|
251
|
+
#visualRows() {
|
|
252
|
+
const inner0 = Math.max(1, this.w - strWidth(this.prompt) - 2);
|
|
253
|
+
const innerN = Math.max(1, this.w - 2);
|
|
254
|
+
const rows = [];
|
|
255
|
+
const cps = Array.from(this.value);
|
|
256
|
+
let text = "", width = 0, limit = inner0, start = 0;
|
|
257
|
+
for (let i = 0; i < cps.length; i++) {
|
|
258
|
+
const ch = cps[i];
|
|
259
|
+
if (ch === "\n") {
|
|
260
|
+
rows.push({ text, start, end: i, limit });
|
|
261
|
+
text = ""; width = 0; limit = innerN; start = i + 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const cw = strWidth(ch);
|
|
265
|
+
if (width + cw > limit && width > 0) {
|
|
266
|
+
rows.push({ text, start, end: i, limit });
|
|
267
|
+
text = ""; width = 0; limit = innerN; start = i;
|
|
268
|
+
}
|
|
269
|
+
text += ch; width += cw;
|
|
270
|
+
}
|
|
271
|
+
rows.push({ text, start, end: cps.length, limit });
|
|
272
|
+
return rows;
|
|
273
|
+
}
|
|
274
|
+
/** [visualRow, display-col] of the cursor in wrapped coordinates. */
|
|
275
|
+
#cursorVisual() {
|
|
276
|
+
const rows = this.#visualRows();
|
|
277
|
+
const cursor = Math.max(0, Math.min(this.cursor, Array.from(this.value).length));
|
|
278
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
279
|
+
const r = rows[ri];
|
|
280
|
+
if (cursor >= r.start && cursor <= r.end) {
|
|
281
|
+
const before = Array.from(r.text).slice(0, cursor - r.start);
|
|
282
|
+
return { row: ri, col: before.reduce((w, ch) => w + strWidth(ch), 0) };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const last = rows[rows.length - 1];
|
|
286
|
+
return { row: rows.length - 1, col: strWidth(last.text) };
|
|
287
|
+
}
|
|
288
|
+
/** Code-point index of the nearest position at a visual [row, col]. */
|
|
289
|
+
#indexAtVisual(row, col) {
|
|
290
|
+
const rows = this.#visualRows();
|
|
291
|
+
const r = rows[Math.max(0, Math.min(row, rows.length - 1))];
|
|
292
|
+
const cps = Array.from(r.text);
|
|
293
|
+
let w = 0, j = 0;
|
|
294
|
+
for (; j < cps.length; j++) {
|
|
295
|
+
const cw = strWidth(cps[j]);
|
|
296
|
+
if (col < w + cw / 2) break;
|
|
297
|
+
w += cw;
|
|
298
|
+
}
|
|
299
|
+
return r.start + j;
|
|
300
|
+
}
|
|
301
|
+
/** Rendered height: 1, or wrapped rows capped at maxLines when multi. */
|
|
302
|
+
height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#visualRows().length)) : 1; }
|
|
253
303
|
setValue(v, opts = {}) {
|
|
254
304
|
this.value = String(v);
|
|
255
305
|
this.cursor = this.#cps().length;
|
|
@@ -270,75 +320,88 @@ export class Input extends Widget {
|
|
|
270
320
|
cps.splice(idx, 1);
|
|
271
321
|
this.value = cps.join("");
|
|
272
322
|
}
|
|
273
|
-
/**
|
|
274
|
-
#
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
#indexAtLineCol(line, col) {
|
|
285
|
-
const lines = this.#lines();
|
|
286
|
-
let idx = 0;
|
|
287
|
-
for (let i = 0; i < Math.min(line, lines.length); i++) idx += Array.from(lines[i]).length + 1;
|
|
288
|
-
const target = lines[Math.min(line, lines.length - 1)] ?? "";
|
|
289
|
-
const cps = Array.from(target);
|
|
290
|
-
let w = 0;
|
|
291
|
-
for (let j = 0; j < cps.length; j++) {
|
|
292
|
-
const cw = strWidth(cps[j]);
|
|
293
|
-
if (col < w + cw / 2) break;
|
|
294
|
-
w += cw;
|
|
295
|
-
idx++;
|
|
296
|
-
}
|
|
297
|
-
return idx;
|
|
323
|
+
/** Scroll offset that keeps the cursor's visual row inside the window. */
|
|
324
|
+
#scrollStart(h) {
|
|
325
|
+
const rows = this.#visualRows();
|
|
326
|
+
const { row } = this.#cursorVisual();
|
|
327
|
+
const maxStart = Math.max(0, rows.length - this.maxLines);
|
|
328
|
+
let start = this.scrollY ?? 0;
|
|
329
|
+
if (row < start) start = row;
|
|
330
|
+
else if (row >= start + h) start = row - h + 1;
|
|
331
|
+
start = Math.max(0, Math.min(maxStart, start));
|
|
332
|
+
this.scrollY = start;
|
|
333
|
+
return start;
|
|
298
334
|
}
|
|
299
335
|
render(screen) {
|
|
300
|
-
|
|
301
|
-
|
|
336
|
+
if (!this.multi) {
|
|
337
|
+
// single-line: horizontal scroll (search/rename/picker inputs)
|
|
338
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y, " ", { bg: this.bg });
|
|
339
|
+
screen.hline(this.x, this.x + this.w - 1, this.y, "─", { fg: this.border, bg: this.bg });
|
|
340
|
+
const promptW = strWidth(this.prompt);
|
|
341
|
+
const inner = Math.max(0, this.w - promptW - 2);
|
|
342
|
+
screen.text(this.x, this.y, this.prompt, { fg: T.ACCENT, bg: this.bg });
|
|
343
|
+
if (this.value === "" && this.placeholder) {
|
|
344
|
+
screen.text(this.x + promptW, this.y, truncate(this.placeholder, inner), { fg: T.FAINT, bg: this.bg });
|
|
345
|
+
this.cursorCell = { x: this.x + promptW, y: this.y };
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const before = Array.from(this.value).slice(0, this.cursor).join("");
|
|
349
|
+
const cx = strWidth(before);
|
|
350
|
+
let start = 0;
|
|
351
|
+
while (cx - start >= inner) start += Math.max(1, Math.floor(inner / 2));
|
|
352
|
+
const visible = truncate(Array.from(this.value).slice(start).join(""), inner);
|
|
353
|
+
screen.text(this.x + promptW, this.y, visible, { fg: this.fg, bg: this.bg });
|
|
354
|
+
this.cursorCell = { x: this.x + promptW + Math.min(inner, Math.max(0, cx - start)), y: this.y };
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// multi-line: auto-wrap + scroll window that follows the cursor
|
|
358
|
+
const rows = this.#visualRows();
|
|
359
|
+
const h = Math.min(this.maxLines, rows.length);
|
|
360
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + h - 1, " ", { bg: this.bg });
|
|
302
361
|
screen.hline(this.x, this.x + this.w - 1, this.y, "─", { fg: this.border, bg: this.bg });
|
|
303
|
-
const lines = this.#lines();
|
|
304
|
-
const [curLine, curCol] = this.#cursorPos();
|
|
305
|
-
const promptW = strWidth(this.prompt);
|
|
306
|
-
const inner0 = Math.max(0, this.w - promptW - 2);
|
|
307
|
-
const innerN = Math.max(0, this.w - 2);
|
|
308
362
|
if (this.value === "" && this.placeholder) {
|
|
309
363
|
screen.text(this.x, this.y, this.prompt, { fg: T.ACCENT, bg: this.bg });
|
|
310
|
-
screen.text(this.x +
|
|
364
|
+
screen.text(this.x + strWidth(this.prompt), this.y, truncate(this.placeholder, this.w - strWidth(this.prompt) - 2), { fg: T.FAINT, bg: this.bg });
|
|
365
|
+
this.cursorCell = { x: this.x + strWidth(this.prompt), y: this.y };
|
|
311
366
|
return;
|
|
312
367
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
368
|
+
const { row: curRow, col: curCol } = this.#cursorVisual();
|
|
369
|
+
const start = this.#scrollStart(h);
|
|
370
|
+
for (let ri = start; ri < Math.min(rows.length, start + h); ri++) {
|
|
371
|
+
const r = rows[ri];
|
|
372
|
+
const y = this.y + (ri - start);
|
|
373
|
+
if (ri === 0) {
|
|
317
374
|
screen.text(this.x, y, this.prompt, { fg: T.ACCENT, bg: this.bg });
|
|
318
|
-
|
|
319
|
-
if (curLine === 0) {
|
|
320
|
-
while (curCol - start >= inner0) start += Math.max(1, Math.floor(inner0 / 2));
|
|
321
|
-
}
|
|
322
|
-
const visible = truncate(Array.from(line).slice(start).join(""), inner0);
|
|
323
|
-
screen.text(this.x + promptW, y, visible, { fg: this.fg, bg: this.bg });
|
|
324
|
-
if (curLine === 0) {
|
|
325
|
-
const at = Math.min(inner0, Math.max(0, curCol - start));
|
|
326
|
-
screen.put(this.x + promptW + at, y, "█", { fg: T.FAINT, bg: this.bg });
|
|
327
|
-
}
|
|
375
|
+
screen.text(this.x + strWidth(this.prompt), y, r.text, { fg: this.fg, bg: this.bg });
|
|
328
376
|
} else {
|
|
329
|
-
screen.text(this.x + 1, y,
|
|
330
|
-
if (curLine === li) {
|
|
331
|
-
const at = Math.min(innerN, Math.max(0, curCol));
|
|
332
|
-
screen.put(this.x + 1 + at, y, "█", { fg: T.FAINT, bg: this.bg });
|
|
333
|
-
}
|
|
377
|
+
screen.text(this.x + 1, y, r.text, { fg: this.fg, bg: this.bg });
|
|
334
378
|
}
|
|
335
379
|
}
|
|
380
|
+
// Native terminal caret (blinking bar) is positioned by the app after the
|
|
381
|
+
// frame; store the cell it should occupy (the char at the cursor index).
|
|
382
|
+
const curY = this.y + (curRow - start);
|
|
383
|
+
const curX = curRow === 0 ? this.x + strWidth(this.prompt) + curCol : this.x + 1 + curCol;
|
|
384
|
+
this.cursorCell = { x: Math.min(this.x + this.w - 1, curX), y: Math.min(this.y + h - 1, curY) };
|
|
336
385
|
}
|
|
337
386
|
onMouse(ev) {
|
|
338
387
|
if (ev.kind === "press" && ev.button === 0) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
388
|
+
if (this.multi) {
|
|
389
|
+
const h = this.height();
|
|
390
|
+
const start = this.scrollY ?? 0;
|
|
391
|
+
const row = start + Math.max(0, Math.min(h - 1, ev.y - this.y));
|
|
392
|
+
const rx = ev.x - this.x - (row === 0 ? strWidth(this.prompt) : 1);
|
|
393
|
+
this.cursor = this.#indexAtVisual(row, Math.max(0, rx));
|
|
394
|
+
} else {
|
|
395
|
+
const rx = ev.x - this.x - strWidth(this.prompt);
|
|
396
|
+
let w = 0, idx = 0;
|
|
397
|
+
for (const ch of Array.from(this.value)) {
|
|
398
|
+
const cw = strWidth(ch);
|
|
399
|
+
if (rx < w + cw / 2) break;
|
|
400
|
+
w += cw;
|
|
401
|
+
idx++;
|
|
402
|
+
}
|
|
403
|
+
this.cursor = idx;
|
|
404
|
+
}
|
|
342
405
|
return true;
|
|
343
406
|
}
|
|
344
407
|
return false;
|
|
@@ -355,12 +418,21 @@ export class Input extends Widget {
|
|
|
355
418
|
return true;
|
|
356
419
|
case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
|
|
357
420
|
case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
|
|
358
|
-
case "home": {
|
|
359
|
-
|
|
421
|
+
case "home": {
|
|
422
|
+
if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].start; }
|
|
423
|
+
else this.cursor = 0;
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
case "end": {
|
|
427
|
+
if (this.multi) { const rows = this.#visualRows(); const { row } = this.#cursorVisual(); this.cursor = rows[row].end; }
|
|
428
|
+
else this.cursor = this.#cps().length;
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
360
431
|
case "up":
|
|
361
432
|
if (this.multi) {
|
|
362
|
-
const
|
|
363
|
-
|
|
433
|
+
const rows = this.#visualRows();
|
|
434
|
+
const { row, col } = this.#cursorVisual();
|
|
435
|
+
if (row > 0) this.cursor = this.#indexAtVisual(row - 1, col);
|
|
364
436
|
} else if (this.history.length) {
|
|
365
437
|
this.histIdx = this.histIdx < 0 ? this.history.length - 1 : Math.max(0, this.histIdx - 1);
|
|
366
438
|
this.setValue(this.history[this.histIdx] ?? "");
|
|
@@ -368,8 +440,9 @@ export class Input extends Widget {
|
|
|
368
440
|
return true;
|
|
369
441
|
case "down":
|
|
370
442
|
if (this.multi) {
|
|
371
|
-
const
|
|
372
|
-
|
|
443
|
+
const rows = this.#visualRows();
|
|
444
|
+
const { row, col } = this.#cursorVisual();
|
|
445
|
+
if (row < rows.length - 1) this.cursor = this.#indexAtVisual(row + 1, col);
|
|
373
446
|
} else if (this.histIdx >= 0) {
|
|
374
447
|
this.histIdx++;
|
|
375
448
|
if (this.histIdx >= this.history.length) { this.histIdx = -1; this.setValue(""); }
|