dsh-neotui 0.0.33 → 0.1.1
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/md.js +15 -10
- package/src/panels.js +2 -0
- package/src/theme.js +1 -1
- package/src/views.js +140 -17
package/package.json
CHANGED
package/src/md.js
CHANGED
|
@@ -252,26 +252,31 @@ export function renderMd(text, width, sink = null, opts = {}) {
|
|
|
252
252
|
const lang = inCode || "text";
|
|
253
253
|
const hw = Math.max(2, width - 4);
|
|
254
254
|
const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// to the corner looked like a rendering artifact)
|
|
255
|
+
if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
|
|
256
|
+
// Fixed-width box: EVERY row is exactly (hw + 4) columns wide —
|
|
257
|
+
// top `┌…[复制]…┐`, content `│ … │`, bottom `└…┘` — so the corners
|
|
258
|
+
// always sit above the vertical bars, never above the code text.
|
|
260
259
|
const btn = "[复制]";
|
|
261
260
|
const btnW = strWidth(btn);
|
|
262
|
-
const
|
|
261
|
+
const inner = hw + 2; // columns between the corners
|
|
262
|
+
const leftLen = Math.max(1, inner - btnW - 2);
|
|
263
263
|
const tag = lang && lang !== "text" ? " " + lang : "";
|
|
264
|
-
if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
|
|
265
264
|
lines.push([
|
|
266
|
-
SEG("┌" + "─".repeat(
|
|
265
|
+
SEG("┌" + "─".repeat(leftLen) + " ", { fg: C.hr }),
|
|
267
266
|
SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
|
|
268
267
|
SEG(" ┐" + tag, { fg: C.hr }),
|
|
269
268
|
]);
|
|
270
269
|
for (const cl of codeLines) {
|
|
271
270
|
const hls = highlightLine(cl, lang);
|
|
272
|
-
|
|
271
|
+
for (const row of wrapSegs(hls, hw)) {
|
|
272
|
+
const rowW = strWidth(row.map((g) => g.t ?? "").join(""));
|
|
273
|
+
const segs = [SEG("│ ", { fg: C.hr }), ...row];
|
|
274
|
+
if (rowW < hw) segs.push(SEG(" ".repeat(hw - rowW)));
|
|
275
|
+
segs.push(SEG(" │", { fg: C.hr }));
|
|
276
|
+
lines.push(segs);
|
|
277
|
+
}
|
|
273
278
|
}
|
|
274
|
-
lines.push([SEG("└" + "─".repeat(
|
|
279
|
+
lines.push([SEG("└" + "─".repeat(inner) + "┘", { fg: C.hr })]);
|
|
275
280
|
inCode = null;
|
|
276
281
|
}
|
|
277
282
|
i++;
|
package/src/panels.js
CHANGED
|
@@ -1216,6 +1216,8 @@ export class ControlPanel extends Widget {
|
|
|
1216
1216
|
["n", "新建会话", () => this.app.newSession()],
|
|
1217
1217
|
["g g", "滚动到顶", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = 0; }],
|
|
1218
1218
|
["G", "滚动到底", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = this.app.chat.view.maxScroll(); }],
|
|
1219
|
+
["[", "上一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "[", text: "[", ctrl: false, alt: false, shift: false }); }],
|
|
1220
|
+
["]", "下一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "]", text: "]", ctrl: false, alt: false, shift: false }); }],
|
|
1219
1221
|
["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
|
|
1220
1222
|
["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
|
|
1221
1223
|
["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
|
package/src/theme.js
CHANGED
package/src/views.js
CHANGED
|
@@ -566,7 +566,7 @@ export class ChatView extends Widget {
|
|
|
566
566
|
this.expandedTools = new Set();
|
|
567
567
|
this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
|
|
568
568
|
this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
|
|
569
|
-
this.bashMode = "
|
|
569
|
+
this.bashMode = "collapsed"; // tool blocks: collapsed by default (b toggles)
|
|
570
570
|
this.todosVisible = true; // todo block above the input (Shift+T toggles)
|
|
571
571
|
this.todoSeen = false; // once seen, the todo box keeps its height
|
|
572
572
|
this.running = false;
|
|
@@ -784,8 +784,8 @@ export class ChatView extends Widget {
|
|
|
784
784
|
this.view.follow = true;
|
|
785
785
|
}
|
|
786
786
|
|
|
787
|
-
async loadOlder() {
|
|
788
|
-
if (!this.hasMore || this.loadingOlder || this.minSeq == null) return;
|
|
787
|
+
async loadOlder(onDone = null) {
|
|
788
|
+
if (!this.hasMore || this.loadingOlder || this.minSeq == null) { onDone?.(); return; }
|
|
789
789
|
this.loadingOlder = true;
|
|
790
790
|
this.app.setStatus("加载更早记录…");
|
|
791
791
|
try {
|
|
@@ -803,6 +803,51 @@ export class ChatView extends Widget {
|
|
|
803
803
|
}
|
|
804
804
|
this.loadingOlder = false;
|
|
805
805
|
this.#rebuild();
|
|
806
|
+
onDone?.();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** [ / ] — jump the viewport to the END of the previous (dir -1) or next
|
|
810
|
+
* (dir +1) user question. `previous` walks to the question entirely above
|
|
811
|
+
* the viewport top (loading older history first when needed); `next` walks
|
|
812
|
+
* to the first question that starts below the viewport top. */
|
|
813
|
+
#jumpQuestion(dir) {
|
|
814
|
+
this.flushRebuild(); // lineMap must reflect the current nodes array
|
|
815
|
+
const top = this.view.scrollY;
|
|
816
|
+
const qs = [];
|
|
817
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
818
|
+
if (this.nodes[i]?.kind !== "user") continue;
|
|
819
|
+
let first = -1, last = -1;
|
|
820
|
+
for (let li = 0; li < this.lineMap.length; li++) {
|
|
821
|
+
if (this.lineMap[li]?.nodeIdx === i) { if (first < 0) first = li; last = li; }
|
|
822
|
+
}
|
|
823
|
+
if (first < 0) continue; // node outside the rendered window
|
|
824
|
+
qs.push({ first, last });
|
|
825
|
+
}
|
|
826
|
+
let target = null;
|
|
827
|
+
if (dir < 0) {
|
|
828
|
+
for (let k = qs.length - 1; k >= 0; k--) {
|
|
829
|
+
if (qs[k].last < top) { target = qs[k]; break; }
|
|
830
|
+
}
|
|
831
|
+
if (!target && this.hasMore && this.minSeq != null) {
|
|
832
|
+
this.loadOlder(() => this.#jumpQuestion(-1)); // after the prepend, retry against the fuller window
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
target = qs.find((q) => q.first > top) ?? null;
|
|
837
|
+
}
|
|
838
|
+
if (!target) {
|
|
839
|
+
this.app.toast(dir < 0 ? "已到最早的问题" : "已到最后的问题");
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
// the question's last CONTENT line (skip trailing blank separator rows);
|
|
843
|
+
// put it ~3 rows into the viewport, never above the question's own start
|
|
844
|
+
let end = target.last;
|
|
845
|
+
while (end > target.first && !(this.lines[end] ?? []).some((g) => g.t.trim() !== "")) end--;
|
|
846
|
+
this.view.anchorLock = null;
|
|
847
|
+
this.view.follow = false;
|
|
848
|
+
this.view.scrollY = Math.max(0, Math.max(target.first, end - 3));
|
|
849
|
+
this.app.redraw();
|
|
850
|
+
return true;
|
|
806
851
|
}
|
|
807
852
|
|
|
808
853
|
onFrame(frame) {
|
|
@@ -897,7 +942,20 @@ export class ChatView extends Widget {
|
|
|
897
942
|
// the segment under the cursor at PRESS time (code-block [复制] hit test):
|
|
898
943
|
// re-resolving it at release would let a streaming rebuild move the line
|
|
899
944
|
const pressSeg = pressY !== null && pressY !== undefined && pressY >= 0 ? this.#segAtLine(pressY, pressX) : null;
|
|
900
|
-
|
|
945
|
+
// press-time BLOCK signature: the streaming tail re-derives between press
|
|
946
|
+
// and release (syncTail replaces the block objects), so the positional
|
|
947
|
+
// nodeIdx:blockIdx key can drift; the signature lets release re-locate
|
|
948
|
+
// the same block by content instead.
|
|
949
|
+
let pressSig = null;
|
|
950
|
+
if (info && info.blockIdx !== null) {
|
|
951
|
+
const b = this.nodes[info.nodeIdx]?.blocks?.[info.blockIdx];
|
|
952
|
+
if (b) pressSig = {
|
|
953
|
+
nodeId: this.nodes[info.nodeIdx]?.id ?? null,
|
|
954
|
+
kind: b.kind,
|
|
955
|
+
prefix: String(b.text ?? b.args ?? "").slice(0, 40),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig };
|
|
901
959
|
}
|
|
902
960
|
|
|
903
961
|
/** The line-segment under a screen x on a rendered line (for the code
|
|
@@ -924,9 +982,9 @@ export class ChatView extends Widget {
|
|
|
924
982
|
const ref = node?.images?.[info.imgIdx];
|
|
925
983
|
if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
|
|
926
984
|
}
|
|
927
|
-
|
|
985
|
+
let node = this.nodes[info.nodeIdx];
|
|
928
986
|
if (!node) return false;
|
|
929
|
-
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
|
|
987
|
+
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig } = ctx ?? this.#anchorCtx(info);
|
|
930
988
|
// code block [复制] button: copy the raw code, no toggle (hit identity
|
|
931
989
|
// and segment locked at press time)
|
|
932
990
|
if (pressSeg?.copyCode) {
|
|
@@ -934,6 +992,31 @@ export class ChatView extends Widget {
|
|
|
934
992
|
this.app.toast("已复制代码块");
|
|
935
993
|
return true;
|
|
936
994
|
}
|
|
995
|
+
// The stream re-derives between press and release: if the positional
|
|
996
|
+
// block no longer matches the press-time signature, re-locate the SAME
|
|
997
|
+
// block by kind + content prefix (node id when the node carries one).
|
|
998
|
+
if (pressSig && info.blockIdx !== null) {
|
|
999
|
+
const cur = node.blocks?.[info.blockIdx];
|
|
1000
|
+
const same = cur && cur.kind === pressSig.kind && String(cur.text ?? cur.args ?? "").slice(0, 40) === pressSig.prefix;
|
|
1001
|
+
if (!same) {
|
|
1002
|
+
let found = null;
|
|
1003
|
+
for (let ni = 0; ni < this.nodes.length && !found; ni++) {
|
|
1004
|
+
const n = this.nodes[ni];
|
|
1005
|
+
if (pressSig.nodeId && n?.id && n.id !== pressSig.nodeId) continue;
|
|
1006
|
+
for (let bi = 0; bi < (n?.blocks ?? []).length; bi++) {
|
|
1007
|
+
const b = n.blocks[bi];
|
|
1008
|
+
if (b.kind === pressSig.kind && String(b.text ?? b.args ?? "").slice(0, 40) === pressSig.prefix) {
|
|
1009
|
+
found = { nodeIdx: ni, blockIdx: bi };
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (found) {
|
|
1015
|
+
info = { ...info, ...found };
|
|
1016
|
+
node = this.nodes[found.nodeIdx];
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
937
1020
|
// formal text blocks are NOT collapsible: clicking them is a no-op
|
|
938
1021
|
if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
|
|
939
1022
|
return true;
|
|
@@ -1222,18 +1305,33 @@ export class ChatView extends Widget {
|
|
|
1222
1305
|
beginCard("CARD");
|
|
1223
1306
|
// FORMAL text output is NOT collapsible — the user's message
|
|
1224
1307
|
// content must stay readable; only think/tool blocks fold.
|
|
1225
|
-
//
|
|
1308
|
+
// The 🐳 marker distinguishes formal output from 💭 think and
|
|
1309
|
+
// ▸ tool blocks at a glance. Code blocks inside render as boxes
|
|
1310
|
+
// with a [复制] button.
|
|
1226
1311
|
const key = `${realIdx}:${bi}`;
|
|
1227
1312
|
const text = b.text ?? "";
|
|
1228
1313
|
const mdW = Math.max(10, w - 6 - strWidth(stepTag));
|
|
1229
1314
|
const sink = { codeBlocks: [] };
|
|
1230
1315
|
const md = renderMd(text, mdW, sink);
|
|
1316
|
+
const whale = { t: " 🐳", fg: K.ACCENT, bold: true };
|
|
1317
|
+
const step = { t: stepTag || " ", fg: K.FAINT };
|
|
1231
1318
|
if (md.length > 0) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1319
|
+
// When the message STARTS with a code box, the whale+step
|
|
1320
|
+
// marker gets its own line so the box's top border keeps the
|
|
1321
|
+
// same indent as its content rows (corners over bars, not
|
|
1322
|
+
// shifted right by the marker).
|
|
1323
|
+
const firstIsBoxTop = md[0].some((g) => g.copyCode);
|
|
1324
|
+
if (firstIsBoxTop) {
|
|
1325
|
+
lines.push([whale, step]);
|
|
1326
|
+
mark(realIdx, bi);
|
|
1327
|
+
for (const ln of md) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
|
|
1328
|
+
} else {
|
|
1329
|
+
lines.push([whale, step, ...md[0]]);
|
|
1330
|
+
mark(realIdx, bi);
|
|
1331
|
+
for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
|
|
1332
|
+
}
|
|
1235
1333
|
} else {
|
|
1236
|
-
lines.push([
|
|
1334
|
+
lines.push([whale, step]);
|
|
1237
1335
|
mark(realIdx, bi);
|
|
1238
1336
|
}
|
|
1239
1337
|
sep();
|
|
@@ -1487,6 +1585,11 @@ export class ChatView extends Widget {
|
|
|
1487
1585
|
return true;
|
|
1488
1586
|
}
|
|
1489
1587
|
if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.anchorLock = null; this.view.follow = true; this.view.scrollY = this.view.maxScroll(); return true; }
|
|
1588
|
+
// [ / ] — jump to the end of the previous / next user question. Guarded by
|
|
1589
|
+
// focus so the sidebar's session-move [ ] keys keep working there.
|
|
1590
|
+
if (ev.name === "char" && (ev.key === "[" || ev.key === "]") && !ev.ctrl && !ev.shift && this.app.focused === this) {
|
|
1591
|
+
return this.#jumpQuestion(ev.key === "[" ? -1 : 1);
|
|
1592
|
+
}
|
|
1490
1593
|
if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
|
|
1491
1594
|
if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
|
|
1492
1595
|
if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
|
|
@@ -1715,6 +1818,8 @@ export class App {
|
|
|
1715
1818
|
this.overlay = null; // Picker / Popup / ImagePopup modal
|
|
1716
1819
|
this.mode = "chat"; // chat | workspace | trajectory
|
|
1717
1820
|
this.sidebarVisible = true; // Ctrl+B hides the whole session pane (nvim-style)
|
|
1821
|
+
this.sidebarWidth = 30; // draggable divider: the session pane's column width
|
|
1822
|
+
this.draggingDivider = false;
|
|
1718
1823
|
this.feedbackMap = new Map(); // messageId → {rating, version}
|
|
1719
1824
|
this.searchQuery = null; // active find-in-conversation term (highlight)
|
|
1720
1825
|
this.findQuery = null; // term being typed in the find picker
|
|
@@ -1726,10 +1831,10 @@ export class App {
|
|
|
1726
1831
|
this.skillsPanel = null;
|
|
1727
1832
|
|
|
1728
1833
|
this.sidebar = new SidebarTree(this);
|
|
1729
|
-
this.sidebar.w =
|
|
1834
|
+
this.sidebar.w = this.sidebarWidth;
|
|
1730
1835
|
this.sidebar.h = screen.h - 1;
|
|
1731
|
-
this.searchInput = new Input({ x: 0, y: 0, w:
|
|
1732
|
-
this.chat = new ChatView({ x:
|
|
1836
|
+
this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
|
|
1837
|
+
this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
|
|
1733
1838
|
this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
|
|
1734
1839
|
this.focus(this.chat);
|
|
1735
1840
|
this.layout();
|
|
@@ -1742,11 +1847,12 @@ export class App {
|
|
|
1742
1847
|
}
|
|
1743
1848
|
|
|
1744
1849
|
layout() {
|
|
1745
|
-
const x = this.sidebarVisible ?
|
|
1850
|
+
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
1746
1851
|
const w = this.screen.w - x;
|
|
1747
1852
|
const footerH = this.footerHeight();
|
|
1748
1853
|
const mainH = this.screen.h - 1 - footerH;
|
|
1749
|
-
this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w =
|
|
1854
|
+
this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
|
|
1855
|
+
this.searchInput.w = this.sidebarWidth;
|
|
1750
1856
|
this.chat.resize(x, 1, w, mainH);
|
|
1751
1857
|
for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
|
|
1752
1858
|
if (p?.relayout) p.relayout(x, 1, w, mainH);
|
|
@@ -2608,7 +2714,7 @@ export class App {
|
|
|
2608
2714
|
}
|
|
2609
2715
|
|
|
2610
2716
|
// tab bar clicks (row 0 of the main area)
|
|
2611
|
-
if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ?
|
|
2717
|
+
if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
|
|
2612
2718
|
if (this.#clickTab(ev.x)) { this.redraw(); return; }
|
|
2613
2719
|
}
|
|
2614
2720
|
// mouse routes by position (click = focus + dispatch)
|
|
@@ -2626,6 +2732,23 @@ export class App {
|
|
|
2626
2732
|
// unhandled keys fall through to global shortcuts
|
|
2627
2733
|
}
|
|
2628
2734
|
if (ev.type === "mouse") {
|
|
2735
|
+
// Draggable sidebar divider: press on the boundary column (±1) starts a
|
|
2736
|
+
// resize; drag events (motion flag) update the width live.
|
|
2737
|
+
const divX = this.sidebarVisible ? this.sidebarWidth : -1;
|
|
2738
|
+
const onDivider = divX >= 0 && ev.y >= 1 && ev.x >= divX - 1 && ev.x <= divX + 1;
|
|
2739
|
+
if (ev.kind === "press" && ev.button === 0 && onDivider) {
|
|
2740
|
+
this.draggingDivider = true;
|
|
2741
|
+
this.redraw();
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (this.draggingDivider) {
|
|
2745
|
+
if (ev.kind === "drag" && ev.button === 0) {
|
|
2746
|
+
const nw = Math.max(14, Math.min(ev.x, Math.floor(this.screen.w * 0.6)));
|
|
2747
|
+
if (nw !== this.sidebarWidth) { this.sidebarWidth = nw; this.layout(); this.redraw(); }
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (ev.kind === "release" && ev.button === 0) { this.draggingDivider = false; this.redraw(); return; }
|
|
2751
|
+
}
|
|
2629
2752
|
// Pure mouse motion must never change focus/mode (vim-style: INSERT is
|
|
2630
2753
|
// keyboard-only). It reaches just the already-focused widget (drag select).
|
|
2631
2754
|
if (ev.motion) {
|