dsh-neotui 0.0.18 → 0.0.20
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/views.js +79 -27
package/package.json
CHANGED
package/src/views.js
CHANGED
|
@@ -590,6 +590,8 @@ export class ChatView extends Widget {
|
|
|
590
590
|
this.cardRanges = []; // absolute line ranges of card-backed message blocks
|
|
591
591
|
this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
|
|
592
592
|
this.pressY = null;
|
|
593
|
+
this.pressInfo = null; // hit identity locked at press time
|
|
594
|
+
this.pressCtx = null;
|
|
593
595
|
this.selStart = null;
|
|
594
596
|
this.selEnd = null;
|
|
595
597
|
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
@@ -817,7 +819,8 @@ export class ChatView extends Widget {
|
|
|
817
819
|
send(text) {
|
|
818
820
|
if (!this.sessionId) return;
|
|
819
821
|
const trimmed = text.trim();
|
|
820
|
-
if (trimmed === "/reload") { this.app.
|
|
822
|
+
if (trimmed === "/reload") { this.app.softReload(); return; }
|
|
823
|
+
if (trimmed === "/restart") { this.app.restartApp(); return; }
|
|
821
824
|
if (!trimmed) return;
|
|
822
825
|
const { parts, images, errors } = buildPromptParts(trimmed, {
|
|
823
826
|
readFile: (p) => {
|
|
@@ -844,20 +847,13 @@ export class ChatView extends Widget {
|
|
|
844
847
|
return this.#toggleAt(this.lineMap?.[y]);
|
|
845
848
|
}
|
|
846
849
|
|
|
847
|
-
/**
|
|
848
|
-
*
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
|
|
855
|
-
}
|
|
856
|
-
const node = this.nodes[info.nodeIdx];
|
|
857
|
-
if (!node) return false;
|
|
858
|
-
// anchor context captured from the frame the user saw:
|
|
859
|
-
// - the clicked block's header line + its viewport row (primary anchor)
|
|
860
|
-
// - the viewport-top line's identity + offset (fallback for deep clicks)
|
|
850
|
+
/** Anchor context for a click: the clicked block's header line + its
|
|
851
|
+
* viewport row (primary anchor) and the viewport-top identity + offset
|
|
852
|
+
* (fallback for deep clicks). Captured at PRESS time so the stream cannot
|
|
853
|
+
* shift the hit identity between press and release (the 4-line mystery:
|
|
854
|
+
* the rendered frame and the hit test were consistent, but the identity
|
|
855
|
+
* was re-resolved at release against a stream that had moved). */
|
|
856
|
+
#anchorCtx(info) {
|
|
861
857
|
const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
|
|
862
858
|
const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
|
|
863
859
|
let topFirst = -1;
|
|
@@ -867,23 +863,39 @@ export class ChatView extends Widget {
|
|
|
867
863
|
}
|
|
868
864
|
}
|
|
869
865
|
const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
|
|
870
|
-
const match = info
|
|
871
|
-
? (m) => m?.nodeIdx === info
|
|
872
|
-
: (m) => m?.nodeIdx === info
|
|
866
|
+
const match = info?.blockIdx !== null
|
|
867
|
+
? (m) => m?.nodeIdx === info?.nodeIdx && m?.blockIdx === info?.blockIdx
|
|
868
|
+
: (m) => m?.nodeIdx === info?.nodeIdx;
|
|
873
869
|
const firstNonEmpty = (m) => {
|
|
874
870
|
for (let i = 0; i < this.lineMap.length; i++) {
|
|
875
871
|
if (m(this.lineMap[i]) && (this.lines[i] ?? []).some((g) => g.t.trim() !== "")) return i;
|
|
876
872
|
}
|
|
877
873
|
return -1;
|
|
878
874
|
};
|
|
879
|
-
const preHeaderIdx = firstNonEmpty(match);
|
|
875
|
+
const preHeaderIdx = info ? firstNonEmpty(match) : -1;
|
|
880
876
|
const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
|
|
877
|
+
return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Toggle the block/node under a line-mark, then re-anchor the viewport.
|
|
881
|
+
* `info` must be the mark of the frame the user clicked on; `ctx` its
|
|
882
|
+
* press-time anchor context. */
|
|
883
|
+
#toggleAt(info, ctx = null) {
|
|
884
|
+
if (!info) return false;
|
|
885
|
+
if (info.imgIdx !== undefined) {
|
|
886
|
+
const node = this.nodes[info.nodeIdx];
|
|
887
|
+
const ref = node?.images?.[info.imgIdx];
|
|
888
|
+
if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
|
|
889
|
+
}
|
|
890
|
+
const node = this.nodes[info.nodeIdx];
|
|
891
|
+
if (!node) return false;
|
|
892
|
+
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow } = ctx ?? this.#anchorCtx(info);
|
|
881
893
|
const reanchor = () => {
|
|
882
894
|
// primary anchor: the clicked block's header stays at its pre-click
|
|
883
895
|
// viewport row (zero shift) — only when the header was ON-SCREEN.
|
|
884
896
|
// When the fold removed the tail content below, the anchored position
|
|
885
897
|
// may exceed maxScroll: hold it via the view's anchorLock instead of
|
|
886
|
-
// clamping (the clamp is exactly the
|
|
898
|
+
// clamping (the clamp is exactly the slide the user sees).
|
|
887
899
|
if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
|
|
888
900
|
const h2 = firstNonEmpty(match);
|
|
889
901
|
if (h2 >= 0) {
|
|
@@ -1314,6 +1326,11 @@ export class ChatView extends Widget {
|
|
|
1314
1326
|
if (ev.kind === "wheel-up" && this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; }
|
|
1315
1327
|
if (ev.kind === "press" && ev.button === 0) {
|
|
1316
1328
|
this.pressY = ev.y - this.view.y + this.view.scrollY;
|
|
1329
|
+
// LOCK the hit identity at press time: the stream keeps growing
|
|
1330
|
+
// between press and release, so resolving the block at release would
|
|
1331
|
+
// toggle a block a few lines away from the one the user saw.
|
|
1332
|
+
this.pressInfo = this.lineMap?.[this.pressY] ?? null;
|
|
1333
|
+
this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo) : null;
|
|
1317
1334
|
return true;
|
|
1318
1335
|
}
|
|
1319
1336
|
if (ev.kind === "drag" && ev.button === 0 && this.pressY !== null) {
|
|
@@ -1329,6 +1346,7 @@ export class ChatView extends Widget {
|
|
|
1329
1346
|
const wasPress = this.pressY;
|
|
1330
1347
|
this.pressY = null;
|
|
1331
1348
|
if (this.selStart !== null && this.selEnd !== null) {
|
|
1349
|
+
this.pressInfo = null; this.pressCtx = null;
|
|
1332
1350
|
const text = this.lines.slice(this.selStart, this.selEnd + 1).map((l) => l.map((g) => g.t).join("")).join("\n");
|
|
1333
1351
|
const rows = this.selEnd - this.selStart + 1;
|
|
1334
1352
|
this.selStart = this.selEnd = null;
|
|
@@ -1337,7 +1355,9 @@ export class ChatView extends Widget {
|
|
|
1337
1355
|
return true;
|
|
1338
1356
|
}
|
|
1339
1357
|
this.selStart = this.selEnd = null;
|
|
1340
|
-
this.#
|
|
1358
|
+
this.#toggleAt(this.pressInfo, this.pressCtx);
|
|
1359
|
+
this.pressInfo = null;
|
|
1360
|
+
this.pressCtx = null;
|
|
1341
1361
|
return true;
|
|
1342
1362
|
}
|
|
1343
1363
|
if (ev.kind === "press" && ev.button === 2) {
|
|
@@ -2194,17 +2214,49 @@ export class App {
|
|
|
2194
2214
|
showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
|
|
2195
2215
|
showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
|
|
2196
2216
|
|
|
2197
|
-
/** /reload:
|
|
2198
|
-
*
|
|
2199
|
-
*
|
|
2200
|
-
|
|
2201
|
-
|
|
2217
|
+
/** /reload: in-place soft reload — fresh session list, fresh chat history,
|
|
2218
|
+
* panels rebuilt, screen re-rendered. No process churn, no terminal
|
|
2219
|
+
* handoff (the old process-restart approach leaked mouse bytes into the
|
|
2220
|
+
* boot of the new instance). */
|
|
2221
|
+
async softReload() {
|
|
2222
|
+
this.closeOverlay();
|
|
2223
|
+
this.menu = null;
|
|
2224
|
+
this.popup = null;
|
|
2225
|
+
for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
|
|
2226
|
+
if (p?.dispose) { try { p.dispose(); } catch {} }
|
|
2227
|
+
}
|
|
2228
|
+
this.workspacePanel = this.trajectoryPanel = this.settingsPanel = this.subagentPanel = this.skillsPanel = null;
|
|
2229
|
+
this.chat.cache.clear();
|
|
2230
|
+
this.chat.nodes = [];
|
|
2231
|
+
this.chat.collapsedBlocks.clear();
|
|
2232
|
+
this.chat.expanded.clear();
|
|
2233
|
+
this.chat.expandedTools.clear();
|
|
2234
|
+
this.chat.queueRebuild();
|
|
2235
|
+
this.chat.view.anchorLock = null;
|
|
2236
|
+
this.mode = "chat";
|
|
2237
|
+
this.focus(this.chat);
|
|
2238
|
+
this.toast("正在重新加载…");
|
|
2239
|
+
await this.refreshSessions();
|
|
2240
|
+
if (this.currentSession) await this.openSession(this.currentSession);
|
|
2241
|
+
else await this.newSessionIn(null);
|
|
2242
|
+
this.layout();
|
|
2243
|
+
this.redraw();
|
|
2244
|
+
this.toast("已重新加载会话与界面");
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/** /restart: restart the TUI process in the same terminal so a freshly
|
|
2248
|
+
* published build (the profile symlinks the repo) takes effect. The new
|
|
2249
|
+
* instance starts one second after this process restores the terminal —
|
|
2250
|
+
* no stray mouse/keyboard bytes leak into its boot. */
|
|
2251
|
+
async restartApp() {
|
|
2252
|
+
this.toast("正在重启 TUI(加载新版本代码)…");
|
|
2202
2253
|
this.redraw();
|
|
2203
2254
|
await new Promise((r) => setTimeout(r, 250));
|
|
2204
2255
|
try { this.term?.stop?.(); } catch {}
|
|
2205
2256
|
try {
|
|
2206
2257
|
const { spawn } = await import("node:child_process");
|
|
2207
|
-
const
|
|
2258
|
+
const argv = process.argv.slice(1);
|
|
2259
|
+
const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit" });
|
|
2208
2260
|
child.unref();
|
|
2209
2261
|
} catch (e) {
|
|
2210
2262
|
this.toast(`重启失败: ${e.message}(请手动重启)`);
|