From 3cafa97fe54a4f7568a5fb33053f1029e339dcd2 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Sat, 14 Feb 2026 09:11:19 +0200 Subject: [PATCH 1/7] Better UI for read preview --- src/ui/file-preview/src/app.ts | 28 ++++++ src/ui/styles/apps/file-preview.css | 133 +++++++++++++++------------- 2 files changed, 98 insertions(+), 63 deletions(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 3f30c01f..fc6a721f 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -538,6 +538,34 @@ export function bootstrapApp(): void { onRender?.(); themeAdapter.applyFromData((window as any).__MCP_HOST_CONTEXT__); + + // DEBUG: dump all CSS variables the host injected + const rootStyles = getComputedStyle(document.documentElement); + const allProps: Record = {}; + for (const sheet of document.styleSheets) { + try { + for (const rule of (sheet as CSSStyleSheet).cssRules) { + if (rule instanceof CSSStyleRule && rule.selectorText === ':root') { + for (let i = 0; i < rule.style.length; i++) { + const prop = rule.style[i]; + if (prop.startsWith('--')) { + allProps[prop] = rootStyles.getPropertyValue(prop).trim(); + } + } + } + } + } catch { /* cross-origin */ } + } + // Also get inline style vars set by theme adapter + const inlineStyle = document.documentElement.style; + for (let i = 0; i < inlineStyle.length; i++) { + const prop = inlineStyle[i]; + if (prop.startsWith('--')) { + allProps[prop] = rootStyles.getPropertyValue(prop).trim(); + } + } + console.log('[DC Theme Debug] CSS variables:', JSON.stringify(allProps, null, 2)); + const renderAndSync = (payload?: PreviewStructuredContent): void => { if (payload) { widgetState.write(payload); // Persist for refresh recovery (cross-host) diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index 41e4ca59..93297ab6 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -1,5 +1,5 @@ /* - * App-specific styling for File Preview layouts and render containers. + * App-specific styling for File Preview — tuned to blend with Claude's UI. */ :root { --code-bg: var(--panel); @@ -13,7 +13,6 @@ --hljs-built-in: var(--color-text-accent, #6366f1); --hljs-tag: var(--color-text-info, #0ea5a8); --content-height: min(82vh, 920px); - --markdown-bg: var(--panel); --markdown-text: var(--text); --markdown-muted: var(--text-secondary); --inline-code-bg: var(--panel-subtle); @@ -24,7 +23,7 @@ --notice-text: var(--warning-text); } -/* ── Compact row (loading, ready, status) ── */ +/* ── Compact row ── */ .compact-row { display: flex; @@ -39,12 +38,12 @@ .compact-row--ready { cursor: pointer; - border-radius: 6px; - transition: color 120ms ease; + border-radius: 8px; + transition: color 150ms ease; } -.compact-row--ready:hover { color: var(--text); } -.compact-row--ready:hover .compact-chevron { color: var(--text); } +.compact-row--ready:hover { color: var(--text-secondary); } +.compact-row--ready:hover .compact-chevron { color: var(--text-secondary); } .compact-chevron { width: 14px; @@ -52,7 +51,7 @@ fill: currentColor; color: var(--muted); flex-shrink: 0; - transition: transform 200ms ease, color 120ms ease; + transition: transform 200ms ease, color 150ms ease; } .tool-shell.expanded .compact-chevron { transform: rotate(90deg); } @@ -70,92 +69,105 @@ 50% { opacity: 1; } } -/* ── Panel ── */ +/* ── Panel (Claude-style card) ── */ .panel { - margin-top: 4px; - border: 1px solid var(--border); - border-radius: 10px; + margin-top: 6px; + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 16px; background: var(--panel); overflow: hidden; } +@media (prefers-color-scheme: dark) { + .panel { + background: color-mix(in srgb, var(--panel), white 6%); + } +} + +[data-theme="dark"] .panel { + background: color-mix(in srgb, var(--panel), white 6%); +} + .tool-shell.collapsed .panel { display: none; } -/* ── Top bar (breadcrumb + copy) ── */ +/* ── Top bar ── */ .panel-topbar { display: flex; + flex-wrap: nowrap; align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid var(--border); + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); } .panel-breadcrumb { - font-size: 12px; - color: var(--muted); + font-size: 13px; + color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1 1 0; + width: 0; min-width: 0; } .breadcrumb-sep { - color: var(--border); - margin: 0 1px; + color: color-mix(in srgb, var(--muted) 50%, transparent); + margin: 0 2px; } .panel-topbar-actions { display: flex; + flex-wrap: nowrap; align-items: center; gap: 4px; - flex-shrink: 0; + flex: 0 0 auto; + margin-left: auto; } .panel-action { - font-size: 12px; + font-size: 13px; font-weight: 500; - color: var(--text-secondary); + color: var(--muted); background: none; border: none; - border-radius: 6px; - padding: 4px 10px; + border-radius: 8px; + padding: 5px 12px; cursor: pointer; white-space: nowrap; - transition: color 100ms ease, background 100ms ease; + transition: color 150ms ease, background 150ms ease; line-height: 1.4; font-family: inherit; display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; } .panel-action:hover { color: var(--text); - background: var(--panel-muted); + background: var(--panel-subtle); } .panel-action:disabled { - opacity: 0.4; + opacity: 0.35; cursor: not-allowed; } -.panel-action svg { - flex-shrink: 0; -} +.panel-action svg { flex-shrink: 0; opacity: 0.7; } +.panel-action:hover svg { opacity: 1; } /* ── Footer ── */ .panel-footer { display: flex; align-items: center; - padding: 6px 12px; - border-top: 1px solid var(--border); - font-size: 11px; + padding: 8px 16px; + border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); + font-size: 12px; color: var(--muted); - letter-spacing: 0.02em; + letter-spacing: 0.01em; } /* ── Content areas ── */ @@ -167,14 +179,13 @@ .source-content .code-viewer { height: 100%; overflow: auto; } .html-content .html-rendered-frame { height: 100%; } -.markdown-content { overflow: auto; padding-right: 2px; } +.markdown-content { overflow: auto; padding: 0 4px 0 0; } .notice { background: var(--notice-bg); color: var(--notice-text); - border-bottom: 1px solid var(--notice-border); - padding: 8px 14px; - font-size: 12px; + padding: 10px 16px; + font-size: 13px; } /* ── Code viewer ── */ @@ -183,10 +194,10 @@ background: transparent; color: var(--code-text); overflow-x: auto; - padding: 12px 14px; + padding: 16px 18px; margin: 0; font-family: var(--font-mono, ui-monospace, monospace); - line-height: 1.45; + line-height: 1.5; font-size: 13px; white-space: pre; border: 0; @@ -206,6 +217,7 @@ .hljs-selector-id, .hljs-selector-class, .hljs-doctag, .hljs-regexp { color: var(--hljs-keyword); } .hljs-string, .hljs-template-tag, .hljs-template-variable, .hljs-variable { color: var(--hljs-string); } .hljs-type, .hljs-params, .hljs-variable.language_ { color: var(--hljs-type); } + .hljs-number, .hljs-literal, .hljs-symbol, .hljs-bullet { color: var(--hljs-number); } .hljs-attr, .hljs-attribute, .hljs-property { color: var(--hljs-attr); } .hljs-built_in, .hljs-built-in { color: var(--hljs-built-in); } @@ -223,8 +235,8 @@ .markdown-doc { max-width: 860px; margin: 0 auto; - padding: 18px 22px; - line-height: 1.62; + padding: 20px 24px; + line-height: 1.65; color: var(--markdown-text); font-size: 15px; background: transparent; @@ -232,26 +244,27 @@ border-radius: 0; } -.markdown-doc h1 { margin: 0 0 14px; font-size: 34px; line-height: 1.08; letter-spacing: -0.03em; } -.markdown-doc h2 { margin: 20px 0 10px; font-size: 28px; line-height: 1.14; } -.markdown-doc h3 { margin: 16px 0 8px; font-size: 22px; line-height: 1.2; } -.markdown-doc p, .markdown-doc li { font-size: 17px; line-height: 1.5; color: var(--markdown-muted); } -.markdown-doc ul, .markdown-doc ol { margin: 8px 0 14px; padding-left: 1.2em; } +.markdown-doc h1 { margin: 0 0 16px; font-size: 32px; line-height: 1.1; letter-spacing: -0.02em; } +.markdown-doc h2 { margin: 24px 0 12px; font-size: 26px; line-height: 1.15; } +.markdown-doc h3 { margin: 20px 0 8px; font-size: 20px; line-height: 1.25; } + +.markdown-doc p, .markdown-doc li { font-size: 16px; line-height: 1.55; color: var(--markdown-muted); } +.markdown-doc ul, .markdown-doc ol { margin: 8px 0 16px; padding-left: 1.2em; } .markdown-doc code:not(.hljs) { font-family: var(--font-mono, ui-monospace, monospace); - font-size: .92em; + font-size: .9em; background: var(--inline-code-bg); color: var(--inline-code-text); border: 1px solid var(--inline-code-border); border-radius: 6px; - padding: 1px 5px; + padding: 2px 6px; } .markdown-doc .code-viewer { - margin: 12px 0; - border: 1px solid var(--border); - border-radius: 8px; + margin: 14px 0; + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 10px; } /* ── HTML frame ── */ @@ -268,15 +281,9 @@ /* ── Responsive ── */ @media (max-width: 720px) { - .panel-actions-bar { - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - - .markdown-doc { padding: 14px; } + .markdown-doc { padding: 16px; } .markdown-doc h1 { font-size: 27px; } .markdown-doc h2 { font-size: 22px; } .markdown-doc h3 { font-size: 18px; } - .markdown-doc p, .markdown-doc li { font-size: 16px; } + .markdown-doc p, .markdown-doc li { font-size: 15px; } } From 5c28bf5db457f0aa0cf7360879c477cecddd55ec Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Sat, 14 Feb 2026 09:45:05 +0200 Subject: [PATCH 2/7] markdown style --- src/ui/styles/apps/file-preview.css | 84 ++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index 93297ab6..fa150e95 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -226,30 +226,88 @@ .hljs-meta, .hljs-meta .hljs-keyword, .hljs-subst, .hljs-punctuation, .hljs-operator { color: inherit; } -/* ── Markdown ── */ +/* ── Markdown (typographic, airy, Medium-inspired) ── */ -.markdown h1, .markdown h2, .markdown h3 { margin: .5em 0; letter-spacing: -.01em; } -.markdown p { margin: .7em 0; } -.markdown ul, .markdown ol { margin: .7em 0; padding-left: 1.4rem; } +.markdown h1, .markdown h2, .markdown h3 { letter-spacing: -.015em; } +.markdown p { margin: 1em 0; } +.markdown ul, .markdown ol { margin: .8em 0; padding-left: 1.4rem; } .markdown-doc { - max-width: 860px; + max-width: 720px; margin: 0 auto; - padding: 20px 24px; - line-height: 1.65; + padding: 32px 28px 36px; + line-height: 1.8; color: var(--markdown-text); - font-size: 15px; + font-size: 16px; background: transparent; border: 0; border-radius: 0; } -.markdown-doc h1 { margin: 0 0 16px; font-size: 32px; line-height: 1.1; letter-spacing: -0.02em; } -.markdown-doc h2 { margin: 24px 0 12px; font-size: 26px; line-height: 1.15; } -.markdown-doc h3 { margin: 20px 0 8px; font-size: 20px; line-height: 1.25; } +.markdown-doc h1 { + margin: 0 0 24px; + font-size: 28px; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.02em; + color: var(--markdown-text); +} -.markdown-doc p, .markdown-doc li { font-size: 16px; line-height: 1.55; color: var(--markdown-muted); } -.markdown-doc ul, .markdown-doc ol { margin: 8px 0 16px; padding-left: 1.2em; } +.markdown-doc h2 { + margin: 36px 0 16px; + font-size: 22px; + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; + color: var(--markdown-text); +} + +.markdown-doc h3 { + margin: 28px 0 12px; + font-size: 18px; + font-weight: 600; + line-height: 1.35; + color: var(--markdown-text); +} + +.markdown-doc p { + font-size: 15px; + line-height: 1.75; + color: var(--markdown-muted); + margin: 0 0 1.2em; +} + +.markdown-doc li { + font-size: 15px; + line-height: 1.7; + color: var(--markdown-muted); + margin-bottom: 0.3em; +} + +.markdown-doc ul, .markdown-doc ol { + margin: 8px 0 20px; + padding-left: 1.3em; +} + +.markdown-doc blockquote { + margin: 20px 0; + padding: 2px 0 2px 20px; + border-left: 3px solid color-mix(in srgb, var(--border) 70%, transparent); + color: var(--markdown-muted); + font-style: italic; +} + +.markdown-doc hr { + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); + margin: 32px 0; +} + +.markdown-doc img { + max-width: 100%; + border-radius: 8px; + margin: 16px 0; +} .markdown-doc code:not(.hljs) { font-family: var(--font-mono, ui-monospace, monospace); From a7375525916b425cf665cf81db1c0caf75410ce7 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Sat, 14 Feb 2026 17:21:02 +0200 Subject: [PATCH 3/7] UI: breadcrumb one-line layout, load more lines, markdown typography, line count fix - Breadcrumb + buttons on single line with ellipsis overflow - Removed responsive column breakpoint that broke iframe layout - Load more lines banners (before/after) inside scroll container - Each banner loads only missing lines, merges with existing content - Medium-inspired markdown: wider line-height, generous spacing, narrower column - Fixed off-by-one in countLines() for files with trailing newline - Added test/test-line-count.js with 6 test cases - Removed debug CSS variable logger - Tracks load_lines_before/after events in GA4 --- src/ui/file-preview/src/app.ts | 128 +++++++++++++++++++++------- src/ui/styles/apps/file-preview.css | 51 ++++++++++- src/utils/files/text.ts | 9 +- test/test-line-count.js | 120 ++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 34 deletions(-) create mode 100644 test/test-line-count.js diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index fc6a721f..55798910 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -195,6 +195,13 @@ function stripReadStatusLine(content: string): string { return content.replace(/^\[Reading [^\]]+\]\r?\n?/, ''); } +function countContentLines(content: string): number { + const cleaned = stripReadStatusLine(content); + if (cleaned === '') return 0; + const lines = cleaned.split('\n'); + return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; +} + interface ReadRange { fromLine: number; toLine: number; @@ -351,6 +358,79 @@ function attachOpenInFolderHandler(payload: PreviewStructuredContent): void { }); } +function attachLoadAllHandler( + container: HTMLElement, + payload: PreviewStructuredContent, + htmlMode: HtmlPreviewMode +): void { + const beforeBtn = document.getElementById('load-before') as HTMLButtonElement | null; + const afterBtn = document.getElementById('load-after') as HTMLButtonElement | null; + if (!beforeBtn && !afterBtn) { + return; + } + + const range = parseReadRange(payload.content); + if (!range?.isPartial) return; + + const currentContent = stripReadStatusLine(payload.content); + + const loadLines = async (btn: HTMLButtonElement, direction: 'before' | 'after'): Promise => { + const originalText = btn.textContent; + btn.textContent = 'Loading…'; + btn.disabled = true; + + trackUiEvent?.(direction === 'before' ? 'load_lines_before' : 'load_lines_after', { + file_type: payload.fileType, + file_extension: getFileExtensionForAnalytics(payload.filePath) + }); + + try { + // Load only the missing portion + const readArgs = direction === 'before' + ? { path: payload.filePath, offset: 0, length: range.fromLine - 1 } + : { path: payload.filePath, offset: range.toLine }; + + const result = await rpcCallTool?.('read_file', readArgs); + const resultObj = result as { content?: Array<{ text?: string }> } | undefined; + const newText = resultObj?.content?.[0]?.text; + + if (newText && typeof newText === 'string') { + const cleanNew = stripReadStatusLine(newText); + + // Merge: prepend or append the new lines + const merged = direction === 'before' + ? cleanNew + (cleanNew.endsWith('\n') ? '' : '\n') + currentContent + : currentContent + (currentContent.endsWith('\n') ? '' : '\n') + cleanNew; + + // Build updated status line reflecting the new range + const newFrom = direction === 'before' ? 1 : range.fromLine; + const newTo = direction === 'after' ? range.totalLines : range.toLine; + const lineCount = newTo - newFrom + 1; + const remaining = range.totalLines - newTo; + const isStillPartial = newFrom > 1 || newTo < range.totalLines; + const statusLine = isStillPartial + ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom - 1}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` + : ''; + + const mergedPayload: PreviewStructuredContent = { + ...payload, + content: statusLine + merged + }; + renderApp(container, mergedPayload, htmlMode, isExpanded); + } else { + btn.textContent = 'Failed to load'; + setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000); + } + } catch { + btn.textContent = 'Failed to load'; + setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000); + } + }; + + beforeBtn?.addEventListener('click', () => void loadLines(beforeBtn, 'before')); + afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after')); +} + function renderStatusState(container: HTMLElement, message: string): void { container.innerHTML = `
@@ -398,7 +478,7 @@ export function renderApp( const breadcrumb = buildBreadcrumb(payload.filePath); const range = parseReadRange(payload.content); - const lineCount = range ? range.toLine - range.fromLine + 1 : payload.content.split('\n').length; + const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(payload.content); const fileTypeLabel = payload.fileType === 'markdown' ? 'MARKDOWN' : payload.fileType === 'html' ? 'HTML' : fileExtension !== 'none' ? fileExtension.toUpperCase() @@ -418,6 +498,18 @@ export function renderApp( const copyIcon = ``; const folderIcon = ``; + const loadAllButton = ''; + + // Content-area banners for missing lines + const hasMissingBefore = range?.isPartial && range.fromLine > 1; + const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines; + const loadBeforeBanner = hasMissingBefore + ? `` + : ''; + const loadAfterBanner = hasMissingAfter + ? `` + : ''; + container.innerHTML = `
@@ -435,7 +527,11 @@ export function renderApp(
${notice} - ${body.html} +
+ ${loadBeforeBanner} + ${body.html} + ${loadAfterBanner} +
@@ -446,6 +542,7 @@ export function renderApp( attachCopyHandler(payload); attachHtmlToggleHandler(container, payload, htmlMode); attachOpenInFolderHandler(payload); + attachLoadAllHandler(container, payload, htmlMode); // Compact row click toggles expand/collapse const compactRow = document.getElementById('compact-toggle'); @@ -539,33 +636,6 @@ export function bootstrapApp(): void { onRender?.(); themeAdapter.applyFromData((window as any).__MCP_HOST_CONTEXT__); - // DEBUG: dump all CSS variables the host injected - const rootStyles = getComputedStyle(document.documentElement); - const allProps: Record = {}; - for (const sheet of document.styleSheets) { - try { - for (const rule of (sheet as CSSStyleSheet).cssRules) { - if (rule instanceof CSSStyleRule && rule.selectorText === ':root') { - for (let i = 0; i < rule.style.length; i++) { - const prop = rule.style[i]; - if (prop.startsWith('--')) { - allProps[prop] = rootStyles.getPropertyValue(prop).trim(); - } - } - } - } - } catch { /* cross-origin */ } - } - // Also get inline style vars set by theme adapter - const inlineStyle = document.documentElement.style; - for (let i = 0; i < inlineStyle.length; i++) { - const prop = inlineStyle[i]; - if (prop.startsWith('--')) { - allProps[prop] = rootStyles.getPropertyValue(prop).trim(); - } - } - console.log('[DC Theme Debug] CSS variables:', JSON.stringify(allProps, null, 2)); - const renderAndSync = (payload?: PreviewStructuredContent): void => { if (payload) { widgetState.write(payload); // Persist for refresh recovery (cross-host) diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index fa150e95..659148fd 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -163,6 +163,7 @@ .panel-footer { display: flex; align-items: center; + justify-content: space-between; padding: 8px 16px; border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); font-size: 12px; @@ -172,14 +173,18 @@ /* ── Content areas ── */ -.panel-content { +.panel-content-wrapper { height: var(--content-height); - overflow: hidden; + overflow: auto; } -.source-content .code-viewer { height: 100%; overflow: auto; } +.panel-content { + min-height: 0; +} + +.source-content .code-viewer { overflow-x: auto; } .html-content .html-rendered-frame { height: 100%; } -.markdown-content { overflow: auto; padding: 0 4px 0 0; } +.markdown-content { padding: 0 4px 0 0; } .notice { background: var(--notice-bg); @@ -188,6 +193,44 @@ font-size: 13px; } +/* ── Load-more banners ── */ + +.load-lines-banner { + display: block; + width: 100%; + padding: 10px 16px; + font-size: 13px; + font-family: inherit; + color: var(--muted); + background: var(--panel-subtle); + border: none; + cursor: pointer; + text-align: center; + transition: color 150ms ease, background 150ms ease; + position: sticky; + z-index: 1; +} + +#load-before { + top: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 30%, transparent); +} + +#load-after { + bottom: 0; + border-top: 1px solid color-mix(in srgb, var(--border) 30%, transparent); +} + +.load-lines-banner:hover { + color: var(--text-secondary); + background: var(--panel-muted); +} + +.load-lines-banner:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ── Code viewer ── */ .code-viewer { diff --git a/src/utils/files/text.ts b/src/utils/files/text.ts index 18d408f7..52e37bff 100644 --- a/src/utils/files/text.ts +++ b/src/utils/files/text.ts @@ -105,7 +105,14 @@ export class TextFileHandler implements FileHandler { * Made static and public for use by other modules (e.g., writeFile telemetry in filesystem.ts) */ static countLines(content: string): number { - return content.split('\n').length; + if (content === '') return 0; + // A file with N lines has N-1 newline characters. + // If the file ends with a trailing newline, don't count the empty string after it. + const lines = content.split('\n'); + if (lines[lines.length - 1] === '') { + return lines.length - 1; + } + return lines.length; } /** diff --git a/test/test-line-count.js b/test/test-line-count.js new file mode 100644 index 00000000..e467ffea --- /dev/null +++ b/test/test-line-count.js @@ -0,0 +1,120 @@ +// Test script to verify line counting accuracy in read_file +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import fs from 'fs/promises'; +import assert from 'assert'; +import { handleReadFile } from '../dist/handlers/filesystem-handlers.js'; +import { configManager } from '../dist/config-manager.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_DIR = join(__dirname, 'test_output'); + +// Ensure test dir is allowed +await configManager.setValue('allowedDirectories', [TEST_DIR]); + +async function setup() { + await fs.mkdir(TEST_DIR, { recursive: true }); +} + +async function createTestFile(name, content) { + const filePath = join(TEST_DIR, name); + await fs.writeFile(filePath, content, 'utf8'); + return filePath; +} + +function extractTotalLines(result) { + // Result is { content: [{ type: 'text', text: '...' }] } + const text = result?.content?.[0]?.text ?? ''; + const match = text.match(/\(total: (\d+) lines/); + return match ? parseInt(match[1], 10) : null; +} + +async function testLineCount() { + let passed = 0; + let failed = 0; + + console.log('Testing line count accuracy in read_file...\n'); + + // Test 1: 3 lines with trailing newline → should report 3 + { + const filePath = await createTestFile('trailing.txt', 'line1\nline2\nline3\n'); + const result = await handleReadFile({ path: filePath, offset: 0, length: 2 }); + const total = extractTotalLines(result); + try { + assert.strictEqual(total, 3, `trailing newline: expected 3, got ${total}`); + console.log(` ✅ PASS: 3 lines + trailing newline → total: ${total}`); + passed++; + } catch (e) { console.log(` ❌ FAIL: ${e.message}`); failed++; } + } + + // Test 2: 3 lines without trailing newline → should report 3 + { + const filePath = await createTestFile('no_trailing.txt', 'line1\nline2\nline3'); + const result = await handleReadFile({ path: filePath, offset: 0, length: 2 }); + const total = extractTotalLines(result); + try { + assert.strictEqual(total, 3, `no trailing newline: expected 3, got ${total}`); + console.log(` ✅ PASS: 3 lines no trailing → total: ${total}`); + passed++; + } catch (e) { console.log(` ❌ FAIL: ${e.message}`); failed++; } + } + + // Test 3: 1 line with trailing newline → should report 1 + { + const filePath = await createTestFile('single_trailing.txt', 'hello\n'); + const result = await handleReadFile({ path: filePath, offset: 0, length: 1000 }); + const total = extractTotalLines(result); + try { + assert.strictEqual(total, 1, `single + trailing: expected 1, got ${total}`); + console.log(` ✅ PASS: 1 line + trailing → total: ${total}`); + passed++; + } catch (e) { console.log(` ❌ FAIL: ${e.message}`); failed++; } + } + + // Test 4: 1 line without trailing newline → should report 1 + { + const filePath = await createTestFile('single_no_trailing.txt', 'hello'); + const result = await handleReadFile({ path: filePath, offset: 0, length: 1000 }); + const total = extractTotalLines(result); + try { + assert.strictEqual(total, 1, `single no trailing: expected 1, got ${total}`); + console.log(` ✅ PASS: 1 line no trailing → total: ${total}`); + passed++; + } catch (e) { console.log(` ❌ FAIL: ${e.message}`); failed++; } + } + + // Test 5: 100 lines with trailing newline, partial read → total: 100 + { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const filePath = await createTestFile('hundred.txt', lines.join('\n') + '\n'); + const result = await handleReadFile({ path: filePath, offset: 10, length: 5 }); + const total = extractTotalLines(result); + try { + assert.strictEqual(total, 100, `100-line partial: expected 100, got ${total}`); + console.log(` ✅ PASS: 100-line partial read → total: ${total}`); + passed++; + } catch (e) { console.log(` ❌ FAIL: ${e.message}`); failed++; } + } + + // Test 6: 100 lines without trailing newline → total: 100 + { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const filePath = await createTestFile('hundred_no_trail.txt', lines.join('\n')); + const result = await handleReadFile({ path: filePath, offset: 0, length: 5 }); + const total = extractTotalLines(result); + try { + assert.strictEqual(total, 100, `100-line no trailing: expected 100, got ${total}`); + console.log(` ✅ PASS: 100-line no trailing → total: ${total}`); + passed++; + } catch (e) { console.log(` ❌ FAIL: ${e.message}`); failed++; } + } + + console.log(`\n${passed} passed, ${failed} failed out of ${passed + failed} tests`); + if (failed > 0) process.exit(1); +} + +setup().then(testLineCount).catch(err => { + console.error('Test error:', err); + process.exit(1); +}); From 55af1dede93acda651a77673539c4a472937eac9 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Sat, 14 Feb 2026 20:10:19 +0200 Subject: [PATCH 4/7] improvements to widget --- src/ui/file-preview/src/app.ts | 132 +++++++++++++++++- .../src/components/code-viewer.ts | 17 ++- src/ui/shared/host-lifecycle.ts | 10 +- src/ui/styles/apps/file-preview.css | 92 ++++++++++-- 4 files changed, 233 insertions(+), 18 deletions(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 55798910..eac96ee1 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -17,6 +17,7 @@ let previewShownFired = false; let onRender: (() => void) | undefined; let trackUiEvent: ((event: string, params?: Record) => void) | undefined; let rpcCallTool: ((name: string, args: Record) => Promise) | undefined; +let rpcUpdateContext: ((text: string) => void) | undefined; let shellController: ToolShellController | undefined; function getFileExtensionForAnalytics(filePath: string): string { @@ -225,7 +226,7 @@ function parseReadRange(content: string): ReadRange | undefined { }; } -function renderBody(payload: PreviewStructuredContent, htmlMode: HtmlPreviewMode): { html: string; notice?: string } { +function renderBody(payload: PreviewStructuredContent, htmlMode: HtmlPreviewMode, startLine = 1): { html: string; notice?: string } { const cleanedContent = stripReadStatusLine(payload.content); if (payload.fileType === 'unsupported') { @@ -244,7 +245,7 @@ function renderBody(payload: PreviewStructuredContent, htmlMode: HtmlPreviewMode const formatted = formatJsonIfPossible(cleanedContent, payload.filePath); return { notice: formatted.notice, - html: `
${renderCodeViewer(formatted.content, detectedLanguage)}
` + html: `
${renderCodeViewer(formatted.content, detectedLanguage, startLine)}
` }; } @@ -431,6 +432,119 @@ function attachLoadAllHandler( afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after')); } +/** + * Tracks native text selection and pushes it to the host via ui/update-model-context. + * + * How it works: + * 1. User drags to select text anywhere in the preview (markdown, code, HTML). + * 2. The selectionchange event fires; we extract the selected string. + * 3. We call rpcUpdateContext() which sends a ui/update-model-context JSON-RPC + * request to the host with the selected text + file path (+ line numbers for code). + * 4. The host stores this as widget context. + * 5. The LLM can access it by calling read_widget_context(tool_name="desktop-commander:read_file"). + * + * Note: as of Feb 2025, Claude does NOT auto-inject ui/update-model-context into + * the LLM's context window. The LLM must actively call read_widget_context to see + * the selection. A floating tooltip near the selection tells the user this is working. + */ +function attachTextSelectionHandler(payload: PreviewStructuredContent): void { + const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + if (!contentWrapper) return; + + let hintEl: HTMLElement | null = null; + let lastSelectedText = ''; + let hideTimer: ReturnType | null = null; + + function positionHint(selection: Selection): void { + if (!hintEl) return; + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + const wrapperRect = contentWrapper!.getBoundingClientRect(); + + // Position above the selection, centered horizontally + let left = rect.left + rect.width / 2 - wrapperRect.left; + let top = rect.top - wrapperRect.top + contentWrapper!.scrollTop - 32; + + // Clamp within wrapper bounds + const hintWidth = hintEl.offsetWidth || 200; + left = Math.max(8, Math.min(left - hintWidth / 2, contentWrapper!.clientWidth - hintWidth - 8)); + top = Math.max(4, top); + + hintEl.style.left = `${left}px`; + hintEl.style.top = `${top}px`; + } + + function showHint(selection: Selection): void { + if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } + + if (!hintEl) { + hintEl = document.createElement('div'); + hintEl.className = 'selection-hint'; + hintEl.textContent = 'AI can see your selection'; + contentWrapper!.appendChild(hintEl); + } + hintEl.classList.add('visible'); + positionHint(selection); + } + + function hideHint(): void { + if (!hintEl) return; + hintEl.classList.remove('visible'); + hideTimer = setTimeout(() => { hintEl?.remove(); hintEl = null; }, 200); + } + + function getLineInfo(selection: Selection): string { + const anchorRow = selection.anchorNode?.parentElement?.closest('.code-line') as HTMLElement | null; + const focusRow = selection.focusNode?.parentElement?.closest('.code-line') as HTMLElement | null; + if (anchorRow && focusRow) { + const a = parseInt(anchorRow.dataset.line ?? '', 10); + const f = parseInt(focusRow.dataset.line ?? '', 10); + if (!isNaN(a) && !isNaN(f)) { + const low = Math.min(a, f); + const high = Math.max(a, f); + return low === high ? `line ${low}` : `lines ${low}–${high}`; + } + } + return ''; + } + + document.addEventListener('selectionchange', () => { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) { + if (lastSelectedText) { + lastSelectedText = ''; + rpcUpdateContext?.(''); + hideHint(); + } + return; + } + + const text = selection.toString().trim(); + if (!text || text === lastSelectedText) return; + + // Only act on selections within our content area + const anchorInContent = contentWrapper!.contains(selection.anchorNode); + const focusInContent = contentWrapper!.contains(selection.focusNode); + if (!anchorInContent && !focusInContent) return; + + lastSelectedText = text; + + const lineInfo = getLineInfo(selection); + const locationPart = lineInfo ? ` (${lineInfo})` : ''; + const context = `User selected text from file ${payload.filePath}${locationPart}:\n\`\`\`\n${text}\n\`\`\``; + + rpcUpdateContext?.(context); + showHint(selection); + + trackUiEvent?.('text_selected', { + file_type: payload.fileType, + file_extension: getFileExtensionForAnalytics(payload.filePath), + char_count: text.length + }); + }); +} + + function renderStatusState(container: HTMLElement, message: string): void { container.innerHTML = `
@@ -473,11 +587,11 @@ export function renderApp( const canOpenInFolder = !isLikelyUrl(payload.filePath); const fileExtension = getFileExtensionForAnalytics(payload.filePath); const supportsPreview = payload.fileType !== 'unsupported'; - const body = renderBody(payload, htmlMode); + const range = parseReadRange(payload.content); + const body = renderBody(payload, htmlMode, range?.fromLine ?? 1); const notice = body.notice ? `
${body.notice}
` : ''; const breadcrumb = buildBreadcrumb(payload.filePath); - const range = parseReadRange(payload.content); const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(payload.content); const fileTypeLabel = payload.fileType === 'markdown' ? 'MARKDOWN' : payload.fileType === 'html' ? 'HTML' @@ -543,6 +657,7 @@ export function renderApp( attachHtmlToggleHandler(container, payload, htmlMode); attachOpenInFolderHandler(payload); attachLoadAllHandler(container, payload, htmlMode); + attachTextSelectionHandler(payload); // Compact row click toggles expand/collapse const compactRow = document.getElementById('compact-toggle'); @@ -613,6 +728,15 @@ export function bootstrapApp(): void { }) ); + rpcUpdateContext = (text: string): void => { + const params = text + ? { content: [{ type: 'text', text }] } + : { content: [] }; + rpcClient.request('ui/update-model-context', params).catch(() => { + // Host may not support ui/update-model-context yet + }); + }; + trackUiEvent = (event: string, params: Record = {}): void => { void rpcCallTool?.('track_ui_event', { event, diff --git a/src/ui/file-preview/src/components/code-viewer.ts b/src/ui/file-preview/src/components/code-viewer.ts index cc6f4add..2df96baf 100644 --- a/src/ui/file-preview/src/components/code-viewer.ts +++ b/src/ui/file-preview/src/components/code-viewer.ts @@ -61,8 +61,21 @@ export function formatJsonIfPossible(content: string, filePath: string): { conte } } -export function renderCodeViewer(code: string, language = 'text'): string { +export function renderCodeViewer(code: string, language = 'text', startLine = 1): string { const normalizedLanguage = language || 'text'; const highlighted = highlightSource(code, normalizedLanguage); - return `
${highlighted}
`; + + // Wrap each line with line number gutter + const lines = highlighted.split('\n'); + // Remove trailing empty line from trailing newline + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + + const lineHtml = lines.map((line, i) => { + const lineNum = startLine + i; + return `${lineNum}${line || ' '}`; + }).join('\n'); + + return `
${lineHtml}
`; } diff --git a/src/ui/shared/host-lifecycle.ts b/src/ui/shared/host-lifecycle.ts index d99bc067..63de4b33 100644 --- a/src/ui/shared/host-lifecycle.ts +++ b/src/ui/shared/host-lifecycle.ts @@ -39,12 +39,16 @@ export function createUiHostLifecycle(rpcClient: RpcClient, options: UiHostLifec }, initialize: () => { void rpcClient.request('ui/initialize', { - app: { name: appName, version: appVersion }, - capabilities: {}, + appInfo: { name: appName, version: appVersion }, + appCapabilities: {}, + protocolVersion: '2026-01-26', + }).then(() => { + rpcClient.notify('ui/notifications/initialized', {}); }).catch(() => { // Initialization handshake failure should not break rendering. + // Still send initialized in case host is lenient. + rpcClient.notify('ui/notifications/initialized', {}); }); - rpcClient.notify('ui/notifications/initialized', {}); }, }; } diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index 659148fd..ddb352aa 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -171,11 +171,38 @@ letter-spacing: 0.01em; } +.footer-comment-btn { + margin-left: auto; +} + +/* ── Selection hint (floating tooltip near selection) ── */ + +.selection-hint { + position: absolute; + z-index: 10; + padding: 4px 10px; + font-size: 11px; + color: var(--text-secondary); + background: var(--panel); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; +} + +.selection-hint.visible { + opacity: 1; +} + /* ── Content areas ── */ .panel-content-wrapper { height: var(--content-height); overflow: auto; + position: relative; } .panel-content { @@ -198,26 +225,22 @@ .load-lines-banner { display: block; width: 100%; - padding: 10px 16px; - font-size: 13px; + padding: 8px 16px; + font-size: 12px; font-family: inherit; - color: var(--muted); - background: var(--panel-subtle); + color: var(--text-secondary); + background: color-mix(in srgb, var(--panel-muted) 40%, transparent); border: none; cursor: pointer; text-align: center; transition: color 150ms ease, background 150ms ease; - position: sticky; - z-index: 1; } #load-before { - top: 0; border-bottom: 1px solid color-mix(in srgb, var(--border) 30%, transparent); } #load-after { - bottom: 0; border-top: 1px solid color-mix(in srgb, var(--border) 30%, transparent); } @@ -237,7 +260,7 @@ background: transparent; color: var(--code-text); overflow-x: auto; - padding: 16px 18px; + padding: 12px 0; margin: 0; font-family: var(--font-mono, ui-monospace, monospace); line-height: 1.5; @@ -247,6 +270,57 @@ border-radius: 0; } +.code-table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; +} + +.code-line { + transition: background 80ms ease; +} + +.code-line:hover { + background: color-mix(in srgb, var(--panel-muted) 50%, transparent); +} + +.code-line.selected { + background: color-mix(in srgb, var(--border) 40%, transparent); +} + +.code-line.selected:hover { + background: color-mix(in srgb, var(--border) 55%, transparent); +} + +.line-num { + padding: 0 12px 0 18px; + text-align: right; + color: var(--muted); + opacity: 0.5; + user-select: none; + cursor: pointer; + vertical-align: top; + white-space: nowrap; + font-size: 12px; + min-width: 2.5em; + transition: opacity 120ms ease; +} + +.line-num:hover { + opacity: 1; + color: var(--text-secondary); +} + +.code-line.selected .line-num { + opacity: 0.8; +} + +.line-content { + padding: 0 18px 0 8px; + white-space: pre; + width: 100%; +} + .code-viewer .hljs { display: block; background: transparent; From be64eeafcb0044ad9c61c81aaf843e8f2b860975 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Sat, 14 Feb 2026 20:14:55 +0200 Subject: [PATCH 5/7] Fix last line --- src/ui/file-preview/src/app.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index eac96ee1..529c7363 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -410,7 +410,7 @@ function attachLoadAllHandler( const remaining = range.totalLines - newTo; const isStillPartial = newFrom > 1 || newTo < range.totalLines; const statusLine = isStillPartial - ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom - 1}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` + ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` : ''; const mergedPayload: PreviewStructuredContent = { @@ -616,7 +616,7 @@ export function renderApp( // Content-area banners for missing lines const hasMissingBefore = range?.isPartial && range.fromLine > 1; - const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines; + const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines && (range.totalLines - range.toLine) > 1; const loadBeforeBanner = hasMissingBefore ? `` : ''; From 3f896ffa41fa4190efbd1410e40d6ff3ac283b0f Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 16 Feb 2026 11:03:55 +0200 Subject: [PATCH 6/7] Few more fixes --- src/ui/file-preview/src/app.ts | 11 ++++++++++- src/ui/styles/apps/file-preview.css | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 529c7363..c80bd476 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -447,10 +447,19 @@ function attachLoadAllHandler( * the LLM's context window. The LLM must actively call read_widget_context to see * the selection. A floating tooltip near the selection tells the user this is working. */ +let selectionAbortController: AbortController | null = null; + function attachTextSelectionHandler(payload: PreviewStructuredContent): void { const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; if (!contentWrapper) return; + // Abort any previous selectionchange listener to avoid leaking listeners/closures + if (selectionAbortController) { + selectionAbortController.abort(); + selectionAbortController = null; + } + selectionAbortController = new AbortController(); + let hintEl: HTMLElement | null = null; let lastSelectedText = ''; let hideTimer: ReturnType | null = null; @@ -541,7 +550,7 @@ function attachTextSelectionHandler(payload: PreviewStructuredContent): void { file_extension: getFileExtensionForAnalytics(payload.filePath), char_count: text.length }); - }); + }, { signal: selectionAbortController!.signal }); } diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index ddb352aa..d24d6871 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -200,7 +200,7 @@ /* ── Content areas ── */ .panel-content-wrapper { - height: var(--content-height); + max-height: var(--content-height); overflow: auto; position: relative; } @@ -210,7 +210,7 @@ } .source-content .code-viewer { overflow-x: auto; } -.html-content .html-rendered-frame { height: 100%; } +.html-content .html-rendered-frame { min-height: 300px; height: var(--content-height); } .markdown-content { padding: 0 4px 0 0; } .notice { From d75d40e1b45e704e0fc3fb5779ef73e25b23786a Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 16 Feb 2026 11:25:10 +0200 Subject: [PATCH 7/7] hint fix --- src/ui/file-preview/src/app.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index c80bd476..6132a570 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -534,7 +534,14 @@ function attachTextSelectionHandler(payload: PreviewStructuredContent): void { // Only act on selections within our content area const anchorInContent = contentWrapper!.contains(selection.anchorNode); const focusInContent = contentWrapper!.contains(selection.focusNode); - if (!anchorInContent && !focusInContent) return; + if (!anchorInContent && !focusInContent) { + if (lastSelectedText) { + lastSelectedText = ''; + rpcUpdateContext?.(''); + hideHint(); + } + return; + } lastSelectedText = text;