diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 3f30c01f..6132a570 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 { @@ -195,6 +196,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; @@ -218,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') { @@ -237,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)}
` }; } @@ -351,6 +359,208 @@ 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}`} (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')); +} + +/** + * 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. + */ +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; + + 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) { + if (lastSelectedText) { + lastSelectedText = ''; + rpcUpdateContext?.(''); + hideHint(); + } + 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 + }); + }, { signal: selectionAbortController!.signal }); +} + + function renderStatusState(container: HTMLElement, message: string): void { container.innerHTML = `
@@ -393,12 +603,12 @@ 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 : 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 +628,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 && (range.totalLines - range.toLine) > 1; + const loadBeforeBanner = hasMissingBefore + ? `` + : ''; + const loadAfterBanner = hasMissingAfter + ? `` + : ''; + container.innerHTML = `
@@ -435,7 +657,11 @@ export function renderApp(
${notice} - ${body.html} +
+ ${loadBeforeBanner} + ${body.html} + ${loadAfterBanner} +
@@ -446,6 +672,8 @@ export function renderApp( attachCopyHandler(payload); attachHtmlToggleHandler(container, payload, htmlMode); attachOpenInFolderHandler(payload); + attachLoadAllHandler(container, payload, htmlMode); + attachTextSelectionHandler(payload); // Compact row click toggles expand/collapse const compactRow = document.getElementById('compact-toggle'); @@ -516,6 +744,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, @@ -538,6 +775,7 @@ export function bootstrapApp(): void { onRender?.(); themeAdapter.applyFromData((window as any).__MCP_HOST_CONTEXT__); + const renderAndSync = (payload?: PreviewStructuredContent): void => { if (payload) { widgetState.write(payload); // Persist for refresh recovery (cross-host) 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 41e4ca59..d24d6871 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,111 +69,189 @@ 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; + justify-content: space-between; + 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; +} + +.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 { + max-height: var(--content-height); + overflow: auto; + position: relative; +} + .panel-content { - height: var(--content-height); - overflow: hidden; + min-height: 0; } -.source-content .code-viewer { height: 100%; overflow: auto; } -.html-content .html-rendered-frame { height: 100%; } -.markdown-content { overflow: auto; padding-right: 2px; } +.source-content .code-viewer { overflow-x: auto; } +.html-content .html-rendered-frame { min-height: 300px; height: var(--content-height); } +.markdown-content { padding: 0 4px 0 0; } .notice { background: var(--notice-bg); color: var(--notice-text); - border-bottom: 1px solid var(--notice-border); - padding: 8px 14px; + padding: 10px 16px; + font-size: 13px; +} + +/* ── Load-more banners ── */ + +.load-lines-banner { + display: block; + width: 100%; + padding: 8px 16px; font-size: 12px; + font-family: inherit; + 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; +} + +#load-before { + border-bottom: 1px solid color-mix(in srgb, var(--border) 30%, transparent); +} + +#load-after { + 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 ── */ @@ -183,16 +260,67 @@ background: transparent; color: var(--code-text); overflow-x: auto; - padding: 12px 14px; + padding: 12px 0; 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; 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; @@ -206,6 +334,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); } @@ -214,44 +343,103 @@ .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: 18px 22px; - line-height: 1.62; + 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 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 24px; + font-size: 28px; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.02em; + color: var(--markdown-text); +} + +.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); - 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 +456,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; } } 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); +});