dsh-turn-navigator 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-19
4
+
5
+ ### Added
6
+
7
+ - Initial release: piano-key turn navigation rail for DSH web conversations.
8
+ - A vertical rail of grey capsules floats on the right edge of the conversation — one capsule per turn (registered into `conversation.session.header.utilities`; `position: fixed`).
9
+ - The rail is **height-capped (60vh) and scrolls internally**, so a very long conversation never pushes it past the viewport.
10
+ - Wave hover: hovering a capsule makes it glow with the **theme's primary label color** and widen to 150% (the two neighbours widen to 125%), sliding across the rail like a wave. Widening uses transform:scaleX, so it never reflows or adds a horizontal scrollbar.
11
+ - Native DSH tooltip on hover showing the turn's index, timestamp, and user-message summary (multi-line, `\n`-separated).
12
+ - Click-to-jump: scrolls the conversation flow to the turn's first node and briefly highlights it — computed via the scrollport's `scrollTop` and the `data-chat-anchor-key` DOM anchor.
13
+ - **Stall-free auto-load**: the rail slowly fills its visible height after open (slow cadence, back-pressure, page cap), then keeps loading earlier turns while scrolled near its top — scroll-driven, like the conversation itself. A shared busy-lock prevents concurrent paging clicks.
14
+ - **Custom tooltip bubble** (DSH visual style, multi-line) anchored to the **left** of the rail — rendered via a React portal to `document.body` so it stays viewport-fixed (a transform on the wrapper would otherwise misplace it), vertically centered on the hovered capsule, and clamped so the whole bubble stays inside the viewport even when the summary is long.
15
+ - **Auto-sizing rail**: its length grows with the turn count, capped at **30vh**; scrolls internally (hidden scrollbar) once the cap is hit.
16
+ - **Up/down scroll buttons**: click to scroll ~1 viewport-height, and **hover-hold auto-scrolls** continuously; each is greyed out (disabled) when there is nothing to scroll in that direction.
17
+ - **Center-on-activate**: clicking a capsule scrolls it to the center of the rail, unless it is the first or last turn (which sit at the edges).
18
+ - Wave widening is **right-origin** (`transform-origin: right`), so capsules grow LEFTWARD only — the right edge never moves.
19
+ - Jump retry: if a target turn's row is not yet rendered (older page not loaded), the jump auto-clicks "Load earlier" (recognizing the idle and in-flight labels) and retries until the row appears — no "scroll once first" friction.
20
+ - Bilingual README (en/zh), CHANGELOG, CONTRIBUTING, LICENSE, release-check + post-publish-check scripts, Playwright rail verification script.
21
+
22
+ ### Changed
23
+
24
+ - Replaced the earlier drawer-based design (header trigger pill + `shell.overlay` drawer + module-level relay bridge) with the single session-scoped rail, which reads `useSession` directly — simpler and one additive slot instead of two.
25
+ - Wave hover changed from height growth to **width widening** via transform:scaleX (150% / 125%) — no reflow, no horizontal scrollbar, no layout drift while sliding. Rail is now pointer-events:auto (wheel scrolls anywhere on it, including the gaps) with overflow-x:hidden. Hover color follows the theme (primary label token).
@@ -0,0 +1,30 @@
1
+ # Contributing
2
+
3
+ ## Development workflow
4
+
5
+ ```sh
6
+ pnpm install
7
+ pnpm typecheck # tsc --noEmit
8
+ pnpm bundle # tsdown build → lib/index.js + lib/client.js
9
+ ```
10
+
11
+ ## Release workflow
12
+
13
+ Every release must be done in one pass: code + bilingual README + CHANGELOG + version + tag.
14
+
15
+ 1. Make your changes.
16
+ 2. Update `README.md`, `README.zh.md` (same `##`/`###` section counts), and `CHANGELOG.md`.
17
+ 3. Bump `package.json` version.
18
+ 4. `git commit && git tag v<version> && git push --tags`.
19
+ 5. `pnpm release:check` — verifies version, docs, changelog, tag, tree, build, registry.
20
+ 6. `npm pack --dry-run` — confirm tarball contents.
21
+ 7. `npm publish` (requires 2FA; the agent cannot do this step).
22
+ 8. `postpublish` runs `scripts/post-publish-check.mjs` automatically.
23
+
24
+ ## npm publish is manual (2FA)
25
+
26
+ npm accounts with two-factor authentication require an OTP that the agent cannot provide. The agent prepares everything to "one command to publish"; the human runs `npm login` → `npm publish`.
27
+
28
+ ## Post-release release-check "failures" are expected
29
+
30
+ After a version is published, `release:check` will fail because the version is already on npm and the tag no longer points at HEAD. This is by design — it protects already-published versions. Bump the version and tag a new release instead.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 karoc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # dsh-turn-navigator
2
+
3
+ An external [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that adds a **piano-key turn rail** to the conversation interface — a vertical column of tiny capsules on the right edge of the conversation, one per turn, so you can see every turn at a glance, hover to preview it, and click to jump to its start.
4
+
5
+ ## Why
6
+
7
+ In the default DSH web UI, finding a specific turn in a long conversation means scrolling — a lot. There's no overview of how many turns happened, what each turn was about, or where the current scroll position sits. `dsh-turn-navigator` solves this with a minimap-style rail:
8
+
9
+ - A **vertical capsule per turn** floats on the right edge of the conversation (grey, ~3px tall, piano-key style).
10
+ - **Hovering** a capsule makes it glow with the theme's primary label color and widen to 150% (the two neighbours widen to 125% too), so sliding across the rail ripples like a wave — and shows the full turn info (index, timestamp, user-message summary) in a native DSH tooltip.
11
+ - **Clicking** a capsule scrolls the conversation to that turn's start and briefly highlights it.
12
+ - **Older history auto-loads**: the conversation paginates its window; the plugin keeps loading earlier pages so every turn is reachable, with no manual "Load earlier" clicks.
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ dsh plugin --profile web add dsh-turn-navigator
18
+ ```
19
+
20
+ Then restart `dsh web`:
21
+
22
+ ```sh
23
+ dsh web
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ 1. Open any conversation with at least one completed turn.
29
+ 2. A vertical rail of grey capsules appears on the right edge of the conversation (one capsule per turn). The rail **auto-sizes**: its length grows with the turn count, capped at **30vh** — a short conversation gets a short rail, a long one hits the cap and scrolls internally with a **hidden scrollbar** (no layout jitter). **Up/down scroll buttons** at its top and bottom support both click and **hover-hold auto-scroll**, and are greyed out when there is nothing to scroll in that direction — so the rail never stretches past the viewport, and you can wheel, click, or hold to move through the turns.
30
+ 3. Hover a capsule to see the turn's index, timestamp, and user-message summary in a DSH-style tooltip anchored to the left of the rail, vertically centered on the hovered capsule and always fully inside the viewport. The capsule glows with the theme color and widens 150% LEFTWARDS (right-aligned — the right edge never moves), with the two neighbours widening a little too, a wave ripple across the rail.
31
+ 4. Click a capsule to jump to that turn's start (the target row briefly highlights); the activated capsule scrolls to the center of the rail unless it is the first or last turn.
32
+ 5. Older history loads automatically — the rail fills its visible height first, and scrolling the rail to its top keeps loading earlier turns. No manual "Load earlier" clicks needed.
33
+
34
+ ## How it works
35
+
36
+ The plugin registers **one additive slot** — **no DSH source code is modified**:
37
+
38
+ | Slot | Scope | Role |
39
+ |------|-------|------|
40
+ | `conversation.session.header.utilities` | session | The floating turn rail; reads `useSession` directly |
41
+
42
+ Because the rail is session-scoped, it reads the live `ConversationSnapshot` straight from the framework `useSession` kit and renders as `position: fixed` (so it does not occupy the header's flex row).
43
+
44
+ - **Turn extraction**: `chat.timeline.turnOrder` + `turns` map for boundaries; `chat.locations.getTurn(turn)` for each turn's node keys; the first `kind === 'user'` node's first text block for the summary; `turnTimings` for the timestamp. Turns without a user message fall back to their first node's kind.
45
+ - **Jump-to-turn**: locate the turn's first chat-node key, find the DOM row via `data-chat-anchor-key="<key>"`, compute its position in the `[data-conversation-scroll]` scrollport, and set `scrollTop` precisely (more predictable than `scrollIntoView`). If the target row is not yet rendered (older page not loaded), it auto-clicks the "Load earlier" button and retries until the row appears — no "scroll once first" friction.
46
+ - **Auto-load (stall-free)**: the conversation paginates its history — each page prepend re-renders the whole flow, so loading everything at once would stall the UI. The rail instead (a) slowly fills its visible height after open (slow cadence, back-pressure, page cap), then (b) keeps loading earlier turns while the rail is scrolled near its top — scroll-driven, the same way the conversation itself loads. Clicking a capsule also loads on demand to reach its turn. A shared busy-lock prevents the auto-load and scroll-load paths from ever clicking the paging button concurrently.
47
+
48
+ ## Compatibility
49
+
50
+ - DeepSeek Harness (dsh) with the web client (`dsh web`).
51
+ - Requires the `conversation.session.header.utilities` slot declaration (present in current DSH).
52
+
53
+ ## License
54
+
55
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,55 @@
1
+ # dsh-turn-navigator
2
+
3
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 外部插件,为会话界面增加**钢琴键式轮次胶囊条**——在会话界面右侧悬浮一条竖向胶囊列,每轮一个小胶囊,让你一眼纵览整场会话有哪几轮,悬停预览,点击跳转到任意轮次起始点。
4
+
5
+ ## 为什么需要
6
+
7
+ DSH Web UI 中,在长会话里找某一轮只能靠滚动——没有轮次概览,不知道有几轮、每轮说了什么、当前滚到哪里。`dsh-turn-navigator` 用类似 minimap 的胶囊条解决这个问题:
8
+
9
+ - 会话界面右侧**悬浮一条竖向胶囊列**(每轮一个灰色小胶囊,约 3px 高,钢琴键式)。
10
+ - **悬停**某个胶囊:该胶囊以**主题色**亮起并加宽 150%,相邻两个胶囊也稍微加宽(125%)——鼠标滑过时像波浪一样起伏;同时以 DSH 原生 Tooltip 完整展示该轮信息(序号、时间戳、用户消息摘要)。
11
+ - **点击**某个胶囊:会话滚动到该轮起点并短暂高亮。
12
+ - **历史自动加载**:会话历史是分页的,插件自动持续加载更早的页面,直到全部轮次可定位,无需手动点击"加载更早"。
13
+
14
+ ## 安装
15
+
16
+ ```sh
17
+ dsh plugin --profile web add dsh-turn-navigator
18
+ ```
19
+
20
+ 然后重启 `dsh web`:
21
+
22
+ ```sh
23
+ dsh web
24
+ ```
25
+
26
+ ## 使用
27
+
28
+ 1. 打开任意有至少一轮完成的会话。
29
+ 2. 会话界面右侧出现一条竖向灰色胶囊列(每轮一个胶囊)。胶囊条**长度自适应**:随轮次多少自动增减,**上限 30vh**——轮次少时胶囊条短,轮次多时达到上限后内部滚动;滚动条隐藏(避免抖动)。上下各有一个**滚动按钮**:支持点击和**悬停持续自动滚动**,没有可滚动内容时自动置灰——轮次再多也不会超出浏览器范围。
30
+ 3. 悬停某个胶囊:胶囊**以主题色亮起并向左加宽 150%**(右对齐,右缘不动;相邻两个胶囊也稍微加宽——滑过时形成波浪波纹),胶囊条左侧弹出 DSH 风格 Tooltip,**垂直居中于悬停的胶囊**、始终完整在视口内,展示完整信息(序号、时间戳、用户消息摘要)。
31
+ 4. 点击某个胶囊:会话滚动到该轮起点(目标行短暂高亮),**该胶囊自动滚动到胶囊条中央**(首尾两条除外)。
32
+ 5. 更早的历史会自动加载——胶囊条先填充自身可视高度,**滚动胶囊条到顶部继续加载更早轮次**。无需手动点击"加载更早"。
33
+
34
+ ## 原理
35
+
36
+ 插件只注册**一个加法 slot**——**不修改 DSH 源码**:
37
+
38
+ | Slot | 作用域 | 职责 |
39
+ |------|--------|------|
40
+ | `conversation.session.header.utilities` | session | 悬浮轮次胶囊条;直接通过 `useSession` 读取 |
41
+
42
+ 因为胶囊条是 session 作用域,它直接从框架 `useSession` kit 读取实时 `ConversationSnapshot`,并以 `position: fixed` 渲染(不占据 header 的 flex 行)。
43
+
44
+ - **轮次提取**:`chat.timeline.turnOrder` + `turns` map 得到轮次边界;`chat.locations.getTurn(turn)` 取该轮 node keys;首个 `kind === 'user'` 节点的首个 text block 作摘要;`turnTimings` 取时间戳。无用户消息的轮次降级显示其首个节点的 kind。
45
+ - **跳转定位**:取该轮第一个 chat-node key,通过 `data-chat-anchor-key="<key>"` 找到 DOM 行,在 `[data-conversation-scroll]` 滚动容器中精确计算并设置 `scrollTop`(比 `scrollIntoView` 更可控)。若目标行尚未渲染(更早页面未加载),自动点击"加载更早"按钮并重试直到行出现——无需先滚动一下。
46
+ - **自动加载(不卡顿)**:会话历史是分页的,每页 prepend 都会重渲染整个会话流,一次性全加载会卡住界面。胶囊条改为:(a) 打开后以缓慢节奏(背压 + 页数上限)先填充自身可视高度;(b) 之后**滚动胶囊条到顶部时继续加载更早轮次**(滚动驱动,与会话自身加载方式一致)。点击胶囊跳转时也会按需加载到目标轮。自动加载与滚动加载通过共享 busy 锁互斥,绝不并发点击分页按钮。
47
+
48
+ ## 兼容性
49
+
50
+ - DeepSeek Harness (dsh) Web 客户端(`dsh web`)。
51
+ - 需要 `conversation.session.header.utilities` slot 声明(当前 DSH 已包含)。
52
+
53
+ ## 许可证
54
+
55
+ MIT
@@ -0,0 +1,9 @@
1
+ # Bundle layer applied when a profile lists this package. The row names the
2
+ # package by name so Node resolution finds the installed code; its browser half
3
+ # (exports["./client"] + dsh.client manifest) is discovered by dsh web's
4
+ # client-modules service and served at /plugins/dsh-turn-navigator/client.js.
5
+ # The host half is an empty apply (no tools/routes); the client half registers
6
+ # the session-header trigger and the shell.overlay drawer.
7
+ - insert:
8
+ - id: dsh-turn-navigator
9
+ name: dsh-turn-navigator
package/lib/client.js ADDED
@@ -0,0 +1,675 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-turn-navigator",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_dom = require("react-dom");
9
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
+ let react_jsx_runtime = require("react/jsx-runtime");
11
+ //#region src/client/turns.ts
12
+ const SUMMARY_MAX_CHARS = 80;
13
+ /**
14
+ * Extract the text of the first `type: 'text'` content block from a node's
15
+ * content array.
16
+ */
17
+ function firstText(content) {
18
+ if (content === void 0) return "";
19
+ for (const block of content) if (block.type === "text" && typeof block.text === "string") return block.text;
20
+ return "";
21
+ }
22
+ /**
23
+ * Derive the flat turn list from a conversation snapshot.
24
+ *
25
+ * For each turn in `timeline.turnOrder`, the function looks up the turn's
26
+ * chat-node keys via `locations.getTurn`, finds the first node whose `kind`
27
+ * is `'user'`, and extracts the text summary from its content blocks. The
28
+ * timestamp comes from `turnTimings` (preferred) or `turn.start.time`.
29
+ *
30
+ * @param snap - the conversation snapshot (structural subset).
31
+ * @returns ordered turn entries (empty when the snapshot has no turns).
32
+ */
33
+ function extractTurns(snap) {
34
+ if (snap === void 0) return [];
35
+ const chat = snap.chat;
36
+ const timeline = chat.timeline;
37
+ const turnOrder = timeline.turnOrder;
38
+ if (turnOrder.length === 0) return [];
39
+ const entries = [];
40
+ let displayIndex = 0;
41
+ for (const turn of turnOrder) {
42
+ displayIndex += 1;
43
+ const loc = timeline.turns.get(turn);
44
+ const status = loc?.status ?? "unknown";
45
+ const startTime = snap.turnTimings.get(turn)?.startTime ?? loc?.start?.time;
46
+ let summary = "";
47
+ let fullText = "";
48
+ const keys = chat.locations.getTurn(turn);
49
+ for (const key of keys) {
50
+ const node = chat.nodes.get(key);
51
+ if (node === void 0) continue;
52
+ if (node.kind === "user") {
53
+ const userData = node.data;
54
+ if (userData !== void 0) {
55
+ fullText = firstText(userData.content);
56
+ summary = fullText.length > SUMMARY_MAX_CHARS ? `${fullText.slice(0, 79)}…` : fullText;
57
+ }
58
+ break;
59
+ }
60
+ if (summary === "") {
61
+ const peek = peekNodeText(node);
62
+ fullText = peek;
63
+ summary = peek.length > SUMMARY_MAX_CHARS ? `${peek.slice(0, 79)}…` : peek;
64
+ }
65
+ }
66
+ entries.push({
67
+ turn,
68
+ index: displayIndex,
69
+ summary: summary || `[${kindLabel(turn, keys, chat)}]`,
70
+ fullText,
71
+ startTime,
72
+ status
73
+ });
74
+ }
75
+ return entries;
76
+ }
77
+ /** Best-effort text peek from a non-user chat node's data (erased shape). */
78
+ function peekNodeText(node) {
79
+ const data = node.data;
80
+ if (data === void 0) return "";
81
+ for (const field of [
82
+ "summary",
83
+ "text",
84
+ "content",
85
+ "message",
86
+ "name"
87
+ ]) {
88
+ const value = data[field];
89
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
90
+ if (Array.isArray(value)) {
91
+ for (const block of value) if (block !== null && typeof block === "object") {
92
+ const b = block;
93
+ if (typeof b.text === "string" && b.text.trim().length > 0) return b.text.trim();
94
+ }
95
+ }
96
+ }
97
+ return "";
98
+ }
99
+ /** Human label for a turn that has no readable first-node text. */
100
+ function kindLabel(turn, keys, chat) {
101
+ return `turn ${turn} (${chat.nodes.get(keys[0] ?? "")?.kind ?? "turn"})`;
102
+ }
103
+ /**
104
+ * Find the chat-node key of the first visible node in a given turn — the
105
+ * scroll target for "jump to turn".
106
+ *
107
+ * @param snap - the conversation snapshot.
108
+ * @param turn - the turn number to jump to.
109
+ * @returns the first node key in that turn (typically the user message), or undefined.
110
+ */
111
+ function firstNodeKeyOfTurn(snap, turn) {
112
+ if (snap === void 0) return void 0;
113
+ return snap.chat.locations.getTurn(turn)[0];
114
+ }
115
+ //#endregion
116
+ //#region src/client/TurnNavRail.tsx
117
+ /**
118
+ * Turn navigation rail: a vertical "piano-key" rail floating on the right
119
+ * edge of the conversation. One capsule per turn (grey, ~3px tall), stacked
120
+ * vertically. Hovering a capsule makes it glow white and grow (with the two
121
+ * neighbours slightly raised — a wave ripple), and shows the full turn info
122
+ * in a DSH Tooltip. Clicking a capsule jumps the conversation to that turn's
123
+ * start.
124
+ *
125
+ * Registered into `conversation.session.header.utilities` (session scope), so
126
+ * this component reads the live `ConversationSnapshot` directly via
127
+ * `useSession` — no cross-scope bridge needed.
128
+ *
129
+ * Also drives "auto-load older history": the conversation paginates its
130
+ * window (a "Load earlier" button appears at the top when `hasMore`); this
131
+ * rail keeps clicking it until all history is loaded, so every turn is
132
+ * reachable. The same load-and-retry loop backs click-to-jump when the target
133
+ * row is not yet rendered.
134
+ */
135
+ /** Scrollport selector: the active conversation's scroll container. */
136
+ const SCROLL_SELECTOR = "[data-conversation-scroll]";
137
+ /** Chat row anchor attribute: each rendered row carries its node key. */
138
+ const ANCHOR_ATTR = "data-chat-anchor-key";
139
+ /** CSS class for the jump highlight flash. */
140
+ const HIGHLIGHT_CLASS = "tn-jump-highlight";
141
+ /** Loaded-but-not-found retry window for click-to-jump (ms). */
142
+ const JUMP_TIMEOUT_MS = 5e3;
143
+ /** Delay between auto-load button clicks — lets the conversation render the
144
+ * prepended page (a heavy full-flow re-render) before the next one. */
145
+ const LOAD_RENDER_SETTLE_MS = 700;
146
+ /** Cap on pages auto-loaded per session open. Loading the entire history of a
147
+ * very long conversation at once would stall the UI; the rail fills up
148
+ * first (visual cap) and the rest is loaded on-demand. */
149
+ const MAX_AUTO_PAGES = 30;
150
+ /** Cap on pages loaded while hunting a specific turn's row (click-to-jump). */
151
+ const MAX_JUMP_PAGES = 40;
152
+ /** Extra vertical margin when scrolling a target row into view. */
153
+ const JUMP_MARGIN_PX = 16;
154
+ /** Localized "Load earlier" paging button labels — idle AND in-flight (the
155
+ * conversation swaps the label to a "loading…" copy while a page is being
156
+ * fetched; we must still recognize the button so loading state is tracked). */
157
+ const LOAD_OLDER_TEXTS = /* @__PURE__ */ new Set([
158
+ "加载更早",
159
+ "Load earlier",
160
+ "Load earlier…",
161
+ "加载中",
162
+ "加载中…",
163
+ "Loading",
164
+ "Loading…"
165
+ ]);
166
+ function isLoadOlderButton(el) {
167
+ const text = (el.textContent ?? "").trim();
168
+ return LOAD_OLDER_TEXTS.has(text);
169
+ }
170
+ /** Find the scrollport (the conversation's scroll container). */
171
+ function findScrollport() {
172
+ return document.querySelector(SCROLL_SELECTOR) ?? void 0;
173
+ }
174
+ /**
175
+ * Find the "Load earlier" paging button inside the conversation flow, or null
176
+ * when all history is already loaded (button absent) / currently loading
177
+ * (button disabled).
178
+ */
179
+ function findLoadOlderButton() {
180
+ const scrollport = findScrollport();
181
+ if (scrollport === void 0) return null;
182
+ const candidates = scrollport.querySelectorAll("button");
183
+ for (const btn of candidates) if (isLoadOlderButton(btn)) return btn;
184
+ return null;
185
+ }
186
+ /**
187
+ * Wait for a chat row with the given node key to appear in the DOM, driving
188
+ * the "load earlier" button meanwhile (the target turn may live in a not-yet
189
+ * loaded page). Resolves with the row, or null on timeout / page cap.
190
+ */
191
+ function waitForRow(scrollport, key, timeoutMs) {
192
+ return new Promise((resolve) => {
193
+ const deadline = Date.now() + timeoutMs;
194
+ let pages = 0;
195
+ const check = () => {
196
+ const row = scrollport.querySelector(`[${ANCHOR_ATTR}="${CSS.escape(key)}"]`);
197
+ if (row !== null) {
198
+ resolve(row);
199
+ return;
200
+ }
201
+ if (Date.now() > deadline || pages >= MAX_JUMP_PAGES) {
202
+ resolve(null);
203
+ return;
204
+ }
205
+ const btn = findLoadOlderButton();
206
+ if (btn !== null && !btn.disabled) {
207
+ btn.click();
208
+ pages += 1;
209
+ }
210
+ setTimeout(check, 150);
211
+ };
212
+ check();
213
+ });
214
+ }
215
+ /**
216
+ * Jump to a turn: locate its first chat-node row (auto-loading earlier
217
+ * history until it renders) and scroll the conversation flow to it, with a
218
+ * brief highlight flash.
219
+ */
220
+ async function jumpToTurn(snapshot, turn) {
221
+ if (snapshot === void 0) return;
222
+ const key = firstNodeKeyOfTurn(snapshot, turn);
223
+ if (key === void 0) return;
224
+ const scrollport = findScrollport();
225
+ if (scrollport === void 0) return;
226
+ const row = await waitForRow(scrollport, key, JUMP_TIMEOUT_MS);
227
+ if (row === null) return;
228
+ const targetTop = row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top + scrollport.scrollTop;
229
+ scrollport.scrollTop = Math.max(0, targetTop - JUMP_MARGIN_PX);
230
+ row.classList.add(HIGHLIGHT_CLASS);
231
+ setTimeout(() => row.classList.remove(HIGHLIGHT_CLASS), 1500);
232
+ }
233
+ /** Tooltip body for one turn: index, time, full summary. */
234
+ function tooltipText(entry, index, t) {
235
+ const time = formatTime(entry.startTime);
236
+ const label = t("turnLabel", { n: String(index) });
237
+ const body = entry.fullText || entry.summary || t("noSummary");
238
+ const lines = [label];
239
+ if (time !== "") lines.push(time);
240
+ lines.push(body);
241
+ return lines.join("\n");
242
+ }
243
+ /** Short HH:MM from a Unix-epoch-ms timestamp. */
244
+ function formatTime(ms) {
245
+ if (ms === void 0 || ms === null || !Number.isFinite(ms)) return "";
246
+ const date = new Date(ms);
247
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
248
+ }
249
+ /**
250
+ * The piano-key rail. Session scope: reads the conversation snapshot directly
251
+ * and renders a floating vertical capsule per turn.
252
+ */
253
+ function TurnNavRail({ useSession, t }) {
254
+ const snapshot = useSession?.((s) => s);
255
+ const turns = (0, react.useMemo)(() => extractTurns(snapshot), [snapshot]);
256
+ const [hoverIndex, setHoverIndex] = (0, react.useState)(-1);
257
+ const [hoverY, setHoverY] = (0, react.useState)(0);
258
+ const [tipTop, setTipTop] = (0, react.useState)(0);
259
+ const [canScrollUp, setCanScrollUp] = (0, react.useState)(false);
260
+ const [canScrollDown, setCanScrollDown] = (0, react.useState)(false);
261
+ const autoPagesRef = (0, react.useRef)(0);
262
+ const loadBusyRef = (0, react.useRef)(false);
263
+ const railRef = (0, react.useRef)(null);
264
+ const tipRef = (0, react.useRef)(null);
265
+ const hoverScrollRef = (0, react.useRef)(null);
266
+ const loadMoreOnce = (0, react.useCallback)((then) => {
267
+ if (loadBusyRef.current) {
268
+ then(false);
269
+ return;
270
+ }
271
+ const btn = findLoadOlderButton();
272
+ if (btn === null || btn.disabled) {
273
+ then(false);
274
+ return;
275
+ }
276
+ loadBusyRef.current = true;
277
+ btn.click();
278
+ setTimeout(() => {
279
+ loadBusyRef.current = false;
280
+ then(true);
281
+ }, LOAD_RENDER_SETTLE_MS);
282
+ }, []);
283
+ const hasTurns = turns.length > 0;
284
+ (0, react.useEffect)(() => {
285
+ if (!hasTurns) return;
286
+ autoPagesRef.current = 0;
287
+ let timer = null;
288
+ let stopped = false;
289
+ const tick = () => {
290
+ if (stopped || autoPagesRef.current >= MAX_AUTO_PAGES) return;
291
+ const rail = railRef.current;
292
+ if (rail !== null && rail.scrollHeight > rail.clientHeight + 2) return;
293
+ if (findLoadOlderButton() === null) return;
294
+ loadMoreOnce((loaded) => {
295
+ if (loaded) autoPagesRef.current += 1;
296
+ if (!stopped) timer = setTimeout(tick, 300);
297
+ });
298
+ };
299
+ timer = setTimeout(tick, 300);
300
+ return () => {
301
+ stopped = true;
302
+ if (timer !== null) clearTimeout(timer);
303
+ };
304
+ }, [hasTurns, loadMoreOnce]);
305
+ (0, react.useEffect)(() => {
306
+ const rail = railRef.current;
307
+ if (rail === null) return;
308
+ const onScroll = () => {
309
+ if (rail.scrollTop > 60) return;
310
+ const btn = findLoadOlderButton();
311
+ if (btn === null) return;
312
+ if (loadBusyRef.current || btn.disabled) {
313
+ setTimeout(onScroll, 150);
314
+ return;
315
+ }
316
+ loadBusyRef.current = true;
317
+ btn.click();
318
+ setTimeout(() => {
319
+ loadBusyRef.current = false;
320
+ if (rail.scrollTop <= 60) setTimeout(onScroll, 150);
321
+ }, LOAD_RENDER_SETTLE_MS);
322
+ };
323
+ rail.addEventListener("scroll", onScroll, { passive: true });
324
+ return () => rail.removeEventListener("scroll", onScroll);
325
+ }, [hasTurns]);
326
+ (0, react.useEffect)(() => {
327
+ const rail = railRef.current;
328
+ if (rail === null) return;
329
+ const update = () => {
330
+ setCanScrollUp(rail.scrollTop > 2);
331
+ setCanScrollDown(rail.scrollTop < rail.scrollHeight - rail.clientHeight - 2);
332
+ };
333
+ update();
334
+ rail.addEventListener("scroll", update, { passive: true });
335
+ const ro = new ResizeObserver(update);
336
+ ro.observe(rail);
337
+ return () => {
338
+ rail.removeEventListener("scroll", update);
339
+ ro.disconnect();
340
+ };
341
+ }, [turns.length]);
342
+ const hoverEntry = hoverIndex >= 0 ? turns[hoverIndex] : void 0;
343
+ (0, react.useEffect)(() => {
344
+ if (hoverEntry === void 0) return;
345
+ const tip = tipRef.current;
346
+ if (tip === null) return;
347
+ const h = tip.offsetHeight;
348
+ setTipTop(Math.max(8, Math.min(hoverY - h / 2, window.innerHeight - h - 8)));
349
+ }, [hoverEntry, hoverY]);
350
+ (0, react.useEffect)(() => () => stopHoverScroll(), []);
351
+ const startHoverScroll = (dir) => {
352
+ stopHoverScroll();
353
+ if (railRef.current === null) return;
354
+ const step = () => {
355
+ const r = railRef.current;
356
+ if (r === null) return;
357
+ if (dir < 0 && r.scrollTop <= 2) {
358
+ stopHoverScroll();
359
+ return;
360
+ }
361
+ if (dir > 0 && r.scrollTop >= r.scrollHeight - r.clientHeight - 2) {
362
+ stopHoverScroll();
363
+ return;
364
+ }
365
+ r.scrollBy({ top: dir * 24 });
366
+ };
367
+ step();
368
+ hoverScrollRef.current = setInterval(step, 120);
369
+ };
370
+ function stopHoverScroll() {
371
+ if (hoverScrollRef.current !== null) {
372
+ clearInterval(hoverScrollRef.current);
373
+ hoverScrollRef.current = null;
374
+ }
375
+ }
376
+ const centerCapsule = (index) => {
377
+ const rail = railRef.current;
378
+ if (rail === null) return;
379
+ const btn = rail.querySelectorAll(".tn-cap-btn")[index];
380
+ if (btn === void 0) return;
381
+ const railRect = rail.getBoundingClientRect();
382
+ const btnRect = btn.getBoundingClientRect();
383
+ const contentTop = railRect.top - rail.scrollTop;
384
+ const target = btnRect.top - contentTop - (rail.clientHeight - btnRect.height) / 2;
385
+ rail.scrollTop = Math.max(0, target);
386
+ };
387
+ if (turns.length === 0) return null;
388
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
389
+ className: "tn-wrap",
390
+ role: "navigation",
391
+ "aria-label": t("rail"),
392
+ onMouseLeave: () => {
393
+ setHoverIndex(-1);
394
+ stopHoverScroll();
395
+ },
396
+ children: [
397
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
398
+ type: "button",
399
+ className: "tn-scroll-btn",
400
+ "aria-label": "scroll rail up",
401
+ disabled: !canScrollUp,
402
+ onClick: () => scrollRail(railRef.current, -1),
403
+ onMouseEnter: () => {
404
+ if (canScrollUp) startHoverScroll(-1);
405
+ },
406
+ onMouseLeave: stopHoverScroll,
407
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronUpOutline14, { size: 12 })
408
+ }),
409
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
410
+ ref: railRef,
411
+ className: "tn-rail",
412
+ children: turns.map((entry, i) => {
413
+ const dist = hoverIndex === -1 ? Infinity : Math.abs(i - hoverIndex);
414
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
415
+ type: "button",
416
+ className: `tn-cap-btn${dist === 0 ? " tn-cap-hot" : dist === 1 ? " tn-cap-warm" : ""}`,
417
+ onMouseEnter: (e) => {
418
+ setHoverIndex(i);
419
+ const rect = e.currentTarget.getBoundingClientRect();
420
+ setHoverY(rect.top + rect.height / 2);
421
+ },
422
+ onClick: () => {
423
+ jumpToTurn(snapshot, entry.turn);
424
+ centerCapsule(i);
425
+ },
426
+ "aria-label": tooltipText(entry, entry.index, t).replace(/\n/g, " — "),
427
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "tn-cap" })
428
+ }, entry.turn);
429
+ })
430
+ }),
431
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
432
+ type: "button",
433
+ className: "tn-scroll-btn",
434
+ "aria-label": "scroll rail down",
435
+ disabled: !canScrollDown,
436
+ onClick: () => scrollRail(railRef.current, 1),
437
+ onMouseEnter: () => {
438
+ if (canScrollDown) startHoverScroll(1);
439
+ },
440
+ onMouseLeave: stopHoverScroll,
441
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 12 })
442
+ }),
443
+ hoverEntry !== void 0 && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
444
+ ref: tipRef,
445
+ className: "tn-tip",
446
+ style: { top: tipTop },
447
+ role: "tooltip",
448
+ children: tooltipText(hoverEntry, hoverEntry.index, t)
449
+ }), document.body)
450
+ ]
451
+ });
452
+ }
453
+ /** Scroll the rail by roughly one viewport-height (smooth). */
454
+ function scrollRail(rail, dir) {
455
+ if (rail === null) return;
456
+ const step = Math.max(60, rail.clientHeight * .8);
457
+ rail.scrollBy({
458
+ top: dir * step,
459
+ behavior: "smooth"
460
+ });
461
+ }
462
+ //#endregion
463
+ //#region src/client/locales.ts
464
+ /** Copy dictionaries for the dsh-turn-navigator plugin. */
465
+ /** English strings (the key-set source of truth for this pair). */
466
+ const en = {
467
+ rail: "Turn navigation",
468
+ turnLabel: "Turn {n}",
469
+ noSummary: "(no user message)"
470
+ };
471
+ /** Chinese strings (same keys as {@link en}). */
472
+ const zh = {
473
+ rail: "轮次导航",
474
+ turnLabel: "第 {n} 轮",
475
+ noSummary: "(无用户消息)"
476
+ };
477
+ //#endregion
478
+ //#region src/client/styles.ts
479
+ /**
480
+ * Design-token styles for the turn-nav piano-key rail (external plugin, no
481
+ * CSS modules available). Re-declared against the official `--dsw-alias-*`
482
+ * semantic tokens, namespaced under `tn-` to avoid collisions. Tokens carry
483
+ * no fallback because the host theme always defines them on the app root.
484
+ *
485
+ * Wave hover: hovering a capsule makes it glow white and WIDEN (150%);
486
+ * its two neighbours widen a little too (125%), so sliding across the rail
487
+ * ripples like a wave (hot = hovered, warm = adjacent). Height stays ~3px.
488
+ */
489
+ const TURN_NAV_STYLES = `
490
+ /* Wrapper: fixed column on the right edge (scroll buttons + rail + tooltip).
491
+ The whole wrapper is pointer-events:auto so the wheel scrolls the rail
492
+ anywhere on it; buttons sit above and below the rail. */
493
+ .tn-wrap {
494
+ position: fixed;
495
+ right: 6px;
496
+ top: 50%;
497
+ transform: translateY(-50%);
498
+ display: flex;
499
+ flex-direction: column;
500
+ align-items: center;
501
+ z-index: 30;
502
+ pointer-events: auto;
503
+ }
504
+ /* Up/down scroll controls at the top and bottom of the rail. Disabled (grey,
505
+ no pointer/hover-scroll) when there is nothing to scroll in that
506
+ direction. */
507
+ .tn-scroll-btn {
508
+ flex: none;
509
+ width: 26px;
510
+ height: 18px;
511
+ display: flex;
512
+ align-items: center;
513
+ justify-content: center;
514
+ padding: 0;
515
+ border: none;
516
+ border-radius: 6px;
517
+ background: transparent;
518
+ color: var(--dsw-alias-label-secondary);
519
+ cursor: pointer;
520
+ }
521
+ .tn-scroll-btn:hover:not(:disabled) {
522
+ background: var(--dsw-alias-interactive-bg-hover);
523
+ color: var(--dsw-alias-label-primary);
524
+ }
525
+ .tn-scroll-btn:disabled {
526
+ color: var(--dsw-alias-label-tertiary);
527
+ opacity: 0.45;
528
+ cursor: default;
529
+ }
530
+ /* Rail: AUTO-SIZING vertical column — its length grows with the turn count,
531
+ capped at 30vh; once the cap is hit it scrolls internally. The scrollbar is
532
+ HIDDEN (scrollbar-width:none + webkit) so its appear/disappear never shifts
533
+ layout; scrolling is via wheel over the rail, the up/down buttons (click or
534
+ hover-hold), or the rail-top auto-load. Buttons are packed flush (no gap)
535
+ so hovering slides continuously without dead zones. */
536
+ .tn-rail {
537
+ display: flex;
538
+ flex-direction: column;
539
+ width: 26px;
540
+ height: auto;
541
+ max-height: 30vh;
542
+ min-height: 0;
543
+ overflow-y: auto;
544
+ overflow-x: hidden;
545
+ scrollbar-width: none;
546
+ -ms-overflow-style: none;
547
+ }
548
+ .tn-rail::-webkit-scrollbar {
549
+ display: none;
550
+ width: 0;
551
+ height: 0;
552
+ }
553
+ /* Per-turn hotspot: a wider/taller transparent button so the tiny capsule
554
+ still has a comfortable hover/click target. Fixed size — excess turns make
555
+ the rail scroll, they never compress it. */
556
+ .tn-cap-btn {
557
+ flex: none;
558
+ min-height: 10px;
559
+ width: 100%;
560
+ padding: 0;
561
+ border: none;
562
+ background: transparent;
563
+ cursor: pointer;
564
+ display: flex;
565
+ align-items: center;
566
+ justify-content: center;
567
+ outline: none;
568
+ }
569
+ /* The visible capsule itself: grey, ~3px tall, ~12px wide by default. The
570
+ hover widening uses transform:scaleX (NOT width) with transform-origin on
571
+ the RIGHT, so it grows LEFTWARD only (right-aligned — the right edge never
572
+ moves), never reflows the rail or adds a horizontal scrollbar. */
573
+ .tn-cap {
574
+ width: 12px;
575
+ height: 3px;
576
+ border-radius: 2px;
577
+ background: var(--dsw-alias-border-l3);
578
+ transform-origin: right center;
579
+ transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
580
+ flex: none;
581
+ }
582
+ /* Hovered capsule: glow with the theme's primary label color and widen 150%
583
+ (the wave peak). Theme-following (dark theme = white, light theme = dark). */
584
+ .tn-cap-btn.tn-cap-hot .tn-cap {
585
+ transform: scaleX(1.5);
586
+ background: var(--dsw-alias-label-primary);
587
+ box-shadow: 0 0 6px color-mix(in srgb, var(--dsw-alias-label-primary) 55%, transparent);
588
+ }
589
+ /* Adjacent capsule: slightly wider (125%) — the wave's near neighbours. */
590
+ .tn-cap-btn.tn-cap-warm .tn-cap {
591
+ transform: scaleX(1.25);
592
+ background: var(--dsw-alias-label-secondary);
593
+ }
594
+ /* Custom tooltip bubble: mirrors the DSH tooltip visual (dark plate, white
595
+ text, pre-line for multi-line info), fixed-positioned to the LEFT of the
596
+ rail so it never falls outside the browser window. */
597
+ .tn-tip {
598
+ position: fixed;
599
+ right: 40px; /* rail 26px + wrapper right 6px + 8px gap */
600
+ z-index: 100;
601
+ width: max-content;
602
+ max-width: 50vw;
603
+ padding: 3px 7px;
604
+ border-radius: 8px;
605
+ background: var(--dsw-alias-tooltip-bg);
606
+ color: var(--dsw-static-neutral-bluish-00);
607
+ font-size: 13px;
608
+ line-height: 20px;
609
+ white-space: pre-line;
610
+ overflow-wrap: break-word;
611
+ pointer-events: none;
612
+ animation: tn-tooltip-in 150ms var(--ds-ease-in-out);
613
+ }
614
+ @keyframes tn-tooltip-in {
615
+ from { opacity: 0; }
616
+ }
617
+
618
+ /* Jump highlight flash on the target row in the conversation flow. */
619
+ @keyframes tn-highlight-flash {
620
+ 0% { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent); }
621
+ 100% { background: transparent; }
622
+ }
623
+ .tn-jump-highlight {
624
+ animation: tn-highlight-flash 1500ms ease-out;
625
+ border-radius: 4px;
626
+ }
627
+ `;
628
+ /**
629
+ * Inject {@link TURN_NAV_STYLES} once, tagged by plugin id so re-evaluation
630
+ * and repeated mounts stay idempotent (mirrors the dsh-kanban pattern).
631
+ * @param pluginId - stable plugin id used as the style tag marker.
632
+ */
633
+ function injectTurnNavStyles(pluginId) {
634
+ if (typeof document === "undefined") return;
635
+ const selector = `style[data-dsh-plugin-css="${pluginId}"]`;
636
+ if (document.querySelector(selector) !== null) return;
637
+ const tag = document.createElement("style");
638
+ tag.setAttribute("data-dsh-plugin-css", pluginId);
639
+ tag.textContent = TURN_NAV_STYLES;
640
+ document.head.appendChild(tag);
641
+ }
642
+ injectTurnNavStyles("dsh-turn-navigator");
643
+ //#endregion
644
+ //#region src/client/index.ts
645
+ /** Dictionary namespace owned by this plugin. */
646
+ const NS = "dsh-turn-navigator";
647
+ /** Required services (cordis fiber inject). */
648
+ const inject = ["slots", "locale"];
649
+ /**
650
+ * Browser plugin body: registers the turn rail into the session header
651
+ * utilities seat.
652
+ * @param ctx - client root context.
653
+ */
654
+ function apply(ctx) {
655
+ ctx.effect(() => ctx.locale.register(NS, {
656
+ zh,
657
+ en
658
+ }), "dsh-turn-navigator: copy dictionaries");
659
+ const t = ctx.locale.bind(NS);
660
+ ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
661
+ name: "conversation.session.header.utilities",
662
+ id: "dsh-turn-navigator",
663
+ order: 20,
664
+ locale: NS,
665
+ inject: () => ({ t })
666
+ }, TurnNavRail));
667
+ }
668
+ //#endregion
669
+ exports.apply = apply;
670
+ exports.inject = inject;
671
+ return module.exports;
672
+ }
673
+ });
674
+
675
+ //# sourceMappingURL=client.js.map
package/lib/index.js ADDED
@@ -0,0 +1,4 @@
1
+ //#region src/index.ts
2
+ function apply(_ctx) {}
3
+ //#endregion
4
+ export { apply };
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "dsh-turn-navigator",
3
+ "version": "0.1.0",
4
+ "description": "External DeepSeek Harness plugin: a turn-navigation drawer that lists every conversation turn (user message summary + timestamp), jumps to any turn's start, and highlights the current turn as you scroll.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib/index.js",
18
+ "lib/client.js",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "README.zh.md",
22
+ "CHANGELOG.md",
23
+ "CONTRIBUTING.md",
24
+ "LICENSE"
25
+ ],
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-connection",
33
+ "@deepseek-ai/dsh-client-locale",
34
+ "@deepseek-ai/dsh-client-runtime",
35
+ "@deepseek-ai/dsh-client-ui-conversation",
36
+ "@deepseek-ai/dsh-client-ui-layout",
37
+ "@deepseek-ai/dsh-client-ui-slots",
38
+ "@deepseek-ai/dsh-client-web-react",
39
+ "@deepseek-ai/dsh-api-remotes"
40
+ ],
41
+ "platform": "web"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "bundle": "tsdown --config ./tsdown.config.ts",
46
+ "prepare": "tsdown --config ./tsdown.config.ts",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "tsc --noEmit",
49
+ "release:check": "node scripts/release-check.mjs",
50
+ "prepack": "node scripts/release-check.mjs",
51
+ "prepublishOnly": "node scripts/release-check.mjs && tsdown --config ./tsdown.config.ts",
52
+ "postpublish": "node scripts/post-publish-check.mjs"
53
+ },
54
+ "engines": {
55
+ "node": ">=18"
56
+ },
57
+ "keywords": [
58
+ "deepseek-harness",
59
+ "dsh",
60
+ "plugin",
61
+ "turn",
62
+ "navigation",
63
+ "conversation",
64
+ "outline"
65
+ ],
66
+ "author": "karoc",
67
+ "license": "MIT",
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "git+https://github.com/karoc/dsh-turn-nav.git"
71
+ },
72
+ "bugs": {
73
+ "url": "https://github.com/karoc/dsh-turn-nav/issues"
74
+ },
75
+ "homepage": "https://github.com/karoc/dsh-turn-nav",
76
+ "publishConfig": {
77
+ "access": "public"
78
+ },
79
+ "devDependencies": {
80
+ "@types/node": "^22.0.0",
81
+ "@types/react": "~18.3.1",
82
+ "@types/react-dom": "^19.2.4",
83
+ "react": "^18.2.0",
84
+ "tsdown": "^0.22.0",
85
+ "typescript": "^5.6.0"
86
+ },
87
+ "peerDependencies": {
88
+ "react": "^18.2.0"
89
+ }
90
+ }