dsh-diff-approval 0.22.2 → 0.23.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/lib/index.js CHANGED
@@ -1276,6 +1276,45 @@ function defaultOpenPath(path, action) {
1276
1276
  }
1277
1277
  }
1278
1278
  //#endregion
1279
+ //#region lib/types/comment-skill.js
1280
+ /**
1281
+ * The one skill this plugin offers the agent: how to answer a diff comment.
1282
+ *
1283
+ * The rules live here rather than in the system prompt. The harness's own `skill`
1284
+ * tool advertises every registered skill's name and description as a context message
1285
+ * and loads the body on demand, so the long form costs nothing until a comment is
1286
+ * actually being answered — and nothing this plugin registers can leak into unrelated
1287
+ * turns the way a global system-prompt section would.
1288
+ *
1289
+ * The comment prompt itself still carries the short version (see
1290
+ * `discussion.promptRule` in `src/client/locales.ts`) and names this skill, so the
1291
+ * behavior survives a build whose harness has no skill registry at all. The two are
1292
+ * kept in step by a test in `tests/diff-approval.spec.ts`.
1293
+ */
1294
+ /** The kebab-case name the comment prompt points the agent at. Prefixed with the whole
1295
+ * package name: skill names share one flat namespace with no reverse-DNS scoping, so a
1296
+ * short name like `diff-comment` is the kind of thing another plugin would also pick. */
1297
+ const COMMENT_SKILL_NAME = "dsh-diff-approval-comment";
1298
+ /** The contribution to register with the skill registry. */
1299
+ const COMMENT_SKILL = {
1300
+ name: COMMENT_SKILL_NAME,
1301
+ description: "How to answer a [评论] / [Review] comment on a few diff lines: answer those lines only, at most 3 lines, no blank line between them, and point at the message box below when three lines cannot carry the answer.",
1302
+ whenToUse: "A message that starts with `[评论] (path:lines)` (or `[Review] (path:lines)` in English) is a comment from the diff panel — load this skill before answering it.",
1303
+ content: `# 回答代码批注
1304
+
1305
+ 消息以 \`[评论] (path:lines)\` 开头时,就是 diff 面板里对那几行代码提的批注。按下面的规则回答:
1306
+
1307
+ - 只谈这几行(最多带上直接相邻的一两行),不要扩到整个文件或整个任务。
1308
+ - 不超过 3 行。
1309
+ - 行与行之间不要空行:讨论区按代码行排版,一个空行就占掉一整行高度,看起来像代码里的空洞。
1310
+ - 要引用代码就写 \`文件:行号\`,不要把整段贴回来。
1311
+ - 回答直接显示在批注下方的小讨论区里,所以不要写"如你所见""上面提到"这类指代。
1312
+ - 不确定就说不确定,并说清需要看哪一段。
1313
+ - 3 行里说不清(要展开、要看更多代码、要来回几轮),就直说一句"这条在批注里说不清,去下方发消息的输入框里聊",不要在批注里硬塞长回答。
1314
+ `,
1315
+ source: "runtime"
1316
+ };
1317
+ //#endregion
1279
1318
  //#region lib/types/vcs.js
1280
1319
  /**
1281
1320
  * Version-control integration, host half: detect which VCS (git/svn/p4)
@@ -1608,6 +1647,82 @@ const inject = [
1608
1647
  ];
1609
1648
  /** The connection RPC channel this plugin serves. */
1610
1649
  const DIFF_APPROVAL_CHANNEL = "/diff-approval";
1650
+ /**
1651
+ * The skill to name in a comment prompt on this deployment, or `undefined` when the
1652
+ * harness cannot deliver it.
1653
+ *
1654
+ * The prompt has two shapes: the short rules inline (always works), or nothing but a
1655
+ * pointer at this skill (the rules then cost nothing until a comment is answered). Which
1656
+ * one the client sends is the HOST's answer, because the host is the only side that knows
1657
+ * whether the contribution landed.
1658
+ *
1659
+ * The answer turns on the registration alone — the half this plugin performs and can be
1660
+ * sure of. The other half, the `skill` tool that advertises the catalog and loads the
1661
+ * body, belongs to the harness: `dsh-base` mounts `@deepseek-ai/dsh-tool-skill` beside
1662
+ * `dsh-skill`, and both have shipped in every release since `0.0.1-rc.1`. Asking the tool
1663
+ * registry for it instead was tried and reads absent in a live session whose catalog was
1664
+ * demonstrably working — a false negative that kept the rules inline. The registration is
1665
+ * the honest signal.
1666
+ *
1667
+ * @param _ctx - the plugin's host context (kept for the shape of the question).
1668
+ * @param registered - whether {@link registerCommentSkill} got the skill in.
1669
+ * @returns the skill name to point the prompt at, or undefined for the inline rules.
1670
+ */
1671
+ function commentSkillCapability(_ctx, registered) {
1672
+ return registered ? COMMENT_SKILL_NAME : void 0;
1673
+ }
1674
+ /**
1675
+ * The answer to {@link commentSkillCapability}, asked no earlier than the first list
1676
+ * request — applying is too early for anything that reads another plugin's services. A
1677
+ * positive is kept; a negative is re-checked on the next request, since it costs two
1678
+ * property lookups.
1679
+ *
1680
+ * @param ctx - the plugin's host context.
1681
+ * @param registered - whether the skill is in the registry.
1682
+ * @returns a memo that answers with the skill name or undefined.
1683
+ */
1684
+ function lazyCommentSkill(ctx, registered) {
1685
+ let resolved;
1686
+ let reported = false;
1687
+ return () => {
1688
+ if (resolved !== void 0) return resolved;
1689
+ resolved = commentSkillCapability(ctx, registered);
1690
+ if (!reported) {
1691
+ reported = true;
1692
+ ctx.logger.info(`diff-approval: comment skill ${resolved ?? "not used"} (registered=${registered})`);
1693
+ }
1694
+ return resolved;
1695
+ };
1696
+ }
1697
+ /**
1698
+ * Register the comment-answering skill with the harness's skill registry, when it has
1699
+ * one. The registry's own `skill` tool advertises the catalog and loads the body on
1700
+ * demand, so this costs nothing until a comment is answered — and unlike a
1701
+ * system-prompt section, a global registration here cannot shape unrelated turns.
1702
+ *
1703
+ * Feature-detected and contained: a build without the registry, or a registry that
1704
+ * refuses the contribution, simply leaves the short rules the comment prompt already
1705
+ * carries as the whole policy.
1706
+ *
1707
+ * @param ctx - the plugin's host context.
1708
+ * @returns whether the skill is now in the registry.
1709
+ */
1710
+ function registerCommentSkill(ctx) {
1711
+ if (typeof ctx.get("skills")?.register !== "function") return false;
1712
+ let registered = false;
1713
+ ctx.effect(() => {
1714
+ const skills = ctx.get("skills");
1715
+ if (typeof skills?.register !== "function") return () => {};
1716
+ try {
1717
+ const dispose = skills.register(COMMENT_SKILL);
1718
+ registered = true;
1719
+ return typeof dispose === "function" ? dispose : () => {};
1720
+ } catch {
1721
+ return () => {};
1722
+ }
1723
+ });
1724
+ return registered;
1725
+ }
1611
1726
  /** The dominant line ending of `text`: CRLF when `\r\n` meets or beats lone
1612
1727
  * `\n`, else LF; a text with no line breaks falls back to LF.
1613
1728
  * @param text - the content to inspect.
@@ -1803,6 +1918,7 @@ function apply(ctx, config) {
1803
1918
  const store = new PendingDiffStore();
1804
1919
  const persistence = new PendingPersistence(resolve(expandHomePath(storageDir ?? defaultStorageDir())));
1805
1920
  const launchPath = config?.openPath ?? defaultOpenPath;
1921
+ const commentSkill = lazyCommentSkill(ctx, registerCommentSkill(ctx));
1806
1922
  /** Hydrate the globally-unique store once from the single persistence file. */
1807
1923
  let loadPromise;
1808
1924
  const ensureLoaded = () => {
@@ -2346,7 +2462,8 @@ function apply(ctx, config) {
2346
2462
  value: {
2347
2463
  files,
2348
2464
  workspacePath: workspaceOf(sessionId)?.path,
2349
- redoCleared: redoCleared || void 0
2465
+ redoCleared: redoCleared || void 0,
2466
+ commentSkill: commentSkill()
2350
2467
  }
2351
2468
  };
2352
2469
  }
@@ -3183,4 +3300,4 @@ function openTargetOf(payload) {
3183
3300
  };
3184
3301
  }
3185
3302
  //#endregion
3186
- export { DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, apply, defaultOpenPath, defaultStorageDir, inject, name };
3303
+ export { COMMENT_SKILL_NAME, DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, apply, defaultOpenPath, defaultStorageDir, inject, name };
@@ -120,5 +120,5 @@ export declare const SplitDiff: import("react").ForwardRefExoticComponent<{
120
120
  */
121
121
  export declare function selectedPlainText(): string | undefined;
122
122
  /** Render the pending-edit review panel and its unified footer action. */
123
- export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onBlockKeep, onBlockRevert, onOpen, onPreviewImage, onPasteReference, onUndo, onRedo, onImportVcs, onRefreshVcs, onBrowse, onAddPath, onKeepAll, onRevertAll, onAckRedoCleared, collapseSidebar, t, docked, dockHost, onOpenDock, closeDock, useDock, }: PendingPanelProps): import("react").JSX.Element;
123
+ export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onBlockKeep, onBlockRevert, onOpen, onPreviewImage, onPasteReference, onAskAgent, watchChat, onUndo, onRedo, onImportVcs, onRefreshVcs, onBrowse, onAddPath, onKeepAll, onRevertAll, onAckRedoCleared, collapseSidebar, t, docked, dockHost, onOpenDock, closeDock, useDock, }: PendingPanelProps): import("react").JSX.Element;
124
124
  export {};
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The panel's one connection to the session's chat: send one prompt, and watch
3
+ * the transcript so a discussion can show its answer where it was asked.
4
+ *
5
+ * Everything here is deliberately defensive. The surfaces it uses (the scoped
6
+ * `conversation` input face, the `sessions` binding, the `chat` view target) are
7
+ * public types but not plugin-documented API, so a build that lacks one reports
8
+ * "unavailable" and the caller falls back to the composer route it already had.
9
+ *
10
+ * @module dsh-diff-approval/client/chat-bridge
11
+ */
12
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
13
+ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
14
+ /** One human-visible transcript node, reduced to what a discussion shows. */
15
+ export interface ChatNodeView {
16
+ /** `user`, `assistant`, or the node's own kind for anything else. */
17
+ kind: string;
18
+ /** The node's text blocks joined; empty for nodes that carry no prose. */
19
+ text: string;
20
+ /**
21
+ * The runtime froze this assistant node when the turn was stopped (`interrupted` on
22
+ * the durable node). A stopped turn leaves one of these, often with no text, which is
23
+ * how a caller can tell "the answer was cut off" from "the answer is still coming".
24
+ * Only ever set when true.
25
+ */
26
+ interrupted?: boolean;
27
+ }
28
+ /** What a discussion needs to know about the session's chat right now. */
29
+ export interface ChatView {
30
+ /** A turn is running for this session. */
31
+ running: boolean;
32
+ /** The durable transcript, in order. */
33
+ nodes: readonly ChatNodeView[];
34
+ /** The in-flight assistant text, or '' when nothing is streaming. */
35
+ partial: string;
36
+ /** The session's recorded prompt error, when there is one. */
37
+ error: string | undefined;
38
+ /**
39
+ * The prompts the session is still holding — its inbox queue plus the local
40
+ * submission echoes that have not become durable yet — or `undefined` on a build
41
+ * that does not publish them. A prompt that is not in the transcript is either one
42
+ * of these (still waiting) or gone (dropped with a stopped turn); without the
43
+ * evidence a caller can only keep waiting.
44
+ */
45
+ queued: readonly string[] | undefined;
46
+ }
47
+ /** The session chat as the panel uses it. */
48
+ export interface ChatBridge {
49
+ /**
50
+ * Send one prompt into the session as a real turn.
51
+ * @param sessionId - the session to prompt.
52
+ * @param text - the prompt text, sent verbatim.
53
+ * @returns whether the send verb was available (false means "not sent").
54
+ */
55
+ ask: (sessionId: SessionId, text: string) => boolean;
56
+ /**
57
+ * Watch the session's transcript and turn state.
58
+ * @param sessionId - the session to watch.
59
+ * @param listener - called with the current view, then on every change.
60
+ * @returns the unsubscribe function.
61
+ */
62
+ watch: (sessionId: SessionId, listener: (view: ChatView) => void) => () => void;
63
+ }
64
+ /**
65
+ * Build the bridge over the client context. Every verb takes the session it
66
+ * addresses, so one bridge serves whichever session the panel is showing.
67
+ * @param ctx - the plugin's client context.
68
+ * @returns the bridge (its verbs report unavailability instead of throwing).
69
+ */
70
+ export declare function createChatBridge(ctx: ClientContext): ChatBridge;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Discussions attached to a diff row range: how many rows a block reserves in
3
+ * the row stream, and which actions the selection frame offers for a selection.
4
+ *
5
+ * Pure derivation so both rules are unit-testable without the panel — the panel
6
+ * itself only maps these answers onto rows and markup.
7
+ *
8
+ * @module dsh-diff-approval/client/discussion
9
+ */
10
+ /** One row range in the current diff model (inclusive, 0-based row indices). */
11
+ export interface DiscussionAnchor {
12
+ /** First row of the range, in the model this block is currently drawn against. */
13
+ start: number;
14
+ /** Last row of the range. */
15
+ end: number;
16
+ /** First new-file line the annotation was made on (survives model changes). */
17
+ startLine: number;
18
+ /** Last new-file line of the annotation. */
19
+ endLine: number;
20
+ }
21
+ /** One turn of a discussion: what the user said, or what the agent answered. */
22
+ export interface DiscussionMessage {
23
+ /** Who spoke. */
24
+ role: 'user' | 'assistant';
25
+ /** The message text as it is rendered. */
26
+ text: string;
27
+ }
28
+ /** One discussion bound to a row range. */
29
+ export interface Discussion {
30
+ /** Stable identity for keys and toggles. */
31
+ id: string;
32
+ /** The rows the block sits below. */
33
+ anchor: DiscussionAnchor;
34
+ /**
35
+ * The thread so far: the annotation first, then answers and follow-ups in the
36
+ * order they happened. A discussion is a conversation, not one question.
37
+ */
38
+ messages: readonly DiscussionMessage[];
39
+ /** What the user is typing into the compose row (not sent yet). */
40
+ draft: string;
41
+ /** Folded to the one-row header. */
42
+ collapsed: boolean;
43
+ /**
44
+ * No line of the anchor exists in the file any more (the code was changed or
45
+ * removed under it). The block stays where it last matched, cannot be commented
46
+ * on again, and is the user's to delete.
47
+ */
48
+ lost?: boolean;
49
+ /** The answer being streamed for the question in flight. */
50
+ reply?: string;
51
+ /** The turn for the last question is running. */
52
+ asking?: boolean;
53
+ /**
54
+ * The question is sent but still behind another turn of the session, so nothing
55
+ * streamed yet belongs to this discussion. The block says it is waiting instead
56
+ * of showing the other turn's answer.
57
+ */
58
+ queued?: boolean;
59
+ /** The answer failed (the session recorded a prompt error). */
60
+ failed?: boolean;
61
+ /**
62
+ * The turn for the last question was stopped before it wrote an answer. The block
63
+ * says so and offers the writing row again, instead of waiting on a turn that is over.
64
+ */
65
+ stopped?: boolean;
66
+ /**
67
+ * How many rows the block's body occupies, measured by the caller from what it
68
+ * actually renders (the thread plus the compose row). It is what keeps a
69
+ * discussion block's height an exact multiple of the code row.
70
+ */
71
+ bodyRows?: number;
72
+ /** How many older messages the row cap left out of the render. */
73
+ hidden?: number;
74
+ }
75
+ /** Rows a folded block occupies: its header line. */
76
+ export declare const DISCUSSION_HEADER_ROWS = 1;
77
+ /** Rows the compose area occupies: 0.3 + 1.4 + 0.3 of a code row. */
78
+ export declare const DISCUSSION_COMPOSE_ROWS = 2;
79
+ /** The most rows an answer may take before it would swamp the diff it annotates. */
80
+ export declare const DISCUSSION_MAX_BODY_ROWS = 12;
81
+ /**
82
+ * The newest turns that fit a size budget, and how many older ones were left out.
83
+ * A thread grows without bound, so the block shows its tail and says what it is
84
+ * hiding rather than clipping silently; the hidden note costs one row of the
85
+ * budget itself. Sizes are in whatever unit the caller budgets in (the panel uses
86
+ * code rows, fractions included, and rounds the total up once).
87
+ *
88
+ * @param messages - the whole thread, oldest first.
89
+ * @param sizeOf - the size one message needs when rendered (text plus its chrome).
90
+ * @param capRows - the body budget.
91
+ * @returns the messages to render, the count left out, and the size they need.
92
+ */
93
+ export declare function discussionTail(messages: readonly DiscussionMessage[], sizeOf: (message: DiscussionMessage) => number, capRows: number): {
94
+ messages: readonly DiscussionMessage[];
95
+ hidden: number;
96
+ rows: number;
97
+ };
98
+ /**
99
+ * An answer with its blank lines dropped.
100
+ *
101
+ * The thread is plain text laid out in code rows, so a blank line between two
102
+ * paragraphs costs a whole row of height and reads as a gap in the code it
103
+ * annotates. The prompt asks the agent not to write them; this is what makes a
104
+ * stray one harmless, and both the render and the row measurement run the answer
105
+ * through it so the two can never disagree about how tall a turn is. What the user
106
+ * typed is shown exactly as typed.
107
+ *
108
+ * @param text - the answer text.
109
+ * @returns the same text without empty (or whitespace-only) lines.
110
+ */
111
+ export declare function stripBlankLines(text: string): string;
112
+ /**
113
+ * The text of one turn as the thread shows it: an answer loses its blank lines, a
114
+ * first-person turn keeps every one of them.
115
+ *
116
+ * @param message - the turn to render.
117
+ * @returns the text to lay out and to render.
118
+ */
119
+ export declare function discussionText(message: DiscussionMessage): string;
120
+ /**
121
+ * How many rows a block reserves, so the diff's height table stays exact.
122
+ * @param discussion - the discussion to measure.
123
+ * @returns the row count the block contributes.
124
+ */
125
+ export declare function discussionRows(discussion: Discussion): number;
126
+ /**
127
+ * Extra rows to add to each diff row's height: the block hangs below its range's
128
+ * last row, so a block's rows belong to that row's entry in the height table.
129
+ * Several blocks on one row sum up.
130
+ *
131
+ * @param discussions - the blocks currently attached to this file.
132
+ * @param rowCount - how many rows the diff has (clamps a stale anchor).
133
+ * @returns a map from row index to the rows added below it.
134
+ */
135
+ export declare function discussionRowExtras(discussions: readonly Discussion[], rowCount: number): Map<number, number>;
136
+ /**
137
+ * The discussion attached to exactly this range, if there is one.
138
+ *
139
+ * @param discussions - the blocks currently attached to this file.
140
+ * @param anchor - the range to look up.
141
+ * @returns the matching discussion, or `undefined`.
142
+ */
143
+ export declare function discussionOnRange(discussions: readonly Discussion[], anchor: {
144
+ start: number;
145
+ end: number;
146
+ }): Discussion | undefined;
147
+ /**
148
+ * The discussion that already covers any part of this range. A row belongs to one
149
+ * annotation at most, so a selection that touches an existing block offers no
150
+ * second one - otherwise two blocks would describe the same line.
151
+ *
152
+ * @param discussions - the blocks currently attached to this file.
153
+ * @param anchor - the candidate range.
154
+ * @returns the overlapping discussion, or `undefined`.
155
+ */
156
+ export declare function discussionOverlapping(discussions: readonly Discussion[], anchor: {
157
+ start: number;
158
+ end: number;
159
+ }): Discussion | undefined;
160
+ /**
161
+ * Re-anchor one discussion against the current rows: the rows whose new-file line
162
+ * falls inside the annotation's line range. When no line matches any more, the
163
+ * block keeps the rows it last matched and is marked lost, so it stays where the
164
+ * user last saw it and can only be deleted.
165
+ *
166
+ * @param discussion - the block to re-anchor.
167
+ * @param lineOf - the new-file line number of a row, or `undefined` for a row that has none.
168
+ * @param rowCount - how many rows the current model has.
169
+ * @returns the re-anchored discussion (the same object when nothing moved).
170
+ */
171
+ export declare function remapDiscussion(discussion: Discussion, lineOf: (row: number) => number | undefined, rowCount: number): Discussion;
172
+ /** What the selection frame shows for the current selection. */
173
+ export interface SelectionFrameActions {
174
+ /** Whether keep/revert apply (the selection fully covers change blocks). */
175
+ keepRevert: boolean;
176
+ /** Whether a new discussion may be started on this range. */
177
+ comment: boolean;
178
+ /** Whether the frame renders at all (no visible action means no frame). */
179
+ visible: boolean;
180
+ }
181
+ /**
182
+ * Decide the selection frame's contents.
183
+ *
184
+ * Keep/revert needs covered change blocks; commenting needs a range that has no
185
+ * discussion yet. A frame with neither would be an empty floating box, so it is
186
+ * not rendered at all.
187
+ *
188
+ * @param options - whether change blocks are covered, and whether this range already has a discussion.
189
+ * @returns the three flags the caller branches on.
190
+ */
191
+ export declare function selectionFrame(options: {
192
+ coversBlocks: boolean;
193
+ hasDiscussion: boolean;
194
+ }): SelectionFrameActions;
@@ -48,6 +48,8 @@ export declare const zh: {
48
48
  'panel.diffDelColor': string;
49
49
  'panel.diffDelColorDesc': string;
50
50
  'panel.quickSummon': string;
51
+ 'panel.commentMode': string;
52
+ 'panel.commentModeDesc': string;
51
53
  'panel.quickSummonDesc': string;
52
54
  'panel.recordShortcut': string;
53
55
  'settings.keybindings': string;
@@ -61,6 +63,7 @@ export declare const zh: {
61
63
  'panel.key.jumpUp': string;
62
64
  'panel.key.jumpDown': string;
63
65
  'panel.key.copyRef': string;
66
+ 'panel.key.addComment': string;
64
67
  'panel.key.openSearch': string;
65
68
  'panel.key.searchNext': string;
66
69
  'panel.key.searchPrev': string;
@@ -81,6 +84,20 @@ export declare const zh: {
81
84
  'row.added': string;
82
85
  'row.removed': string;
83
86
  'action.keep': string;
87
+ 'action.comment': string;
88
+ 'action.more': string;
89
+ 'action.discussionExpand': string;
90
+ 'action.discussionCollapse': string;
91
+ 'discussion.placeholder': string;
92
+ 'discussion.marker': string;
93
+ 'discussion.promptRule': string;
94
+ 'discussion.promptRuleSkill': string;
95
+ 'discussion.thinking': string;
96
+ 'discussion.queued': string;
97
+ 'discussion.lost': string;
98
+ 'discussion.hidden': string;
99
+ 'discussion.failed': string;
100
+ 'discussion.stopped': string;
84
101
  'action.revert': string;
85
102
  'action.delete': string;
86
103
  'action.keepAll': string;
@@ -243,6 +260,8 @@ export declare const en: {
243
260
  'panel.diffDelColor': string;
244
261
  'panel.diffDelColorDesc': string;
245
262
  'panel.quickSummon': string;
263
+ 'panel.commentMode': string;
264
+ 'panel.commentModeDesc': string;
246
265
  'panel.quickSummonDesc': string;
247
266
  'panel.recordShortcut': string;
248
267
  'settings.keybindings': string;
@@ -256,6 +275,7 @@ export declare const en: {
256
275
  'panel.key.jumpUp': string;
257
276
  'panel.key.jumpDown': string;
258
277
  'panel.key.copyRef': string;
278
+ 'panel.key.addComment': string;
259
279
  'panel.key.openSearch': string;
260
280
  'panel.key.searchNext': string;
261
281
  'panel.key.searchPrev': string;
@@ -276,6 +296,20 @@ export declare const en: {
276
296
  'row.added': string;
277
297
  'row.removed': string;
278
298
  'action.keep': string;
299
+ 'action.comment': string;
300
+ 'action.more': string;
301
+ 'action.discussionExpand': string;
302
+ 'action.discussionCollapse': string;
303
+ 'discussion.placeholder': string;
304
+ 'discussion.marker': string;
305
+ 'discussion.promptRule': string;
306
+ 'discussion.promptRuleSkill': string;
307
+ 'discussion.thinking': string;
308
+ 'discussion.queued': string;
309
+ 'discussion.lost': string;
310
+ 'discussion.hidden': string;
311
+ 'discussion.failed': string;
312
+ 'discussion.stopped': string;
279
313
  'action.revert': string;
280
314
  'action.delete': string;
281
315
  'action.keepAll': string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Whether the follow animation can drive the frame, or the render has to place it.
3
+ *
4
+ * A scroller with nothing to scroll has an INACTIVE timeline. Measured in a browser: the
5
+ * animation reports a null current time and its transform is never applied at all, which
6
+ * left the frame wherever the render had put it — the wrapper's top edge, i.e. the top of
7
+ * the viewport — on a file short enough that the code does not scroll. There is nothing
8
+ * to follow in that case either, so the clamp at rest is already exact; the same holds
9
+ * before the box has been measured, and where the browser has no scroll timelines.
10
+ *
11
+ * @param hasTimeline - whether the browser supports scroll-driven animations.
12
+ * @param viewportHeight - the scroller's client height (0 before it is measured).
13
+ * @param maxScroll - the scroller's maximum `scrollTop` (0 when it does not scroll).
14
+ * @returns whether the animation, rather than the render, places the frame.
15
+ */
16
+ export declare function frameFollowIsAnimated(hasTimeline: boolean, viewportHeight: number, maxScroll: number): boolean;
17
+ /**
18
+ * The floating action frame's vertical position, as scroll-driven keyframes.
19
+ *
20
+ * The frame follows the row its anchor ends on — a point in the scroller's CONTENT
21
+ * coordinates — and is clamped into the visible area, so that it stays clickable when
22
+ * that row is off-screen. Written the direct way ("read `scrollTop`, subtract, clamp")
23
+ * it has to be recomputed on the main thread for every scroll event, which leaves it a
24
+ * frame behind the code the compositor has already moved. The same clamp expressed as
25
+ * keyframes over the scroll range becomes a scroll-driven animation, which the browser
26
+ * runs off the main thread: the code and the frame never disagree about the offset.
27
+ *
28
+ * The clamp is piecewise linear in the scroll offset, with exactly two corners — where
29
+ * the frame stops being held down by the viewport's bottom edge, and where it reaches
30
+ * the viewport's top edge and stays there — so listing those stops makes the animation
31
+ * reproduce it exactly, not approximately.
32
+ *
33
+ * @param anchorTop - the anchor's offset in the scroller's content, in px.
34
+ * @param maxScroll - the scroller's maximum `scrollTop` (0 when it does not scroll).
35
+ * @param viewportHeight - the scroller's client height, in px.
36
+ * @param frameHeight - the frame's own height, which the clamp has to keep on screen.
37
+ * @returns the keyframe stops: `offset` is the scroll progress (0-1) and `y` the frame's
38
+ * position in px from the viewport's top. Ascending, deduplicated, and including the
39
+ * corners; with nothing to scroll there is a single stop, the clamp being constant.
40
+ */
41
+ export declare function frameFollowKeyframes(anchorTop: number, maxScroll: number, viewportHeight: number, frameHeight: number): {
42
+ offset: number;
43
+ y: number;
44
+ }[];
@@ -198,6 +198,19 @@ export declare function setSplitMode(value: boolean): void;
198
198
  export declare function mdPreviewEnabled(): boolean;
199
199
  /** Persist the Markdown-preview default preference. */
200
200
  export declare function setMdPreviewEnabled(value: boolean): void;
201
+ /**
202
+ * Whether commenting on a diff range is offered: the selection frame's comment button,
203
+ * and the chord that stands for it. Defaults to off while the mode is a preview (see
204
+ * {@link COMMENT_MODE_PREVIEW_KEY}); only an explicit `'1'` enables it. This gates
205
+ * STARTING a thread, not the threads themselves — one already written keeps rendering,
206
+ * so turning the mode off never hides work or interrupts an answer on its way back.
207
+ * @returns whether comment mode is on.
208
+ */
209
+ export declare function commentModeEnabled(): boolean;
210
+ /** Window event: comment mode was toggled (see {@link setCommentModeEnabled}). */
211
+ export declare const COMMENT_MODE_CHANGED_EVENT = "diff-approval:comment-mode";
212
+ /** Persist the comment-mode preference. */
213
+ export declare function setCommentModeEnabled(value: boolean): void;
201
214
  /**
202
215
  * The Markdown-preview content max width, in pixels (single column). Defaults to
203
216
  * 800; the double-column view totals 2x this. An out-of-range or non-integer
@@ -2,6 +2,7 @@
2
2
  import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
3
3
  import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
4
4
  import type { DockSnapshot } from './dock.tsx';
5
+ import type { ChatView } from './chat-bridge.ts';
5
6
  import type { DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, PendingFileDiff, VcsImportValue } from '../types.ts';
6
7
  /** What the panel reads and drives: the pending list plus in-flight entries. */
7
8
  export interface PendingDiffSnapshot {
@@ -17,6 +18,12 @@ export interface PendingDiffSnapshot {
17
18
  failed?: ReadonlyMap<string, string> | undefined;
18
19
  /** The viewing session's workspace root (when it has one); enables workspace-relative references. */
19
20
  workspacePath?: string | undefined;
21
+ /**
22
+ * The answer-rules skill this host can deliver, or absent when it cannot (see the
23
+ * wire type). A comment prompt points at the skill when it is here, and carries the
24
+ * same rules inline when it is not.
25
+ */
26
+ commentSkill?: string | undefined;
20
27
  /** Latched when an external change created a fresh undo checkpoint that
21
28
  * superseded the redo history; the panel surfaces it once (deferred if the
22
29
  * panel is closed) via a bottom-right notice. */
@@ -62,6 +69,15 @@ export interface PendingPanelFace {
62
69
  onPreviewImage: (sessionId: SessionId, path: string) => Promise<string | undefined>;
63
70
  /** Paste a copied reference into the session's chat input and focus it. */
64
71
  onPasteReference: (sessionId: SessionId, reference: string) => void;
72
+ /**
73
+ * Send one prompt into the session as a real turn (the discussion's answer
74
+ * comes back through `watchChat`).
75
+ * @returns whether a send verb was available; false means the caller should
76
+ * fall back to the composer route.
77
+ */
78
+ onAskAgent: (sessionId: SessionId, text: string) => boolean;
79
+ /** Watch a session's transcript and turn state for a discussion's answer. */
80
+ watchChat: (sessionId: SessionId, listener: (view: ChatView) => void) => () => void;
65
81
  /** Undo the session's last keep/revert, then refresh the list; resolves to the affected entry id when it is still pending. */
66
82
  onUndo: (sessionId: SessionId) => Promise<string | undefined>;
67
83
  /** Redo the session's last undone keep/revert, then refresh the list; resolves to the affected entry id when it is still pending. */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one skill this plugin offers the agent: how to answer a diff comment.
3
+ *
4
+ * The rules live here rather than in the system prompt. The harness's own `skill`
5
+ * tool advertises every registered skill's name and description as a context message
6
+ * and loads the body on demand, so the long form costs nothing until a comment is
7
+ * actually being answered — and nothing this plugin registers can leak into unrelated
8
+ * turns the way a global system-prompt section would.
9
+ *
10
+ * The comment prompt itself still carries the short version (see
11
+ * `discussion.promptRule` in `src/client/locales.ts`) and names this skill, so the
12
+ * behavior survives a build whose harness has no skill registry at all. The two are
13
+ * kept in step by a test in `tests/diff-approval.spec.ts`.
14
+ */
15
+ /** The kebab-case name the comment prompt points the agent at. Prefixed with the whole
16
+ * package name: skill names share one flat namespace with no reverse-DNS scoping, so a
17
+ * short name like `diff-comment` is the kind of thing another plugin would also pick. */
18
+ export declare const COMMENT_SKILL_NAME = "dsh-diff-approval-comment";
19
+ /** Short routing line the skill catalog shows beside the name. */
20
+ export declare const COMMENT_SKILL_DESCRIPTION: string;
21
+ /** Extra routing guidance for the catalog. */
22
+ export declare const COMMENT_SKILL_WHEN_TO_USE: string;
23
+ /** The instruction body the agent loads, in the language its comments arrive in. */
24
+ export declare const COMMENT_SKILL_CONTENT = "# \u56DE\u7B54\u4EE3\u7801\u6279\u6CE8\n\n\u6D88\u606F\u4EE5 `[\u8BC4\u8BBA] (path:lines)` \u5F00\u5934\u65F6\uFF0C\u5C31\u662F diff \u9762\u677F\u91CC\u5BF9\u90A3\u51E0\u884C\u4EE3\u7801\u63D0\u7684\u6279\u6CE8\u3002\u6309\u4E0B\u9762\u7684\u89C4\u5219\u56DE\u7B54\uFF1A\n\n- \u53EA\u8C08\u8FD9\u51E0\u884C\uFF08\u6700\u591A\u5E26\u4E0A\u76F4\u63A5\u76F8\u90BB\u7684\u4E00\u4E24\u884C\uFF09\uFF0C\u4E0D\u8981\u6269\u5230\u6574\u4E2A\u6587\u4EF6\u6216\u6574\u4E2A\u4EFB\u52A1\u3002\n- \u4E0D\u8D85\u8FC7 3 \u884C\u3002\n- \u884C\u4E0E\u884C\u4E4B\u95F4\u4E0D\u8981\u7A7A\u884C\uFF1A\u8BA8\u8BBA\u533A\u6309\u4EE3\u7801\u884C\u6392\u7248\uFF0C\u4E00\u4E2A\u7A7A\u884C\u5C31\u5360\u6389\u4E00\u6574\u884C\u9AD8\u5EA6\uFF0C\u770B\u8D77\u6765\u50CF\u4EE3\u7801\u91CC\u7684\u7A7A\u6D1E\u3002\n- \u8981\u5F15\u7528\u4EE3\u7801\u5C31\u5199 `\u6587\u4EF6:\u884C\u53F7`\uFF0C\u4E0D\u8981\u628A\u6574\u6BB5\u8D34\u56DE\u6765\u3002\n- \u56DE\u7B54\u76F4\u63A5\u663E\u793A\u5728\u6279\u6CE8\u4E0B\u65B9\u7684\u5C0F\u8BA8\u8BBA\u533A\u91CC\uFF0C\u6240\u4EE5\u4E0D\u8981\u5199\"\u5982\u4F60\u6240\u89C1\"\"\u4E0A\u9762\u63D0\u5230\"\u8FD9\u7C7B\u6307\u4EE3\u3002\n- \u4E0D\u786E\u5B9A\u5C31\u8BF4\u4E0D\u786E\u5B9A\uFF0C\u5E76\u8BF4\u6E05\u9700\u8981\u770B\u54EA\u4E00\u6BB5\u3002\n- 3 \u884C\u91CC\u8BF4\u4E0D\u6E05\uFF08\u8981\u5C55\u5F00\u3001\u8981\u770B\u66F4\u591A\u4EE3\u7801\u3001\u8981\u6765\u56DE\u51E0\u8F6E\uFF09\uFF0C\u5C31\u76F4\u8BF4\u4E00\u53E5\"\u8FD9\u6761\u5728\u6279\u6CE8\u91CC\u8BF4\u4E0D\u6E05\uFF0C\u53BB\u4E0B\u65B9\u53D1\u6D88\u606F\u7684\u8F93\u5165\u6846\u91CC\u804A\"\uFF0C\u4E0D\u8981\u5728\u6279\u6CE8\u91CC\u786C\u585E\u957F\u56DE\u7B54\u3002\n";
25
+ /** One runtime skill contribution, in the shape `ctx.skills.register` takes. */
26
+ export interface CommentSkillRegistration {
27
+ /** Kebab-case identity the agent loads by. */
28
+ readonly name: string;
29
+ /** Catalog routing line. */
30
+ readonly description: string;
31
+ /** Catalog routing guidance. */
32
+ readonly whenToUse: string;
33
+ /** Instruction body. */
34
+ readonly content: string;
35
+ /** Origin bucket: this plugin contributes it at runtime, not from disk. */
36
+ readonly source: 'runtime';
37
+ }
38
+ /** The contribution to register with the skill registry. */
39
+ export declare const COMMENT_SKILL: CommentSkillRegistration;
@@ -50,6 +50,7 @@ export declare const name = "diff-approval";
50
50
  export declare const inject: string[];
51
51
  /** The connection RPC channel this plugin serves. */
52
52
  export declare const DIFF_APPROVAL_CHANNEL = "/diff-approval";
53
+ export { COMMENT_SKILL_NAME } from './comment-skill.ts';
53
54
  /**
54
55
  * Plugin configuration overridable from the profile's `cordis.patch.yml`.
55
56
  */