dsh-winrm 0.3.0 → 0.4.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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Transcript cards for `winrm_read`, `winrm_write` and `winrm_edit`.
3
+ *
4
+ * Without these the three tools render as the generic tool row, so the user
5
+ * reads the same envelope text the model reads. Every call becomes a collapsed
6
+ * disclosure with a one-line header — file-type icon, verb, remote path — that
7
+ * expands into a card. The header is drawn from the first dispatch, so settling
8
+ * only fills in the trailing figure instead of replacing the row: the line the
9
+ * user watched while it ran is the line they click to open the result.
10
+ *
11
+ * Collapsed by default is the harness's own convention for these rows: a long
12
+ * file would otherwise fill the transcript before the user asked for it.
13
+ *
14
+ * The header is `DisclosureRow` and the body is `ReadBlock` / `DiffBlock`, all
15
+ * from the harness's published `ui-primitives`, so a remote file looks and
16
+ * behaves exactly like a local one. Open state comes from the `useDisclosure`
17
+ * hook the Tool layer hands every toolview, which also resets it when the
18
+ * enclosing turn re-renders.
19
+ * @module dsh-winrm/src/client/tool/cards
20
+ */
21
+ import type { ReactNode } from 'react'
22
+ import {
23
+ DiffBlock, DisclosureRow, FileTypeIcon, ReadBlock,
24
+ type DiffBlockLabels, type DiffHunk, type ReadBlockLabels,
25
+ } from '@deepseek-ai/dsh-client-ui-primitives'
26
+ import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
27
+ import { tt } from '../panel/helpers.ts'
28
+ import { diffCard, readCard, type DiffCard as DiffCardModel, type ReadCard as ReadCardModel } from './card-model.ts'
29
+ import css from './cards.module.css'
30
+
31
+ /** The Tool view props these cards read. */
32
+ export type WinrmFileCardProps = ToolCallViewProps
33
+
34
+ /**
35
+ * Content lines a transcript card shows before the primitive collapses the
36
+ * middle. Half the primitive's own default (16), because a chat row summarizes
37
+ * inside a message flow while a dedicated panel is the reading surface. Mirrors
38
+ * the harness's `CHAT_READ_MAX_LINES` / `CHAT_DIFF_MAX_LINES`.
39
+ */
40
+ const CHAT_READ_MAX_LINES = 8
41
+ const CHAT_DIFF_MAX_LINES = 9
42
+
43
+ /** Grammar hint from a remote path's extension; unknown extensions stay plain. */
44
+ const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
45
+ bat: 'bat', cmd: 'bat', ps1: 'powershell', psd1: 'powershell', psm1: 'powershell',
46
+ c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
47
+ css: 'css', scss: 'scss', less: 'less',
48
+ go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift',
49
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
50
+ ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
51
+ json: 'json', jsonc: 'jsonc', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'ini',
52
+ md: 'markdown', markdown: 'markdown', rst: 'rst',
53
+ py: 'python', rb: 'ruby', php: 'php', pl: 'perl', lua: 'lua',
54
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
55
+ sql: 'sql', xml: 'xml', html: 'html', htm: 'html', vue: 'vue', svelte: 'svelte',
56
+ diff: 'diff', patch: 'diff', csv: 'csv', tsv: 'tsv',
57
+ }
58
+
59
+ /**
60
+ * Guess the grammar hint from a path's extension.
61
+ * @param path - the remote path as authored.
62
+ * @returns the language id, or undefined to keep the block plain.
63
+ */
64
+ function langOf(path: string): string | undefined {
65
+ const dot = path.lastIndexOf('.')
66
+ if (dot < 0) return undefined
67
+ const ext = path.slice(dot + 1).toLowerCase()
68
+ return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
69
+ }
70
+
71
+ /** Localized chrome for {@link ReadBlock}. */
72
+ function readLabels(): ReadBlockLabels {
73
+ return {
74
+ codeLabel: tt('tool.codeLabel'),
75
+ wrapLabel: tt('tool.wrapLabel'),
76
+ unwrapLabel: tt('tool.unwrapLabel'),
77
+ window: (shown, total) => tt('tool.window', { shown, total }),
78
+ copy: tt('tool.copy'),
79
+ copied: tt('tool.copied'),
80
+ collapseAria: tt('tool.collapseAria'),
81
+ expandAria: hidden => tt('tool.expandAria', { hidden }),
82
+ collapse: tt('tool.collapse'),
83
+ expand: hidden => tt('tool.expand', { hidden }),
84
+ }
85
+ }
86
+
87
+ /** Localized chrome for {@link DiffBlock}. */
88
+ function diffLabels(): DiffBlockLabels {
89
+ return {
90
+ codeLabel: tt('tool.codeLabel'),
91
+ wrapLabel: tt('tool.wrapLabel'),
92
+ unwrapLabel: tt('tool.unwrapLabel'),
93
+ copy: tt('tool.copy'),
94
+ copied: tt('tool.copied'),
95
+ collapseAria: tt('tool.collapseAria'),
96
+ expandAria: hidden => tt('tool.expandAria', { hidden }),
97
+ collapse: tt('tool.collapse'),
98
+ expand: hidden => tt('tool.expand', { hidden }),
99
+ }
100
+ }
101
+
102
+ /** One-line row for a call that failed: the message replaces the card. */
103
+ function Row(props: { title: string; detail: string }) {
104
+ return (
105
+ <div className={css.rowError}>
106
+ <span className={css.title}>{props.title}</span>
107
+ {props.detail !== '' && <span className={css.detail}>{props.detail}</span>}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ /** Read the persisted metadata off a settled result node. */
113
+ function metaOf(block: unknown): unknown {
114
+ if (typeof block !== 'object' || block === null) return undefined
115
+ return (block as { meta?: unknown }).meta
116
+ }
117
+
118
+ /**
119
+ * The remote path a call named, read from its arguments.
120
+ *
121
+ * Available from the first dispatch onward, which is what keeps the header line
122
+ * stable across the running → settled transition: the row keeps saying which
123
+ * file it is working on instead of swapping its text when the result lands. A
124
+ * settled node carries the call head under `call`; a running one is the head.
125
+ * @param block - any phase of the Tool block.
126
+ * @returns the authored path, or undefined when none was named yet.
127
+ */
128
+ function argsPath(block: unknown): string | undefined {
129
+ if (typeof block !== 'object' || block === null) return undefined
130
+ const record = block as { argsRaw?: unknown; call?: { argsRaw?: unknown } | null }
131
+ const head = record.call ?? record
132
+ if (typeof head.argsRaw !== 'string' || head.argsRaw === '') return undefined
133
+ let parsed: unknown
134
+ try {
135
+ parsed = JSON.parse(head.argsRaw)
136
+ } catch {
137
+ return undefined
138
+ }
139
+ if (typeof parsed !== 'object' || parsed === null) return undefined
140
+ const path = (parsed as { remotePath?: unknown }).remotePath
141
+ return typeof path === 'string' && path !== '' ? path : undefined
142
+ }
143
+
144
+ /** Count the lines a hunk adds and removes. */
145
+ function changedLines(card: DiffCardModel): { added: number; removed: number } {
146
+ let added = 0
147
+ let removed = 0
148
+ for (const hunk of card.hunks) {
149
+ added += hunk.newText === '' ? 0 : hunk.newText.split('\n').length
150
+ removed += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length
151
+ }
152
+ return { added, removed }
153
+ }
154
+
155
+ /** The `+n -m` figure a collapsed write or edit row shows beside its path. */
156
+ function changedTotals(card: DiffCardModel): { added: number; removed: number } | undefined {
157
+ const totals = changedLines(card)
158
+ return totals.added === 0 && totals.removed === 0 ? undefined : totals
159
+ }
160
+
161
+ /** The summary line a write or edit card shows under its diff. */
162
+ function diffSummary(card: DiffCardModel): string {
163
+ const parts: string[] = []
164
+ if (card.tool === 'write') {
165
+ parts.push(tt(card.operation === 'create' ? 'tool.created' : 'tool.updated', { bytes: card.bytes }))
166
+ } else {
167
+ parts.push(tt('tool.replacements', { count: card.replacements }))
168
+ }
169
+ if (card.encoding !== 'utf8') parts.push(card.encoding)
170
+ if (card.beyondBasis) parts.push(tt('tool.beyondBasis'))
171
+ return parts.join(' · ')
172
+ }
173
+
174
+ /**
175
+ * One collapsed file row that expands into a card.
176
+ * @param props - the disclosure state, the header material, and the card body.
177
+ * @returns the disclosure row, body included only while expanded.
178
+ */
179
+ function FileDisclosure(props: {
180
+ path: string
181
+ title: string
182
+ meta?: string | undefined
183
+ diff?: { added: number; removed: number } | undefined
184
+ open: boolean
185
+ expandable: boolean
186
+ running?: boolean
187
+ onToggle: () => void
188
+ children: ReactNode
189
+ }) {
190
+ const meta = props.meta ?? ''
191
+ const hasPath = props.path !== ''
192
+ return (
193
+ <div className={css.wrap}>
194
+ <DisclosureRow
195
+ icon={<FileTypeIcon path={props.path} size={14} />}
196
+ title={props.title}
197
+ open={props.open}
198
+ expandable={props.expandable}
199
+ running={props.running ?? false}
200
+ expandOnRowClick={props.expandable}
201
+ onToggle={props.onToggle}
202
+ collapsedContent={hasPath || meta !== '' ? (
203
+ <>
204
+ {hasPath && <span className={css.sep} aria-hidden />}
205
+ {hasPath && <span className={css.summary}>{props.path}</span>}
206
+ {meta !== '' && <span className={css.meta}>{meta}</span>}
207
+ {props.diff !== undefined && (
208
+ <span className={css.diffStat}>
209
+ <span className={css.added}>{`+${props.diff.added}`}</span>{' '}
210
+ <span className={css.removed}>{`-${props.diff.removed}`}</span>
211
+ </span>
212
+ )}
213
+ </>
214
+ ) : undefined}
215
+ >
216
+ {props.children}
217
+ </DisclosureRow>
218
+ </div>
219
+ )
220
+ }
221
+
222
+ /** Render the settled read card. */
223
+ function ReadView(props: { card: ReadCardModel; open: boolean; onToggle: () => void }) {
224
+ const { card, open, onToggle } = props
225
+ const notes: string[] = []
226
+ if (card.truncatedByBytes) notes.push(tt('tool.truncatedByBytes'))
227
+ if (card.truncatedLines > 0) notes.push(tt('tool.truncatedLines', { count: card.truncatedLines }))
228
+ if (card.encoding !== 'utf8') notes.push(card.encoding)
229
+ return (
230
+ <FileDisclosure
231
+ path={card.path}
232
+ title={tt('tool.read')}
233
+ meta={tt('tool.lines', { count: card.totalLines })}
234
+ open={open}
235
+ expandable
236
+ onToggle={onToggle}
237
+ >
238
+ <ReadBlock
239
+ label={card.path}
240
+ lines={card.lines}
241
+ totalLines={card.totalLines}
242
+ lang={langOf(card.path)}
243
+ labels={readLabels()}
244
+ maxLines={CHAT_READ_MAX_LINES}
245
+ />
246
+ {notes.length > 0 && <div className={css.note}>{notes.join(' · ')}</div>}
247
+ </FileDisclosure>
248
+ )
249
+ }
250
+
251
+ /** Render the settled write or edit card. */
252
+ function DiffView(props: { card: DiffCardModel; open: boolean; onToggle: () => void }) {
253
+ const { card, open, onToggle } = props
254
+ // The primitive takes `oldText: null` to mean "no prior content", which is a
255
+ // create; the host writes '' for that case so the two stay JSON-friendly.
256
+ const diffs: DiffHunk[] = card.hunks.map(hunk => ({
257
+ path: card.path,
258
+ oldText: hunk.oldText === '' ? null : hunk.oldText,
259
+ newText: hunk.newText,
260
+ }))
261
+ return (
262
+ <FileDisclosure
263
+ path={card.path}
264
+ title={tt(card.tool === 'write' ? 'tool.write' : 'tool.edit')}
265
+ diff={changedTotals(card)}
266
+ open={open}
267
+ expandable={diffs.length > 0}
268
+ onToggle={onToggle}
269
+ >
270
+ {diffs.length > 0 && (
271
+ <DiffBlock diffs={diffs} labels={diffLabels()} maxLines={CHAT_DIFF_MAX_LINES} />
272
+ )}
273
+ <div className={css.note}>{diffSummary(card)}</div>
274
+ </FileDisclosure>
275
+ )
276
+ }
277
+
278
+ /** Shared driver: one card per stage, with the model built from the metadata. */
279
+ function FileCardView(props: WinrmFileCardProps & { tool: 'read' | 'write' | 'edit' }) {
280
+ const { phase, block, tool, useDisclosure } = props
281
+ const disclosure = useDisclosure()
282
+ const title = tt(`tool.${tool}`)
283
+ const path = argsPath(block) ?? ''
284
+ if (phase !== 'result') {
285
+ return (
286
+ <FileDisclosure
287
+ path={path}
288
+ title={title}
289
+ meta={path === '' ? tt('tool.running') : undefined}
290
+ open={false}
291
+ expandable={false}
292
+ running
293
+ onToggle={() => {}}
294
+ >
295
+ {null}
296
+ </FileDisclosure>
297
+ )
298
+ }
299
+ const meta = metaOf(block)
300
+ const card = tool === 'read' ? readCard(meta, path) : diffCard(meta, path, tool)
301
+ if (card === undefined) {
302
+ return (
303
+ <FileDisclosure
304
+ path={path}
305
+ title={title}
306
+ meta={tt('tool.noDetail')}
307
+ open={false}
308
+ expandable={false}
309
+ onToggle={() => {}}
310
+ >
311
+ {null}
312
+ </FileDisclosure>
313
+ )
314
+ }
315
+ if (card.kind === 'error') return <Row title={title} detail={card.message} />
316
+ if (card.kind === 'read') {
317
+ return <ReadView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
318
+ }
319
+ return <DiffView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
320
+ }
321
+
322
+ /** Card for `winrm_read`. */
323
+ export function WinrmReadCard(props: WinrmFileCardProps) {
324
+ return <FileCardView {...props} tool="read" />
325
+ }
326
+
327
+ /** Card for `winrm_write`. */
328
+ export function WinrmWriteCard(props: WinrmFileCardProps) {
329
+ return <FileCardView {...props} tool="write" />
330
+ }
331
+
332
+ /** Card for `winrm_edit`. */
333
+ export function WinrmEditCard(props: WinrmFileCardProps) {
334
+ return <FileCardView {...props} tool="edit" />
335
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Contextual diff derivation for the write/edit presentation metadata.
3
+ *
4
+ * The GUI renders a diff card from `tool/result` metadata rather than from the
5
+ * model-facing text, so the hunks are projected here, at result time, and
6
+ * persisted with the session log. This mirrors the harness's own file tools:
7
+ * one entry per applied change with a few surrounding context lines.
8
+ *
9
+ * The alignment is deliberately cheap rather than a full LCS: common leading
10
+ * and trailing lines are trimmed, and when the remaining halves have the same
11
+ * length each differing run becomes its own hunk. That covers the cases these
12
+ * two tools actually produce — an exact-string edit, and a rewrite that keeps
13
+ * most lines — without a diff dependency in a plugin whose point is a small
14
+ * footprint. A rewrite that changes the line count collapses to one hunk over
15
+ * the changed span, which is still a truthful summary.
16
+ * @module dsh-winrm/src/diff
17
+ */
18
+
19
+ /** Context lines shown on each side of an applied hunk. */
20
+ export const DIFF_CONTEXT = 3
21
+
22
+ /**
23
+ * Largest file whose text may serve as a diff basis. Above this the card shows
24
+ * the path and operation without hunks: fetching and projecting a bigger basis
25
+ * would cost another transfer and a large metadata payload for a summary the
26
+ * user reads at a glance.
27
+ */
28
+ export const DIFF_BASIS_MAX_BYTES = 256 * 1024
29
+
30
+ /**
31
+ * Largest serialized hunk list kept on one result. A pathological edit (a
32
+ * whole-file rewrite of a large file) can produce thousands of hunks; past
33
+ * this bound the card degrades to path-only instead of bloating the log.
34
+ */
35
+ export const DIFF_META_MAX_BYTES = 512 * 1024
36
+
37
+ /**
38
+ * One applied hunk: the fragment before the change and after it.
39
+ *
40
+ * Declared as a type alias rather than an interface so it satisfies the
41
+ * `JsonValue` index signature the tool-output metadata is typed against.
42
+ */
43
+ export type RemoteFileDiff = {
44
+ /** Remote path the hunk belongs to (the authored argument). */
45
+ path: string
46
+ /** Prior content including context lines; empty for a pure insertion. */
47
+ oldText: string
48
+ /** Content after the change, including the shared context lines. */
49
+ newText: string
50
+ }
51
+
52
+ /** Split text into lines, treating every terminator as a separator. */
53
+ function toLines(text: string): string[] {
54
+ return text === '' ? [] : text.split(/\r?\n/)
55
+ }
56
+
57
+ /**
58
+ * Compute the hunks between two texts.
59
+ * @param path - remote path stamped on every hunk.
60
+ * @param before - file text before the change.
61
+ * @param after - file text after the change.
62
+ * @returns one hunk per contiguous change in file order; empty when equal.
63
+ */
64
+ export function computeHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
65
+ if (before === after) return []
66
+ const oldLines = toLines(before)
67
+ const newLines = toLines(after)
68
+
69
+ let head = 0
70
+ while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1
71
+ let tail = 0
72
+ while (
73
+ tail < oldLines.length - head
74
+ && tail < newLines.length - head
75
+ && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]
76
+ ) tail += 1
77
+
78
+ const oldMid = oldLines.slice(head, oldLines.length - tail)
79
+ const newMid = newLines.slice(head, newLines.length - tail)
80
+
81
+ /** Build one hunk around the changed range `[from, to)` in the middle. */
82
+ const emit = (from: number, to: number): RemoteFileDiff => {
83
+ // Both mid arrays have the same length in the aligned branch below, so one
84
+ // clamped window addresses the old and the new side alike.
85
+ const start = Math.max(0, from - DIFF_CONTEXT)
86
+ const stop = Math.min(oldMid.length, to + DIFF_CONTEXT)
87
+ return {
88
+ path,
89
+ oldText: oldLines.slice(head + start, head + stop).join('\n'),
90
+ newText: newLines.slice(head + start, head + stop).join('\n'),
91
+ }
92
+ }
93
+
94
+ if (oldMid.length !== newMid.length) {
95
+ // Unaligned rewrite: one hunk over the whole changed span.
96
+ return [{
97
+ path,
98
+ oldText: oldLines.slice(Math.max(0, head - DIFF_CONTEXT), oldLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
99
+ newText: newLines.slice(Math.max(0, head - DIFF_CONTEXT), newLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
100
+ }]
101
+ }
102
+
103
+ const diffs: RemoteFileDiff[] = []
104
+ let index = 0
105
+ while (index < oldMid.length) {
106
+ if (oldMid[index] === newMid[index]) {
107
+ index += 1
108
+ continue
109
+ }
110
+ const from = index
111
+ while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1
112
+ diffs.push(emit(from, index))
113
+ }
114
+ return diffs
115
+ }
116
+
117
+ /**
118
+ * Compute hunks and drop them when the serialized payload would exceed the cap.
119
+ * @param path - remote path stamped on every hunk.
120
+ * @param before - prior text.
121
+ * @param after - text after the change.
122
+ * @returns the kept hunks; empty when nothing changed or the payload is too big.
123
+ */
124
+ export function boundedHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
125
+ const diffs = computeHunkDiffs(path, before, after)
126
+ if (diffs.length === 0) return diffs
127
+ return JSON.stringify(diffs).length > DIFF_META_MAX_BYTES ? [] : diffs
128
+ }