dsh-neotui 0.0.1 → 0.0.3
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/README.md +21 -6
- package/package.json +1 -1
- package/src/panels.js +34 -11
- package/src/term.js +9 -2
- package/src/views.js +88 -19
- package/src/widgets.js +138 -64
package/README.md
CHANGED
|
@@ -10,14 +10,29 @@ dsh plugin --profile dsh-neotui add dsh-neotui-app # 装 bundle,自动加入
|
|
|
10
10
|
dsh --profile dsh-neotui # 启动
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 本地开发(从源码跑)
|
|
14
|
+
|
|
15
|
+
克隆后先自链核心包(app bundle 通过包名 `dsh-neotui` 引核心,Node 会从真实路径向上找 `node_modules`):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
ln -sfn .. node_modules/dsh-neotui # 仓库根自链,让 app/src 能 import "dsh-neotui"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
然后把 profile 的两条软链指到本仓库(`~/.dsh/profiles/node_modules/`):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ln -sfn "$(pwd)" ~/.dsh/profiles/node_modules/dsh-neotui
|
|
25
|
+
ln -sfn "$(pwd)/app" ~/.dsh/profiles/node_modules/dsh-neotui-app
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
启动(本地开发 profile `~/.dsh/profiles/dsh-neotui`):
|
|
14
29
|
|
|
15
30
|
```bash
|
|
16
|
-
dsh --profile dsh-neotui
|
|
17
|
-
dsh --profile dsh-neotui --session <id>
|
|
18
|
-
dsh --profile dsh-neotui --cwd ~/work
|
|
19
|
-
dsh --profile dsh-neotui --port 3981
|
|
20
|
-
dsh --profile dsh-neotui --attach 3080 # 共存调试:连已运行的 web
|
|
31
|
+
dsh --profile dsh-neotui
|
|
32
|
+
dsh --profile dsh-neotui --session <id>
|
|
33
|
+
dsh --profile dsh-neotui --cwd ~/work
|
|
34
|
+
dsh --profile dsh-neotui --port 3981
|
|
35
|
+
dsh --profile dsh-neotui --attach 3080 # 共存调试:连已运行的 web 宿主(存储隔离,不碰 $DSH_HOME)
|
|
21
36
|
|
|
22
37
|
node bin/dsh-tui.js # 纯客户端模式:连接已运行的 web 宿主(默认 3080)
|
|
23
38
|
node bin/dsh-tui.js --base http://host:port
|
package/package.json
CHANGED
package/src/panels.js
CHANGED
|
@@ -393,10 +393,16 @@ export class WorkspacePanel extends Widget {
|
|
|
393
393
|
};
|
|
394
394
|
walk(this.tree);
|
|
395
395
|
this.searchResults = results;
|
|
396
|
-
|
|
397
|
-
|
|
396
|
+
const visH = Math.max(1, this.h - 2);
|
|
397
|
+
if (this.searchSel < (this.searchScroll ?? 0)) this.searchScroll = this.searchSel;
|
|
398
|
+
else if (this.searchSel >= (this.searchScroll ?? 0) + visH) this.searchScroll = this.searchSel - visH + 1;
|
|
399
|
+
this.searchScroll = Math.max(0, Math.min(Math.max(0, results.length - visH), this.searchScroll ?? 0));
|
|
400
|
+
for (let i = 0; i < visH; i++) {
|
|
401
|
+
const idx = this.searchScroll + i;
|
|
402
|
+
if (idx >= results.length) break;
|
|
403
|
+
const sel = idx === this.searchSel;
|
|
398
404
|
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[
|
|
405
|
+
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
406
|
}
|
|
401
407
|
screen.text(this.x + 1, this.y + this.h - 1, ` 匹配 ${results.length} 个文件 · Esc 退出搜索`, { fg: K.FAINT });
|
|
402
408
|
return;
|
|
@@ -496,6 +502,15 @@ export class TrajectoryPanel extends Widget {
|
|
|
496
502
|
this.buildLines();
|
|
497
503
|
}
|
|
498
504
|
async load(sessionId) {
|
|
505
|
+
// Re-entry into the same session reuses the already-built steps — instant,
|
|
506
|
+
// like the web view (which keeps its timeline in memory). A fresh session
|
|
507
|
+
// or an explicit refresh (r) re-fetches the recent window.
|
|
508
|
+
if (this.sessionId === sessionId && this.steps.length > 0) {
|
|
509
|
+
this.loading = false;
|
|
510
|
+
this.buildLines();
|
|
511
|
+
this.app.redraw();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
499
514
|
this.sessionId = sessionId;
|
|
500
515
|
this.loading = true;
|
|
501
516
|
this.steps = [];
|
|
@@ -506,8 +521,8 @@ export class TrajectoryPanel extends Widget {
|
|
|
506
521
|
this.app.setStatus("加载轨迹…");
|
|
507
522
|
try {
|
|
508
523
|
// One bounded call for the recent steps (maxMessages = model messages =
|
|
509
|
-
// steps).
|
|
510
|
-
const h = await this.app.api.call("session.history", { sessionId, maxMessages:
|
|
524
|
+
// steps). Older steps load on demand via PgUp/click.
|
|
525
|
+
const h = await this.app.api.call("session.history", { sessionId, maxMessages: 20 });
|
|
511
526
|
this.stats = h.projections?.values?.sessionStats ?? null;
|
|
512
527
|
this.minSeq = h.events[0]?.event?.seq ?? null;
|
|
513
528
|
this.hasMore = h.hasMore;
|
|
@@ -557,7 +572,7 @@ export class TrajectoryPanel extends Widget {
|
|
|
557
572
|
buildLines() {
|
|
558
573
|
const w = Math.max(40, this.w - 2);
|
|
559
574
|
const lines = [];
|
|
560
|
-
lines.push([{ t: "轨迹 —
|
|
575
|
+
lines.push([{ t: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情 · r 刷新)", fg: K.ACCENT, bold: true }]);
|
|
561
576
|
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
|
|
562
577
|
else lines.push([{ t: "" }]);
|
|
563
578
|
const st = this.stats;
|
|
@@ -609,6 +624,7 @@ export class TrajectoryPanel extends Widget {
|
|
|
609
624
|
return true;
|
|
610
625
|
}
|
|
611
626
|
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
|
|
627
|
+
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.steps = []; this.load(this.sessionId); return true; }
|
|
612
628
|
if (ev.name === "pgup") { if (this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
|
|
613
629
|
if (ev.name === "pgdn" || ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
|
|
614
630
|
return false;
|
|
@@ -771,6 +787,7 @@ export class ControlPanel extends Widget {
|
|
|
771
787
|
this.subPages = ["常规", "插件"];
|
|
772
788
|
this.subPage = 0;
|
|
773
789
|
this.sel = 0;
|
|
790
|
+
this.scroll = 0;
|
|
774
791
|
this.commands = DEFAULT_COMMANDS;
|
|
775
792
|
this.plugins = null;
|
|
776
793
|
this.pluginError = null;
|
|
@@ -874,9 +891,15 @@ export class ControlPanel extends Widget {
|
|
|
874
891
|
}
|
|
875
892
|
const items = this.items();
|
|
876
893
|
if (this.sel >= items.length) this.sel = Math.max(0, items.length - 1);
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
894
|
+
const visible = Math.max(1, this.h - 3);
|
|
895
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
896
|
+
else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
|
|
897
|
+
this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
|
|
898
|
+
for (let i = 0; i < visible; i++) {
|
|
899
|
+
const idx = this.scroll + i;
|
|
900
|
+
const it = items[idx];
|
|
901
|
+
if (!it) { s.hline(this.x + 1, this.x + this.w - 2, this.y + 2 + i, " ", { bg: T.PANEL }); continue; }
|
|
902
|
+
const sel = idx === this.sel;
|
|
880
903
|
s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
|
|
881
904
|
const label = it[0];
|
|
882
905
|
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 });
|
|
@@ -937,9 +960,9 @@ export class ControlPanel extends Widget {
|
|
|
937
960
|
}
|
|
938
961
|
return true;
|
|
939
962
|
}
|
|
940
|
-
const idx = ev.y - this.y - 2;
|
|
963
|
+
const idx = this.scroll + (ev.y - this.y - 2);
|
|
941
964
|
const items = this.items();
|
|
942
|
-
if (idx >= 0 && idx < items.length &&
|
|
965
|
+
if (idx >= 0 && idx < items.length && (ev.y - this.y - 2) < this.h - 3) {
|
|
943
966
|
this.sel = idx;
|
|
944
967
|
const it = items[idx];
|
|
945
968
|
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
|
|
@@ -128,7 +129,13 @@ export class Term {
|
|
|
128
129
|
if (cp === 13) this.#emit({ type: "key", name: "enter", ctrl: false, alt: true, shift: false });
|
|
129
130
|
else this.#emit({ type: "key", name: "char", key: next.toLowerCase(), text: next, ctrl: false, alt: true, shift: false });
|
|
130
131
|
i += 2;
|
|
131
|
-
} else {
|
|
132
|
+
} else {
|
|
133
|
+
// Lone ESC at end of buffer = the standalone Escape key. Terminals
|
|
134
|
+
// deliver escape sequences atomically, so a bare \x1b is never the
|
|
135
|
+
// head of a sequence that will arrive in a later chunk.
|
|
136
|
+
this.#emit({ type: "key", name: "escape", ctrl: false, alt: false, shift: false });
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
132
139
|
} else if (ch === "\r") {
|
|
133
140
|
this.#emit({ type: "key", name: "enter", ctrl: false, alt: false, shift: false });
|
|
134
141
|
i++;
|
package/src/views.js
CHANGED
|
@@ -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;
|
|
@@ -679,12 +680,20 @@ export class ChatView extends Widget {
|
|
|
679
680
|
}
|
|
680
681
|
}
|
|
681
682
|
|
|
683
|
+
/** Height of the collapsible todo block (0 when empty or collapsed). */
|
|
684
|
+
#todoHeight() {
|
|
685
|
+
const todos = this.app.todos;
|
|
686
|
+
if (!this.todosVisible || !todos || todos.length === 0) return 0;
|
|
687
|
+
return Math.min(todos.length, 6) + 1; // 1 header row + up to 6 todos
|
|
688
|
+
}
|
|
689
|
+
|
|
682
690
|
#inputChanged() {
|
|
683
691
|
// Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
|
|
684
692
|
const ih = this.input.height();
|
|
685
693
|
const prevIh = this.input.h;
|
|
686
694
|
this.input.h = ih;
|
|
687
|
-
|
|
695
|
+
const th = this.#todoHeight();
|
|
696
|
+
this.view.h = this.h - ih - th - 1;
|
|
688
697
|
this.input.y = this.y + this.h - ih;
|
|
689
698
|
if (ih !== prevIh) this.app.layout();
|
|
690
699
|
this.app.redraw();
|
|
@@ -694,7 +703,8 @@ export class ChatView extends Widget {
|
|
|
694
703
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
695
704
|
const ih = this.input.height();
|
|
696
705
|
this.input.h = ih;
|
|
697
|
-
|
|
706
|
+
const th = this.#todoHeight();
|
|
707
|
+
this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
|
|
698
708
|
this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
|
|
699
709
|
this.cache.clear();
|
|
700
710
|
this.#rebuild();
|
|
@@ -1107,10 +1117,27 @@ export class ChatView extends Widget {
|
|
|
1107
1117
|
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
1118
|
}
|
|
1109
1119
|
}
|
|
1120
|
+
this.#renderTodos(screen);
|
|
1110
1121
|
screen.hline(this.x, this.x + this.w - 1, this.input.y - 1, "─", { fg: T.BORDER2 });
|
|
1111
1122
|
this.input.render(screen);
|
|
1112
1123
|
}
|
|
1113
1124
|
|
|
1125
|
+
/** Collapsible todo block between the view and the input (Shift+T toggles). */
|
|
1126
|
+
#renderTodos(screen) {
|
|
1127
|
+
const th = this.#todoHeight();
|
|
1128
|
+
if (th === 0) return;
|
|
1129
|
+
const todos = this.app.todos ?? [];
|
|
1130
|
+
const y = this.input.y - th - 1;
|
|
1131
|
+
screen.fillRect(this.x, y, this.x + this.w - 1, y + th - 1, " ", { bg: T.THINKBG });
|
|
1132
|
+
screen.text(this.x, y, " ☐ 任务清单(Shift+T 折叠)", { fg: K.FAINT });
|
|
1133
|
+
for (let i = 0; i < Math.min(todos.length, th - 1); i++) {
|
|
1134
|
+
const t = todos[i];
|
|
1135
|
+
const icon = t.status === "completed" ? "✓" : t.status === "in_progress" ? "◉" : "○";
|
|
1136
|
+
const color = t.status === "completed" ? T.FAINT : t.status === "in_progress" ? T.WARN : T.DIM;
|
|
1137
|
+
screen.text(this.x + 2, y + 1 + i, `${icon} ${truncate(t.content ?? String(t), this.w - 6)}`, { fg: color });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1114
1141
|
onMouse(ev) {
|
|
1115
1142
|
if (this.input.inside(ev.x, ev.y)) {
|
|
1116
1143
|
// INSERT mode is keyboard-only; only position the cursor if already there.
|
|
@@ -1213,6 +1240,12 @@ export class ChatView extends Widget {
|
|
|
1213
1240
|
return true;
|
|
1214
1241
|
}
|
|
1215
1242
|
if (ev.name === "char" && ev.key === "t" && !ev.ctrl) {
|
|
1243
|
+
if (ev.shift) {
|
|
1244
|
+
this.todosVisible = !this.todosVisible;
|
|
1245
|
+
this.app.toast(this.todosVisible ? "任务块:显示(Shift+T 折叠)" : "任务块:折叠(Shift+T 展开)");
|
|
1246
|
+
this.#inputChanged();
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1216
1249
|
this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
|
|
1217
1250
|
this.expanded.clear();
|
|
1218
1251
|
this.collapsedBlocks.clear();
|
|
@@ -1227,12 +1260,16 @@ export class ChatView extends Widget {
|
|
|
1227
1260
|
function toolSummary(b) {
|
|
1228
1261
|
// Prefer the human description of what the tool did (the agent's `description`
|
|
1229
1262
|
// argument), then the host card title, then the raw command — mirrors the web.
|
|
1263
|
+
// File tools get a path-based summary so read/edit collapse as concisely as bash.
|
|
1230
1264
|
if (b.args) {
|
|
1231
1265
|
try {
|
|
1232
1266
|
const a = JSON.parse(b.args);
|
|
1233
1267
|
if (typeof a === "object" && a !== null) {
|
|
1234
|
-
const desc = a.description ?? a.summary ?? a.title ?? a.
|
|
1268
|
+
const desc = a.description ?? a.summary ?? a.title ?? a.query ?? a.content ?? a.name;
|
|
1235
1269
|
if (desc) return String(desc).slice(0, 120);
|
|
1270
|
+
if (a.file_path) return `read ${String(a.file_path)}`.slice(0, 120);
|
|
1271
|
+
if (a.path && a.command) return `${a.command} ${String(a.path)}`.slice(0, 120);
|
|
1272
|
+
if (a.path) return String(a.path).slice(0, 120);
|
|
1236
1273
|
return a.command ?? null;
|
|
1237
1274
|
}
|
|
1238
1275
|
return String(a).slice(0, 120);
|
|
@@ -1511,10 +1548,18 @@ export class App {
|
|
|
1511
1548
|
setJobs(jobs) { this.jobs = jobs; this.layout(); this.redraw(); }
|
|
1512
1549
|
|
|
1513
1550
|
#startPolling() {
|
|
1514
|
-
|
|
1551
|
+
// Self-rescheduling so the interval actually tracks run state (a fixed
|
|
1552
|
+
// setInterval would freeze at the idle 1500ms forever).
|
|
1553
|
+
let ticks = 0;
|
|
1554
|
+
const tick = () => {
|
|
1515
1555
|
if (this.chat.sessionId) this.chat.pollTail();
|
|
1516
|
-
|
|
1517
|
-
|
|
1556
|
+
// session.list is expensive (~100ms); refresh the sidebar every ~5s, not
|
|
1557
|
+
// on every streaming poll.
|
|
1558
|
+
if (ticks++ % 10 === 0) this.refreshSessions();
|
|
1559
|
+
const delay = this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500);
|
|
1560
|
+
this.pollTimer = setTimeout(tick, delay);
|
|
1561
|
+
};
|
|
1562
|
+
this.pollTimer = setTimeout(tick, 500);
|
|
1518
1563
|
}
|
|
1519
1564
|
|
|
1520
1565
|
async init() {
|
|
@@ -2007,9 +2052,6 @@ export class App {
|
|
|
2007
2052
|
tx += strWidth(seg);
|
|
2008
2053
|
}
|
|
2009
2054
|
if (this.currentSession == null) s.text(x + w - 16, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
|
|
2010
|
-
const modeLabel = this.focused === this.chat?.input ? " INSERT " : " NORMAL ";
|
|
2011
|
-
const modeColor = this.focused === this.chat?.input ? T.OK : T.FAINT;
|
|
2012
|
-
s.text(x + w - strWidth(modeLabel), 0, modeLabel, { fg: modeColor, bg: T.PANEL, attrs: 1 });
|
|
2013
2055
|
}
|
|
2014
2056
|
|
|
2015
2057
|
#clickTab(px) {
|
|
@@ -2135,6 +2177,19 @@ export class App {
|
|
|
2135
2177
|
}
|
|
2136
2178
|
// global keys
|
|
2137
2179
|
if (ev.type === "key") {
|
|
2180
|
+
// INSERT mode: Esc exits, everything else goes to the input for editing.
|
|
2181
|
+
// Global shortcuts (Ctrl+P, Ctrl+B, F7, …) are disabled here so plain
|
|
2182
|
+
// typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
|
|
2183
|
+
if (this.focused === this.chat.input) {
|
|
2184
|
+
if (ev.name === "escape") {
|
|
2185
|
+
this.focus(this.chat);
|
|
2186
|
+
this.toast("已退出输入(i 重新进入)");
|
|
2187
|
+
} else {
|
|
2188
|
+
this.chat.input.onKey(ev);
|
|
2189
|
+
}
|
|
2190
|
+
this.redraw();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2138
2193
|
if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
|
|
2139
2194
|
this.overlay = new ControlPanel(this, { startPage: 0 });
|
|
2140
2195
|
this.redraw();
|
|
@@ -2174,11 +2229,13 @@ export class App {
|
|
|
2174
2229
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
2175
2230
|
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
2176
2231
|
if (ev.name === "escape") {
|
|
2177
|
-
if (this.focused === this.chat.
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
this.
|
|
2181
|
-
|
|
2232
|
+
if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
|
|
2233
|
+
else {
|
|
2234
|
+
// Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
|
|
2235
|
+
const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
|
|
2236
|
+
if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
|
|
2237
|
+
else if (this.mode !== "chat") this.setMode("chat");
|
|
2238
|
+
}
|
|
2182
2239
|
return;
|
|
2183
2240
|
}
|
|
2184
2241
|
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|
|
@@ -2297,6 +2354,10 @@ export class App {
|
|
|
2297
2354
|
const rows = [];
|
|
2298
2355
|
// ── row 0: identity ──
|
|
2299
2356
|
const row0 = { left: [], right: [] };
|
|
2357
|
+
// Editing-mode badge first (near the input at the bottom): INSERT is loud,
|
|
2358
|
+
// NORMAL is subtle — the user's eyes are at the input line, not the top.
|
|
2359
|
+
const editing = this.focused === this.chat?.input;
|
|
2360
|
+
row0.left.push({ t: editing ? " INSERT " : " NORMAL ", fg: editing ? T.OK : T.FAINT, bg: T.STATUSBG, bold: editing });
|
|
2300
2361
|
// Left badge: the session's permission/mode (e.g. "工作区写入/创造模式"),
|
|
2301
2362
|
// which is far more meaningful than a static "工作区" label.
|
|
2302
2363
|
const perm = this.projections.permissions?.currentValue;
|
|
@@ -2372,11 +2433,14 @@ export class App {
|
|
|
2372
2433
|
if (this.menu) this.menu.render(s);
|
|
2373
2434
|
if (this.overlay) this.overlay.render(s);
|
|
2374
2435
|
if (this.toastMsg) {
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2436
|
+
// Toasts land in the LOWER half (just above the input/footer) where the
|
|
2437
|
+
// user's attention is while pressing shortcuts — a solid color block,
|
|
2438
|
+
// not a top-of-screen whisper.
|
|
2439
|
+
const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
|
|
2440
|
+
const x0 = Math.max(2, Math.floor((s.w - w) / 2));
|
|
2441
|
+
const y = Math.max(1, s.h - this.footerHeight() - 2);
|
|
2442
|
+
s.fillRect(x0 - 1, y - 1, x0 + w, y + 1, " ", { bg: T.ACCENT });
|
|
2443
|
+
s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
2380
2444
|
}
|
|
2381
2445
|
if (this.renameInput && this.popup) this.renameInput.render(s);
|
|
2382
2446
|
|
|
@@ -2385,6 +2449,11 @@ export class App {
|
|
|
2385
2449
|
if (this.overlay && typeof this.overlay.kittyTransmit === "function" && kittyCapable()) {
|
|
2386
2450
|
tail = this.overlay.kittyTransmit();
|
|
2387
2451
|
}
|
|
2452
|
+
// Native terminal caret: a blinking vertical bar (text-editor style) at
|
|
2453
|
+
// the focused input's cursor cell; hidden everywhere else.
|
|
2454
|
+
const cell = this.focused?.cursorCell;
|
|
2455
|
+
if (cell) tail = `\x1b[?25h\x1b[${cell.y + 1};${cell.x + 1}H` + tail;
|
|
2456
|
+
else tail = "\x1b[?25l" + tail;
|
|
2388
2457
|
this.term.output.write(out + tail);
|
|
2389
2458
|
}
|
|
2390
2459
|
|
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(""); }
|
|
@@ -401,6 +474,7 @@ export class Input extends Widget {
|
|
|
401
474
|
this.insert(ev.text);
|
|
402
475
|
return true;
|
|
403
476
|
case "enter":
|
|
477
|
+
if (ev.shift && this.multi) { this.insert("\n"); return true; } // Shift+Enter = newline
|
|
404
478
|
if (this.value.trim() === "" && !this.allowEmptyEnter) return false;
|
|
405
479
|
const v = this.value;
|
|
406
480
|
this.history.push(v);
|