dsh-neotui 0.0.30 → 0.0.32
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 +4 -2
- package/src/md.js +14 -2
- package/src/views.js +45 -19
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-neotui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
6
|
+
"bin": {
|
|
7
|
+
"dsh-neotui": "bin/dsh-tui.js"
|
|
8
|
+
},
|
|
7
9
|
"exports": {
|
|
8
10
|
".": "./src/index.js",
|
|
9
11
|
"./package.json": "./package.json"
|
package/src/md.js
CHANGED
|
@@ -160,7 +160,7 @@ function highlightLine(line, lang) {
|
|
|
160
160
|
// ---- block renderer ----
|
|
161
161
|
// Returns array of lines; each line = array of segments.
|
|
162
162
|
|
|
163
|
-
export function renderMd(text, width) {
|
|
163
|
+
export function renderMd(text, width, sink = null) {
|
|
164
164
|
const lines = [];
|
|
165
165
|
const pushLine = (segs = []) => {
|
|
166
166
|
if (segs.length === 0) segs = [SEG(" ")];
|
|
@@ -246,7 +246,19 @@ export function renderMd(text, width) {
|
|
|
246
246
|
const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
|
|
247
247
|
let maxLen = Math.max(...codeLines.map((l) => strWidth(l)), 1);
|
|
248
248
|
const barW = Math.min(hw, maxLen + 2);
|
|
249
|
-
|
|
249
|
+
// web-style box with a click-to-copy button in the top-right corner;
|
|
250
|
+
// a fence WITHOUT a language gets no label (the bare "text" tag next
|
|
251
|
+
// to the corner looked like a rendering artifact)
|
|
252
|
+
const btn = "[复制]";
|
|
253
|
+
const btnW = strWidth(btn);
|
|
254
|
+
const topLen = Math.max(1, barW - btnW - 4);
|
|
255
|
+
const tag = lang && lang !== "text" ? " " + lang : "";
|
|
256
|
+
if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
|
|
257
|
+
lines.push([
|
|
258
|
+
SEG("┌" + "─".repeat(topLen) + " ", { fg: C.hr }),
|
|
259
|
+
SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
|
|
260
|
+
SEG(" ┐" + tag, { fg: C.hr }),
|
|
261
|
+
]);
|
|
250
262
|
for (const cl of codeLines) {
|
|
251
263
|
const hls = highlightLine(cl, lang);
|
|
252
264
|
lines.push([SEG("│ ", { fg: C.hr }), ...wrapSegs(hls, hw, { pad: true }).map((l) => l).flatMap((l, k) => (k === 0 ? l : [SEG(" ", { fg: C.hr }), ...l])), SEG(" │", { fg: C.hr })]);
|
package/src/views.js
CHANGED
|
@@ -594,6 +594,7 @@ export class ChatView extends Widget {
|
|
|
594
594
|
this.pressY = null;
|
|
595
595
|
this.pressInfo = null; // hit identity locked at press time
|
|
596
596
|
this.pressCtx = null;
|
|
597
|
+
this.pressX = null;
|
|
597
598
|
this.selStart = null;
|
|
598
599
|
this.selEnd = null;
|
|
599
600
|
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
@@ -871,7 +872,7 @@ export class ChatView extends Widget {
|
|
|
871
872
|
* shift the hit identity between press and release (the 4-line mystery:
|
|
872
873
|
* the rendered frame and the hit test were consistent, but the identity
|
|
873
874
|
* was re-resolved at release against a stream that had moved). */
|
|
874
|
-
#anchorCtx(info, pressY = null) {
|
|
875
|
+
#anchorCtx(info, pressY = null, pressX = null) {
|
|
875
876
|
const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
|
|
876
877
|
const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
|
|
877
878
|
let topFirst = -1;
|
|
@@ -893,7 +894,24 @@ export class ChatView extends Widget {
|
|
|
893
894
|
const preHeaderIdx = info ? firstNonEmpty(match) : -1;
|
|
894
895
|
const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
|
|
895
896
|
const pressRow = pressY !== null && pressY !== undefined && pressY >= 0 ? pressY - this.view.scrollY : null;
|
|
896
|
-
|
|
897
|
+
// the segment under the cursor at PRESS time (code-block [复制] hit test):
|
|
898
|
+
// re-resolving it at release would let a streaming rebuild move the line
|
|
899
|
+
const pressSeg = pressY !== null && pressY !== undefined && pressY >= 0 ? this.#segAtLine(pressY, pressX) : null;
|
|
900
|
+
return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** The line-segment under a screen x on a rendered line (for the code
|
|
904
|
+
* block's [复制] button hit test). */
|
|
905
|
+
#segAtLine(y, x) {
|
|
906
|
+
const line = this.lines[y];
|
|
907
|
+
if (!line || x == null || x < 0) return null;
|
|
908
|
+
let px = this.view.x;
|
|
909
|
+
for (const g of line) {
|
|
910
|
+
const w = strWidth(g.t ?? "");
|
|
911
|
+
if (x >= px && x < px + w) return g;
|
|
912
|
+
px += w;
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
897
915
|
}
|
|
898
916
|
|
|
899
917
|
/** Toggle the block/node under a line-mark, then re-anchor the viewport.
|
|
@@ -908,7 +926,18 @@ export class ChatView extends Widget {
|
|
|
908
926
|
}
|
|
909
927
|
const node = this.nodes[info.nodeIdx];
|
|
910
928
|
if (!node) return false;
|
|
911
|
-
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow } = ctx ?? this.#anchorCtx(info);
|
|
929
|
+
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
|
|
930
|
+
// code block [复制] button: copy the raw code, no toggle (hit identity
|
|
931
|
+
// and segment locked at press time)
|
|
932
|
+
if (pressSeg?.copyCode) {
|
|
933
|
+
this.app.copyText(pressSeg.copyCode);
|
|
934
|
+
this.app.toast("已复制代码块");
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
// formal text blocks are NOT collapsible: clicking them is a no-op
|
|
938
|
+
if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
912
941
|
let collapsing = false;
|
|
913
942
|
if (process.env.DSH_TUI_DEBUG_CLICK) {
|
|
914
943
|
this.#clickLog(`toggle mark=${JSON.stringify(info)} kind=${node.kind} blockIdx=${info.blockIdx} preHeaderRow=${preHeaderRow} topKey=${topKey} topOffset=${topOffset}`);
|
|
@@ -1181,27 +1210,20 @@ export class ChatView extends Widget {
|
|
|
1181
1210
|
sep();
|
|
1182
1211
|
} else {
|
|
1183
1212
|
beginCard("CARD");
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1213
|
+
// FORMAL text output is NOT collapsible — the user's message
|
|
1214
|
+
// content must stay readable; only think/tool blocks fold.
|
|
1215
|
+
// Code blocks inside render as boxes with a [复制] button.
|
|
1187
1216
|
const key = `${realIdx}:${bi}`;
|
|
1188
|
-
const open = !this.collapsedBlocks.has(key);
|
|
1189
1217
|
const text = b.text ?? "";
|
|
1190
|
-
const glyph = open ? "▾" : "▸";
|
|
1191
1218
|
const mdW = Math.max(10, w - 6 - strWidth(stepTag));
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
: renderMd(truncateText(text, 600), mdW).slice(0, 3);
|
|
1219
|
+
const sink = { codeBlocks: [] };
|
|
1220
|
+
const md = renderMd(text, mdW, sink);
|
|
1195
1221
|
if (md.length > 0) {
|
|
1196
|
-
lines.push([{ t: " " }, { t:
|
|
1222
|
+
lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
|
|
1197
1223
|
mark(realIdx, bi);
|
|
1198
1224
|
for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
|
|
1199
1225
|
} else {
|
|
1200
|
-
lines.push([{ t: " " +
|
|
1201
|
-
mark(realIdx, bi);
|
|
1202
|
-
}
|
|
1203
|
-
if (!open && text.length > 0) {
|
|
1204
|
-
lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
|
|
1226
|
+
lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
|
|
1205
1227
|
mark(realIdx, bi);
|
|
1206
1228
|
}
|
|
1207
1229
|
sep();
|
|
@@ -1365,7 +1387,8 @@ export class ChatView extends Widget {
|
|
|
1365
1387
|
// between press and release, so resolving the block at release would
|
|
1366
1388
|
// toggle a block a few lines away from the one the user saw.
|
|
1367
1389
|
this.pressInfo = this.lineMap?.[this.pressY] ?? null;
|
|
1368
|
-
this.
|
|
1390
|
+
this.pressX = ev.x;
|
|
1391
|
+
this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY, ev.x) : null;
|
|
1369
1392
|
if (process.env.DSH_TUI_DEBUG_CLICK) {
|
|
1370
1393
|
const t = this.lines[this.pressY]?.map((g) => g.t).join("") ?? "";
|
|
1371
1394
|
this.#clickLog(`press screenY=${ev.y} screenX=${ev.x} lineIdx=${this.pressY} mark=${JSON.stringify(this.pressInfo)} text="${t.slice(0, 40)}" scrollY=${this.view.scrollY} viewY=${this.view.y} viewH=${this.view.h} assumedH=${this.app.screen?.h} assumedW=${this.app.screen?.w} ttyRows=${process.stdout.rows} ttyCols=${process.stdout.columns} inputY=${this.input.y} inputH=${this.input.h} todoH=${this.todoHeight()} footerH=${this.app.footerHeight?.() ?? "?"}`);
|
|
@@ -1407,8 +1430,11 @@ export class ChatView extends Widget {
|
|
|
1407
1430
|
const node = this.nodes[info.nodeIdx];
|
|
1408
1431
|
const items = [
|
|
1409
1432
|
{ label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
|
|
1410
|
-
{ label: "展开 / 折叠", action: () => this.#toggleAt(info) },
|
|
1411
1433
|
];
|
|
1434
|
+
const clickedBlock = node?.kind === "assistant" && info.blockIdx !== null ? node.blocks[info.blockIdx] : null;
|
|
1435
|
+
if (clickedBlock?.kind !== "text") {
|
|
1436
|
+
items.push({ label: "展开 / 折叠", action: () => this.#toggleAt(info) });
|
|
1437
|
+
}
|
|
1412
1438
|
if (node?.id) {
|
|
1413
1439
|
items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
|
|
1414
1440
|
}
|