diff --git a/README.md b/README.md index 3c9d659..b4a005f 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,13 @@ Turn the guard off with `guard: false`, or change the list of blocked tools with ## Diff cards in the Web UI -The plugin ships a web client (`client.js`) that registers an `edit` tool view. Hashline, sloppy and apply_patch calls render as a diff card instead of raw patch text: -- green added rows, red removed rows and gray context, using only DSH's theme tokens (`--dsw-alias-code-diff-added/-deleted`, `--dsw-alias-state-success/error-primary`, the code-block font), so it follows the active theme like the stock diff view; -- a **line-number gutter** with the old and new file line of every row; -- `+N -M` counts in the header, plus Wrap and Copy buttons. +The plugin ships a web client (`client.js`) that registers an `edit` tool view. Hashline, sloppy and apply_patch calls would otherwise show raw patch JSON; instead they render with the **same chrome as DSH's own tool rows**, built from DSH's primitives (`DisclosureRow`, `TextShimmer`, `Tooltip`, its icons) with CSS copied from the stock `ToolRow`, `CodeCard` and `DiffBlock` modules: +- the standard 24px row: edit icon, `Edit` title, a dot separator, the file path as a dotted link that opens the file, and a small monospace `+N -M` that turns green/red on hover or when expanded. Rows start collapsed and follow the turn's expand/collapse state, like every other tool; +- a failed edit shows its first error line in red, and expanding it shows the standard **Output** card; +- the expanded diff is the stock code card: a language header with the wrap and copy icon buttons, 22px-padded rows with the inset +/- bar, and the head/tail fold after 9 rows; +- one addition: a **line-number gutter** with the old and new file line of every row. + +Colors come only from DSH theme tokens, so the view follows the active theme. The host attaches `oldStart`, `newStart` and `rows` to every hunk in `meta.diffs`, next to the standard `path`/`oldText`/`newText` fields. Older results without them still render, just without numbers. @@ -195,7 +198,7 @@ npm test # node --test test/*.test.mjs — 34 tests (engine + integration on | File | Role | | --- | --- | | `index.js` | DSH plugin: per-agent tool registration, mode selection, fs policy, paging, guard rule | -| `client.js` | Web client: `edit` tool view rendering hashline/patch results as the native diff card | +| `client.js` | Web client: `edit` tool view rendering hashline/patch results with DSH's native tool-row and diff-card chrome | | `lib/hashline.js` | Hashline parser and applier | | `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) | | `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase | diff --git a/client.js b/client.js index 71c77cb..7db23f3 100644 --- a/client.js +++ b/client.js @@ -1,70 +1,126 @@ -// omp-edit Web client: render hashline / patch edits as a DSH-native diff card with line numbers. +// omp-edit Web client: render hashline / patch edits with DSH's own tool-row chrome. // // The stock `edit` row derives its diff from `file_path` + `old_string`/`new_string` arguments. // Hashline, sloppy and apply_patch calls carry one `input` string instead, so the stock row falls -// back to raw text. This keyed toolview (priority -1 shadows the stock one) renders the applied +// back to raw JSON. This keyed toolview (priority -1 shadows the stock one) renders the applied // hunks the host persists in `meta.diffs`. // -// Styling uses only DSH theme tokens (the same ones DiffBlock.module.css uses: --dsw-alias-code- -// diff-added/-deleted, --dsw-alias-state-success-/error-primary, --dsw-font-markdown-code-block…) -// so it follows every theme. Each row shows its old/new file line numbers in a gutter, from the -// `oldStart`/`newStart`/`rows` the host attaches to every hunk (older results without them fall -// back to un-numbered rows). +// It is assembled from the same parts as the stock ToolRow + DiffBlock so it lines up with every +// other tool row: the DisclosureRow / TextShimmer / Tooltip primitives and icons, the turn-scoped +// `useDisclosure` open state (collapsed by default), the dotted file link that opens the file, +// the small `+N -M` stat that only takes color on hover/expand, the red first-line error summary +// with an Output card, and a code card whose rules are copied from CodeCard/DiffBlock.module.css +// (language header with icon actions, 22px rows, inset +/- bar, head/tail fold at 9 rows). +// The one addition is the old/new line-number gutter, from the `oldStart`/`newStart`/`rows` the +// host attaches to every hunk (older results without them render un-numbered, like stock). window.__ModuleLoader__.load({ id: '@local/omp-edit', factory(require) { const React = require('react'); const P = require('@deepseek-ai/dsh-client-ui-primitives'); const h = React.createElement; + const Fragment = React.Fragment; + /** Rows shown before the fold, as in the stock chat diff card (CHAT_DIFF_MAX_LINES). */ + const MAX_LINES = 9; + + // Rules mirror ToolRow.module.css, CodeCard.module.css and DiffBlock.module.css of DSH. const CSS = ` -.ompe-row{display:flex;flex-direction:column;gap:4px;min-width:0} -.ompe-head{display:flex;align-items:center;gap:6px;min-width:0;min-height:24px;padding:0 4px;margin:0 -4px;border:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;border-radius:var(--dsw-radius-sm);text-align:left} -.ompe-head:hover{background:var(--dsw-alias-interactive-bg-hover)} -.ompe-head:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))} -.ompe-icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-tertiary)} -.ompe-title{color:var(--dsw-alias-label-primary);flex:none} -.ompe-sum{color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} -.ompe-add{color:var(--dsw-alias-state-success-primary);flex:none;font-variant-numeric:tabular-nums} -.ompe-del{color:var(--dsw-alias-state-error-primary);flex:none;font-variant-numeric:tabular-nums} -.ompe-err{color:var(--dsw-alias-state-error-primary)} -.ompe-card{border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-alias-bg-l2,var(--dsw-alias-fill-l1));border:1px solid var(--dsw-alias-border-l1,transparent);overflow:hidden} -.ompe-tools{display:flex;align-items:center;gap:6px;height:30px;padding:0 6px 0 12px;color:var(--dsw-alias-label-tertiary);font-size:12px} -.ompe-tools-path{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.ompe-tbtn{border:0;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;height:24px;padding:0 6px;border-radius:var(--dsw-radius-sm);cursor:pointer} -.ompe-tbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)} -.ompe-body{display:grid;grid-template-columns:auto auto minmax(max-content,1fr);padding:2px 0 8px;font:var(--dsw-font-markdown-code-block);overflow-x:auto;max-height:480px;overflow-y:auto} -.ompe-body[data-wrap]{grid-template-columns:auto auto minmax(0,1fr)} -.ompe-ln{padding:0 8px 0 10px;text-align:right;color:var(--dsw-alias-label-tertiary);opacity:.7;user-select:none;font-variant-numeric:tabular-nums;white-space:pre} -.ompe-ln+.ompe-ln{padding-left:0} -.ompe-code{padding:0 16px 0 6px;white-space:pre;min-height:1lh} -.ompe-body[data-wrap] .ompe-code{white-space:pre-wrap;overflow-wrap:anywhere} -.ompe-code::before{display:inline-block;width:1.5ch;content:' '} -.ompe-r-add>.ompe-code::before{content:'+'} -.ompe-r-del>.ompe-code::before{content:'-'} -.ompe-r{display:contents} -.ompe-r-ctx>*{color:var(--dsw-alias-label-secondary)} -.ompe-r-add>*{background:var(--dsw-alias-code-diff-added)} -.ompe-r-add>.ompe-code{color:var(--dsw-alias-state-success-primary)} -.ompe-r-add>.ompe-ln:first-child{box-shadow:inset 3px 0 0 var(--dsw-alias-state-success-primary)} -.ompe-r-del>*{background:var(--dsw-alias-code-diff-deleted)} -.ompe-r-del>.ompe-code{color:var(--dsw-alias-state-error-primary)} -.ompe-r-del>.ompe-ln:first-child{box-shadow:inset 3px 0 0 var(--dsw-alias-state-error-primary)} -.ompe-r-path>*{color:var(--dsw-alias-label-secondary);font-weight:500;padding-top:6px} -.ompe-r-gap>*{color:var(--dsw-alias-label-tertiary)} -.ompe-text{margin:0;padding:8px 12px;font:var(--dsw-font-markdown-code-block);white-space:pre-wrap;color:var(--dsw-alias-label-secondary);max-height:320px;overflow:auto} +.ompe-root{display:flex;flex-direction:column} +.ompe-leading{flex-shrink:0} +.ompe-title{font-weight:400} +.ompe-sep{background:var(--dsw-alias-label-caption);border-radius:1px;flex:none;width:2px;height:2px;margin:0 8px} +.ompe-summary{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));flex:auto;overflow:hidden} +.ompe-error-summary{color:var(--dsw-alias-state-error-primary)} +.ompe-stopped-summary{color:var(--dsw-alias-state-warn-label)} +.ompe-file{text-overflow:ellipsis;white-space:nowrap;min-width:0;font:inherit;text-align:left;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:inherit;text-underline-offset:3px;cursor:pointer;background:0 0;border:none;flex:0 auto;margin:0;padding:0;text-decoration:underline 1px dotted;overflow:hidden} +.ompe-suffix{white-space:nowrap;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));flex:none;margin-left:4px} +.ompe-stat{font-family:var(--ds-font-family-code);font-size:calc(var(--dsh-content-font-size-secondary,13px) - 2px);margin-left:10px;transform:translateY(.5px)} +.ompe-row:is(:hover,[aria-expanded=true]) .ompe-added{color:var(--dsw-alias-state-success-primary)} +.ompe-row:is(:hover,[aria-expanded=true]) .ompe-removed{color:var(--dsw-alias-state-error-primary)} +.ompe-hidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden} +.ompe-body-wrap{flex-direction:column;display:flex} +.ompe-inspect{border:.5px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);cursor:pointer;opacity:0;border-radius:999px;align-self:flex-start;align-items:center;gap:4px;margin:4px 0 2px 4px;padding:2px 8px;font-size:11px;line-height:16px;transition:opacity .1s;display:inline-flex} +.ompe-root:hover .ompe-inspect,.ompe-inspect:focus-visible{opacity:1} +.ompe-inspect:hover{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-label-primary)} +.ompe-io-card{border:.5px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-markdown-code-block);font:var(--dsw-font-markdown-code-block-small);flex-direction:column;margin:4px 0 4px 4px;display:flex} +.ompe-io-section{grid-template-columns:max-content 1fr;align-items:baseline;column-gap:14px;max-height:150px;padding:12px 16px;display:grid;overflow-y:auto} +.ompe-io-label{color:var(--dsw-alias-label-caption);align-self:start;position:sticky;top:0} +.ompe-io-text{white-space:pre-wrap;word-break:break-word;min-width:0;color:var(--dsw-alias-label-secondary)} +.ompe-io-text[data-error]{color:var(--dsw-alias-state-error-primary)} +.ompe-card{position:relative;margin:4px 0 4px 4px;overflow:hidden;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-markdown-code-block);border-radius:var(--dsw-radius-lg);font:var(--dsw-font-markdown-code-block)} +.ompe-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 18px 8px 22px;background:var(--dsl-code-block-background,var(--dsw-alias-markdown-code-block));border-top-left-radius:inherit;border-top-right-radius:inherit;color:var(--dsw-alias-label-secondary);font:11px/18px var(--dsw-font-family)} +.ompe-card-heading{display:flex;align-items:baseline;gap:12px;min-width:0} +.ompe-card-lang{color:var(--dsw-alias-label-tertiary);flex:none;font-family:var(--ds-font-family-code)} +.ompe-card-actions{display:flex;align-items:center;gap:4px;flex:none} +.ompe-card-action{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);background:transparent;cursor:pointer} +.ompe-card-action:hover{background:var(--dsw-alias-interactive-bg-hover)} +.ompe-card-action:focus-visible{outline:1px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +.ompe-body{display:grid;grid-template-columns:minmax(max-content,1fr);padding:6px 0 20px;font:var(--dsw-font-markdown-code-block);overflow-x:auto} +.ompe-body[data-numbered]{grid-template-columns:auto auto minmax(max-content,1fr)} +.ompe-card[data-code-wrap=true] .ompe-body{grid-template-columns:minmax(0,1fr)} +.ompe-card[data-code-wrap=true] .ompe-body[data-numbered]{grid-template-columns:auto auto minmax(0,1fr)} +.ompe-l{display:contents} +.ompe-full,.ompe-ln,.ompe-code{box-sizing:border-box;min-height:1lh;white-space:pre} +.ompe-full{grid-column:1/-1;padding:0 22px} +.ompe-code{padding:0 22px} +.ompe-body[data-numbered] .ompe-code{padding-left:12px} +.ompe-ln{padding:0 0 0 12px;text-align:right;font-variant-numeric:tabular-nums;user-select:none} +.ompe-ln:first-child{padding-left:22px} +.ompe-card[data-code-wrap=true] .ompe-code,.ompe-card[data-code-wrap=true] .ompe-full{white-space:pre-wrap;overflow-wrap:anywhere} +.ompe-path{color:var(--dsw-alias-label-secondary);font-weight:500} +.ompe-gap{color:var(--dsw-alias-label-tertiary)} +.ompe-del>*{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-code-diff-deleted)} +.ompe-del>:first-child{box-shadow:inset 3px 0 0 var(--dsw-alias-state-error-primary)} +.ompe-del>.ompe-code::before{content:'- '} +.ompe-add>*{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-code-diff-added)} +.ompe-add>:first-child{box-shadow:inset 3px 0 0 var(--dsw-alias-state-success-primary)} +.ompe-add>.ompe-code::before{content:'+ '} +.ompe-ctx>*{color:var(--dsw-alias-label-secondary)} +.ompe-ctx>.ompe-code::before{content:' '} +.ompe-l>.ompe-ln{color:var(--dsw-alias-label-tertiary)} +.ompe-expand{grid-column:1/-1;display:block;width:100%;padding:0 22px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;text-align:left} +.ompe-expand:hover{color:var(--dsw-alias-label-secondary)} `; - let cssDone = false; - const installCss = () => { - if (cssDone || typeof document === 'undefined') return; + if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="@local/omp-edit"]')) { const s = document.createElement('style'); - s.dataset.ompEdit = ''; + s.dataset.plugin = '@local/omp-edit'; + s.dataset.pluginCss = '@local/omp-edit'; s.textContent = CSS; document.head.appendChild(s); - cssDone = true; - }; + } - const Icon = P.IconEditOutlineRegular || (() => null); + const none = () => null; + const IconEdit = P.IconEditOutlineRegular || none; + const IconWrap = P.IconWrapFillRegular || none; + const IconNowrap = P.IconNowrapFillRegular || none; + const IconCopy = P.IconCopyOutlineRegular || none; + const IconCheck = P.IconCheckOutlineRegular || none; + const IconInspect = P.IconInspectOutlineRegular || null; + const Tooltip = P.Tooltip || (({ children }) => children); + const Shimmer = P.TextShimmer || (({ children, className }) => h('span', { className }, children)); + const EDIT_ICON = h(IconEdit, { size: 14 }); + + const cls = (...xs) => xs.filter(Boolean).join(' '); + /** Locale lookup with a fallback for keys a DSH build may lack. */ + const tr = (t, key, fallback, params) => { + const v = typeof t === 'function' ? t(key, params) : undefined; + return typeof v === 'string' && v !== '' && v !== key ? v : fallback; + }; + const firstLine = (s) => { const i = s.indexOf('\n'); return i === -1 ? s : s.slice(0, i); }; + + function displayPath(p, cwd, home) { + let out = p; + if (cwd) { + const root = cwd.replace(/[/\\]+$/, ''); + if (out.startsWith(`${root}/`)) out = out.slice(root.length + 1); + } + if (home) { + const root = home.replace(/\/+$/, ''); + if (root && root !== '/' && out.startsWith(`${root}/`)) out = `~${out.slice(root.length)}`; + } + return out; + } function readDiffs(meta) { const d = meta && typeof meta === 'object' && Array.isArray(meta.diffs) ? meta.diffs : []; @@ -89,82 +145,199 @@ window.__ModuleLoader__.load({ }); } + /** Flatten hunks like DiffBlock: a path header per new file, `⋯` between same-file hunks. */ function buildRows(diffs) { const out = []; let prev; for (const d of diffs) { - if (d.path !== prev) out.push({ kind: 'path', text: d.path, old: '', neu: '' }); - else out.push({ kind: 'gap', text: '⋯', old: '', neu: '' }); + out.push(d.path !== prev ? { kind: 'path', text: d.path } : { kind: 'gap', text: '⋯' }); prev = d.path; out.push(...hunkRows(d)); } return out; } - const totals = (rows) => rows.reduce((a, r) => ({ add: a.add + (r.kind === 'add'), del: a.del + (r.kind === 'del') }), { add: 0, del: 0 }); + const totals = (rows) => rows.reduce((a, r) => ({ added: a.added + (r.kind === 'add'), removed: a.removed + (r.kind === 'del') }), { added: 0, removed: 0 }); - function argsSummary(argsRaw) { + /** Files named by the call arguments (hashline headers, apply_patch markers, or file_path). */ + function argFiles(argsRaw) { try { const a = JSON.parse(argsRaw || '{}'); - if (typeof a.file_path === 'string') return a.file_path; + if (typeof a.file_path === 'string') return [a.file_path]; + if (typeof a.path === 'string') return [a.path]; if (typeof a.input === 'string') { const files = [...a.input.matchAll(/^\[(.+?)#[0-9A-Fa-f]{4}\]\s*$/gm)].map((m) => m[1]); const more = [...a.input.matchAll(/^\*\*\* (?:Edit|Update|Add|Delete) File:\s*(.+?)(?:\s+all)?\s*$/gm)].map((m) => m[1]); - return [...new Set([...files, ...more])].join(', '); + return [...new Set([...files, ...more])]; } } catch { /* partial arguments */ } - return ''; + return []; } - function copyText(rows) { - return rows.map((r) => (r.kind === 'add' ? `+ ${r.text}` : r.kind === 'del' ? `- ${r.text}` : r.kind === 'ctx' ? ` ${r.text}` : r.text)).join('\n'); + /** Mirrors the stock resultText: text parts, other blocks as JSON, else the error name/code. */ + function resultText(block) { + const parts = []; + for (const c of Array.isArray(block.content) ? block.content : []) parts.push(c.type === 'text' ? c.text : JSON.stringify(c, null, 2)); + if (!parts.length && block.error) parts.push(`${block.error.name}: ${block.error.code}`); + return parts.join('\n'); } - function DiffCard({ rows, files, t }) { - const [wrap, setWrap] = React.useState(false); + const copyText = (rows) => rows.map((r) => (r.kind === 'add' ? `+ ${r.text}` : r.kind === 'del' ? `- ${r.text}` : r.kind === 'ctx' ? ` ${r.text}` : r.text)).join('\n'); + + function DiffCard({ rows, paths, t }) { + const [expanded, setExpanded] = React.useState(false); const [copied, setCopied] = React.useState(false); - const onCopy = () => { - navigator.clipboard?.writeText(copyText(rows)).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1000); }).catch(() => {}); - }; - const tr = (k, fb) => (typeof t === 'function' && t(k)) || fb; - return h('div', { className: 'ompe-card', 'data-diff': '' }, - h('div', { className: 'ompe-tools' }, - h('span', { className: 'ompe-tools-path' }, files), - h('button', { type: 'button', className: 'ompe-tbtn', onClick: () => setWrap((v) => !v) }, wrap ? tr('code.unwrap', 'No wrap') : tr('code.wrap', 'Wrap')), - h('button', { type: 'button', className: 'ompe-tbtn', onClick: onCopy }, copied ? tr('copied', 'Copied') : tr('copy', 'Copy'))), - h('div', { className: 'ompe-body', 'data-wrap': wrap ? '' : undefined }, - rows.map((r, i) => h('div', { key: i, className: `ompe-r ompe-r-${r.kind}` }, - h('span', { className: 'ompe-ln' }, r.old), - h('span', { className: 'ompe-ln' }, r.neu), - h('span', { className: 'ompe-code' }, r.text))))); + const [wrapped, setWrapped] = React.useState(false); + const langs = paths.map((p) => (typeof P.languageForPath === 'function' ? P.languageForPath(p) : undefined)); + const lang = langs.every((l) => l === langs[0]) ? langs[0] : undefined; + const numbered = rows.some((r) => r.old || r.neu); + const onCopy = React.useCallback(() => { + if (copied) return; + const text = copyText(rows); + const write = typeof P.writeClipboard === 'function' + ? P.writeClipboard(text) + : (navigator.clipboard?.writeText(text) ?? Promise.reject()).then(() => true); + Promise.resolve(write).then((ok) => { + if (!ok) return; + setCopied(true); + window.setTimeout(() => setCopied(false), 1000); + }).catch(() => {}); + }, [copied, rows]); + + const hidden = rows.length - MAX_LINES; + const capped = hidden > 0 && !expanded; + const headN = Math.ceil(MAX_LINES / 2); + const head = capped ? rows.slice(0, headN) : rows; + const tail = capped ? rows.slice(rows.length - (MAX_LINES - headN)) : []; + const line = (prefix) => (r, i) => (r.kind === 'path' || r.kind === 'gap' + ? h('div', { key: prefix + i, className: `ompe-full ompe-${r.kind}` }, r.text) + : h('div', { key: prefix + i, className: `ompe-l ompe-${r.kind}` }, + numbered ? h('span', { className: 'ompe-ln' }, r.old) : null, + numbered ? h('span', { className: 'ompe-ln' }, r.neu) : null, + h('span', { className: 'ompe-code' }, r.text))); + + const wrapLabel = tr(t, 'codeBlock.wrap', 'Wrap'); + const unwrapLabel = tr(t, 'codeBlock.unwrap', 'No wrap'); + const copyLabel = copied ? tr(t, 'copied', 'Copied') : tr(t, 'copy', 'Copy'); + return h('div', { className: 'ompe-card', 'data-diff': '', 'data-code-wrap': wrapped }, + h('div', { className: 'ompe-card-head', 'data-code-block-banner': true }, + h('div', { className: 'ompe-card-heading' }, + h('span', { className: 'ompe-card-lang' }, lang || tr(t, 'codeBlock.title', 'Code'))), + h('div', { className: 'ompe-card-actions' }, + h(Tooltip, { label: wrapped ? unwrapLabel : wrapLabel, side: 'top', portal: true }, + h('button', { type: 'button', className: 'ompe-card-action', 'aria-label': wrapLabel, 'aria-pressed': wrapped, onClick: () => setWrapped((v) => !v) }, + h(wrapped ? IconNowrap : IconWrap, { size: 14 }))), + h(Tooltip, { label: copyLabel, side: 'top', portal: true }, + h('button', { type: 'button', className: 'ompe-card-action', 'aria-label': copyLabel, onClick: onCopy }, + h(copied ? IconCheck : IconCopy, { size: 14 }))))), + h('div', { className: 'ompe-body', 'data-numbered': numbered ? '' : undefined }, + head.map(line('h')), + hidden > 0 ? h('button', { + type: 'button', + className: 'ompe-expand', + 'aria-expanded': expanded, + 'aria-label': expanded ? tr(t, 'diff.collapseAria', 'Collapse diff') : tr(t, 'diff.expandAria', `Show ${hidden} more lines`, { count: hidden }), + onClick: () => setExpanded((v) => !v), + }, expanded ? tr(t, 'collapse', 'Collapse') : tr(t, 'diff.expandRest', `⋯ ${hidden} more lines`, { count: hidden })) : null, + tail.map(line('t')))); } + function useLocalDisclosure() { + const [expanded, setExpanded] = React.useState(false); + const toggle = React.useCallback(() => setExpanded((v) => !v), []); + return { expanded, setExpanded, toggle }; + } + + const stopKeys = (e) => { if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); }; + + /** The shared row: DisclosureRow + summary/stat chrome exactly like the stock ToolRow. */ + function Row({ props, state, summary, filePath, stat, body }) { + const { t, toolName, openFile, inspect } = props; + const disclosure = (typeof props.useDisclosure === 'function' ? props.useDisclosure : useLocalDisclosure)(); + const expandable = state !== 'preparing' && body !== null; + const open = disclosure.expanded && expandable; + const settledWithCue = state === 'error' || state === 'stopped'; + const onOpen = React.useMemo(() => (filePath && typeof openFile === 'function' && !settledWithCue + ? (e) => { e.stopPropagation(); openFile(filePath); } : undefined), [filePath, openFile, settledWithCue]); + const suffix = settledWithCue ? null : stat; + const status = state === 'preparing' ? tr(t, 'row.preparing', '') + : state === 'running' ? tr(t, 'row.running', '') + : state === 'error' ? tr(t, 'row.failed', '') + : state === 'stopped' ? tr(t, 'row.stopped', '') : ''; + + const collapsed = summary !== '' ? h(Fragment, null, + h('span', { className: 'ompe-sep', 'data-shimmer-decoration': true, 'aria-hidden': true }), + onOpen + ? h('button', { type: 'button', className: 'ompe-file', onClick: onOpen, onKeyDown: stopKeys }, h(Shimmer, null, summary)) + : h('span', { className: cls('ompe-summary', state === 'error' && 'ompe-error-summary', state === 'stopped' && 'ompe-stopped-summary') }, h(Shimmer, null, summary)), + suffix ? h(Shimmer, { className: 'ompe-suffix ompe-stat' }, h(Fragment, null, + h('span', { className: 'ompe-added' }, `+${suffix.added}`), ' ', + h('span', { className: 'ompe-removed' }, `-${suffix.removed}`))) : null) : null; + + const expanded = open ? h('div', { className: 'ompe-body-wrap' }, + body, + typeof inspect === 'function' ? h('button', { type: 'button', className: 'ompe-inspect', onClick: inspect }, + IconInspect ? h(IconInspect, {}) : null, tr(t, 'row.inspect', 'Inspect')) : null) : undefined; + + const title = tr(t, 'tool.title.edit', 'Edit'); + const D = P.DisclosureRow; + return h('div', { className: 'ompe-root', 'data-variant': 'edit', 'data-tool': toolName, 'data-state': state }, + status ? h('span', { className: 'ompe-hidden' }, status) : null, + D + ? h(D, { + rowClassName: 'ompe-row', + leadingClassName: 'ompe-leading', + titleClassName: 'ompe-title', + icon: EDIT_ICON, + title, + running: state === 'running' || state === 'preparing', + open, + expandable, + expandOnRowClick: true, + keepContentWhenOpen: true, + onToggle: disclosure.toggle, + collapsedContent: collapsed, + children: expanded, + }) + : h(Fragment, null, + h('div', { className: 'ompe-row', role: 'button', tabIndex: 0, 'aria-expanded': open, onClick: disclosure.toggle, style: { display: 'flex', alignItems: 'center' } }, + h('span', { className: 'ompe-leading' }, EDIT_ICON), h('span', { className: 'ompe-title' }, title), collapsed), + expanded)); + } + + function PreparingEditRow(props) { + const raw = typeof props.useToolCallArgumentsPartial === 'function' ? props.useToolCallArgumentsPartial() : ''; + const summary = raw ? tr(props.t, 'tool.preparing.content', '', { kilobytes: Math.ceil(raw.length / 1024) }) : ''; + return h(Row, { props, state: 'preparing', summary, filePath: undefined, stat: null, body: null }); + } + + function StartedEditRow(props) { + const { block, cwd, home, t } = props; + const done = !!block && 'kind' in block; + const state = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok'; + const diffs = React.useMemo(() => (done && !block.isError ? readDiffs(block.meta) : []), [done, block]); + const rows = React.useMemo(() => buildRows(diffs), [diffs]); + const argsRaw = (done ? block.call?.argsRaw : block?.argsRaw) ?? ''; + const paths = diffs.length ? [...new Set(diffs.map((d) => d.path))] : argFiles(argsRaw); + const output = done ? resultText(block) || null : null; + const base = paths.map((p) => displayPath(p, cwd, home)).join(', '); + const summary = state === 'error' && output !== null ? firstLine(output) : base; + const stat = React.useMemo(() => (rows.length ? totals(rows) : null), [rows]); + const body = rows.length + ? h(DiffCard, { rows, paths, t }) + : output !== null + ? h('div', { className: 'ompe-io-card' }, + h('div', { className: 'ompe-io-section' }, + h('span', { className: 'ompe-io-label' }, tr(t, 'row.output', 'Output')), + h('span', { className: 'ompe-io-text', 'data-error': state === 'error' || undefined }, output))) + : null; + return h(Row, { props, state, summary, filePath: paths.length === 1 ? paths[0] : undefined, stat, body }); + } + + /** Split by phase like the stock FileMutationRow so hook order never changes within one component. */ function EditRow(props) { - const { block, t, phase } = props; - React.useEffect(installCss, []); - const [open, setOpen] = React.useState(true); - const done = block && 'kind' in block; - const diffs = done && !block.isError ? readDiffs(block.meta) : []; - const rows = React.useMemo(() => buildRows(diffs), [block?.meta]); - const argsRaw = block?.call?.argsRaw ?? block?.argsRaw ?? ''; - const files = diffs.length ? [...new Set(diffs.map((d) => d.path))].join(', ') : argsSummary(argsRaw); - const { add, del } = totals(rows); - const text = done && Array.isArray(block.content) ? block.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n') : ''; - const running = !done || phase === 'preparing'; - const title = (typeof t === 'function' && t('tool.title.edit')) || 'Edit'; - return h('div', { className: 'ompe-row' }, - h('button', { type: 'button', className: 'ompe-head', onClick: () => setOpen((v) => !v), 'aria-expanded': open }, - h('span', { className: 'ompe-icon' }, h(Icon, { size: 14 })), - h('span', { className: 'ompe-title' }, running ? `${title}…` : title), - files ? h('span', { className: 'ompe-sum' }, files) : null, - diffs.length ? h('span', { className: 'ompe-add' }, `+${add}`) : null, - diffs.length ? h('span', { className: 'ompe-del' }, `-${del}`) : null, - done && block.isError ? h('span', { className: 'ompe-err' }, '✕') : null), - open && rows.length - ? h(DiffCard, { rows, files, t }) - : open && done && text - ? h('div', { className: 'ompe-card' }, h('pre', { className: `ompe-text${block.isError ? ' ompe-err' : ''}` }, text)) - : null); + const preparing = props.phase === 'preparing' || (props.block && !('kind' in props.block) && props.block.phase === 'preparing'); + return preparing ? h(PreparingEditRow, props) : h(StartedEditRow, props); } return { diff --git a/package.json b/package.json index c0ea273..1dc73ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/omp-edit", - "version": "1.2.0", + "version": "1.2.1", "description": "oh-my-pi style editing for DSH: replaces the edit tool with hashline patches (content-hash tagged line anchors, block ops, cut/paste registers, multi-file, stale-tag recovery), plus replace/sloppy/apply_patch modes; tags read and grep output.", "private": true, "type": "module", diff --git a/test/client.test.mjs b/test/client.test.mjs new file mode 100644 index 0000000..e868220 --- /dev/null +++ b/test/client.test.mjs @@ -0,0 +1,108 @@ +// Smoke test for the Web client: loads client.js against a tiny stub React + primitives and walks +// the rendered tree, so the row/card code paths run without a browser or a React install. +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const SRC = readFileSync(fileURLToPath(new URL('../client.js', import.meta.url)), 'utf8') + +function makeReact() { + let slots = []; let i = 0 + const React = { + Fragment: Symbol('Fragment'), + createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children: props?.children ?? children.flat() } }), + useState: (init) => { const k = i++; if (!(k in slots)) slots[k] = typeof init === 'function' ? init() : init; return [slots[k], (v) => { slots[k] = typeof v === 'function' ? v(slots[k]) : v }] }, + useMemo: (f) => f(), + useCallback: (f) => f, + reset() { slots = []; i = 0 }, + rewind() { i = 0 }, + } + return React +} + +const stub = (name) => Object.assign((p) => ({ type: name, props: p }), { displayName: name }) +const P = { + IconEditOutlineRegular: stub('IconEdit'), IconWrapFillRegular: stub('IconWrap'), IconNowrapFillRegular: stub('IconNowrap'), + IconCopyOutlineRegular: stub('IconCopy'), IconCheckOutlineRegular: stub('IconCheck'), + Tooltip: ({ children }) => children, + TextShimmer: ({ children, className }) => ({ type: 'span', props: { className, children } }), + languageForPath: (p) => (p.endsWith('.js') ? 'javascript' : undefined), + writeClipboard: async () => true, + DisclosureRow: (p) => ({ type: 'div', props: { 'data-disclosure': true, children: [p.icon, p.title, p.collapsedContent, p.open ? p.children : null] } }), +} + +function load() { + const React = makeReact() + let mod + globalThis.window = { __ModuleLoader__: { load: ({ factory }) => { mod = factory((id) => (id === 'react' ? React : P)) } }, setTimeout } + new Function(SRC)() + let view + mod.apply({ slots: { inject: (_n, f) => f(), register: (_m, C) => { view = C } } }) + return { React, view } +} + +/** Expand function components into host nodes; collect text and class names. */ +function render(node, out = { text: [], classes: [] }) { + if (node == null || node === false || node === true) return out + if (Array.isArray(node)) { node.forEach((n) => render(n, out)); return out } + if (typeof node !== 'object') { out.text.push(String(node)); return out } + if (typeof node.type === 'function') return render(node.type(node.props), out) + if (node.props?.className) out.classes.push(...String(node.props.className).split(' ')) + return render(node.props?.children, out) +} + +const t = (k, p) => ({ 'tool.title.edit': 'Edit', 'row.output': 'Output' }[k] ?? k) +const diffs = [{ path: '/ws/a.js', oldText: 'x\n', newText: 'y\n', oldStart: 5, newStart: 5, rows: [[' ', 'keep'], ['-', 'x'], ['+', 'y']] }] +const result = (extra = {}) => ({ kind: 'tool-result', callId: 'c1', isError: false, content: [{ type: 'text', text: 'ok' }], meta: { diffs }, call: { argsRaw: JSON.stringify({ input: '[a.js#ABCD]\nPUT 6.=6:\n+y' }) }, ...extra }) + +test('settled edit renders DSH row chrome; card only when expanded', () => { + const { React, view } = load() + let expanded = false + const props = { t, toolName: 'edit', cwd: '/ws', phase: 'result', block: result(), openFile: () => {}, useDisclosure: () => ({ expanded, setExpanded() {}, toggle() { expanded = !expanded } }) } + React.reset() + let out = render(view(props)) + assert.ok(out.text.includes('Edit')) + assert.ok(out.text.includes('a.js'), 'workspace-relative file summary') + assert.ok(out.classes.includes('ompe-file'), 'dotted open-file link') + assert.ok(out.text.includes('+1') && out.text.includes('-1'), 'diff stat') + assert.ok(!out.classes.includes('ompe-card'), 'collapsed by default like stock rows') + expanded = true + React.reset() + out = render(view(props)) + assert.ok(out.classes.includes('ompe-card')) + assert.ok(out.text.includes('javascript'), 'language header') + for (const n of ['5', '6', 'keep', 'x', 'y']) assert.ok(out.text.includes(n), `row/gutter text ${n}`) + assert.ok(out.classes.includes('ompe-add') && out.classes.includes('ompe-del') && out.classes.includes('ompe-ln')) +}) + +test('long diffs fold to head/tail with an expand control', () => { + const { React, view } = load() + const rows = Array.from({ length: 30 }, (_, i) => ['+', `line${i}`]) + const block = result({ meta: { diffs: [{ path: 'b.txt', oldText: null, newText: '', oldStart: 1, newStart: 1, rows }] } }) + React.reset() + const out = render(view({ t, toolName: 'edit', phase: 'result', block, useDisclosure: () => ({ expanded: true, toggle() {} }) })) + assert.ok(out.classes.includes('ompe-expand')) + assert.ok(out.text.includes('line0') && out.text.includes('line29') && !out.text.includes('line15')) +}) + +test('failed edit shows red first-line summary and Output card, no stat', () => { + const { React, view } = load() + const block = result({ isError: true, meta: {}, content: [{ type: 'text', text: 'Error: stale tag\nmore' }] }) + React.reset() + const out = render(view({ t, toolName: 'edit', phase: 'result', block, useDisclosure: () => ({ expanded: true, toggle() {} }) })) + assert.ok(out.text.includes('Error: stale tag')) + assert.ok(out.classes.includes('ompe-error-summary')) + assert.ok(out.classes.includes('ompe-io-card') && out.text.includes('Output')) + assert.ok(!out.text.some((s) => /^\+\d/.test(s))) +}) + +test('preparing and running phases render without a body', () => { + const { React, view } = load() + React.reset() + let out = render(view({ t, toolName: 'edit', phase: 'preparing', block: { phase: 'preparing', callId: 'c' }, useToolCallArgumentsPartial: () => 'x'.repeat(3000) })) + assert.ok(out.text.includes('Edit')) + React.reset() + out = render(view({ t, toolName: 'edit', phase: 'start', block: { phase: 'start', callId: 'c', argsRaw: JSON.stringify({ input: '[x/y.md#1A2B]\nPUT 1.=1:\n+a' }) } })) + assert.ok(out.text.includes('x/y.md')) +})