diff --git a/pwa/bun.lock b/pwa/bun.lock index 69b29fc..9253d22 100644 --- a/pwa/bun.lock +++ b/pwa/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "pwa", + "dependencies": { + "highlight.js": "^11.11.1", + }, "devDependencies": { "@types/bun": "latest", "typescript": "^5", @@ -68,6 +71,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], diff --git a/pwa/package.json b/pwa/package.json index 4114efe..309aa0b 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -11,5 +11,8 @@ "@types/bun": "latest", "typescript": "^5", "vite": "^8.0.16" + }, + "dependencies": { + "highlight.js": "^11.11.1" } } diff --git a/pwa/src/chat.ts b/pwa/src/chat.ts index 34f2c99..5df5eb1 100644 --- a/pwa/src/chat.ts +++ b/pwa/src/chat.ts @@ -1,38 +1,76 @@ import { state, escapeHTML, renderPart } from './state'; import { handleDisconnect } from './connect'; +declare const lucide: { createIcons: () => void } | undefined; +function initIcons() { try { lucide?.createIcons(); } catch {} } + const app = () => document.querySelector('#app')!; export function renderChat(): void { const session = state.sessions.find((s) => s.id === state.activeSessionID); - const title = session?.title || 'Chat'; app().innerHTML = ` -
${highlightCode(output.slice(0, 3000), 'bash')}`;
+ }
+ case 'edit':
+ case 'multiedit': {
+ const oldContent = meta?.old_content as string;
+ const newContent = meta?.new_content as string;
+ const filePath = (meta?.file_path as string) || '';
+ const lang = filePath ? guessLang(filePath) : undefined;
+ if (oldContent && newContent) {
+ return `${highlightCode(content.slice(0, 1500), lang)}`;
+ return '';
+ }
+ case 'write': {
+ // Try to get file path from the matching tool_call to guess language
+ if (content && content !== 'File successfully written.') {
+ return `${highlightCode(content.slice(0, 2000))}`;
+ }
+ return '';
+ }
+ case 'view': {
+ const viewContent = (meta?.content as string) || content;
+ if (viewContent) {
+ return `${highlightCode(viewContent.slice(0, 3000))}`;
+ }
+ return '';
+ }
+ case 'todos': {
+ if (meta?.todos) {
+ const todos = meta.todos as Array<{ content: string; status: string; active_form?: string }>;
+ const lines = todos.map(t => {
+ const icon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '→' : '○';
+ const cls = t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'active' : '';
+ const text = t.status === 'in_progress' && t.active_form ? t.active_form : t.content;
+ return `${escapeHTML(content.slice(0, 1000))}`;
+ return '';
+ }
+ default: {
+ if (!content) return '';
+ const maxLen = 2000;
+ const truncated = content.length > maxLen ? content.slice(0, maxLen) + '\n…' : content;
+ return `${highlightCode(truncated)}`;
+ }
+ }
+}
+
+function renderColoredDiff(oldStr: string, newStr: string, lang?: string): string {
+ const oldLines = oldStr.split('\n');
+ const newLines = newStr.split('\n');
+
+ const hlOld = oldLines.map(l => highlightCode(l, lang));
+ const hlNew = newLines.map(l => highlightCode(l, lang));
+
+ const lcs = computeLCS(oldLines, newLines);
+ const raw: Array<{ type: 'ctx' | 'add' | 'del'; html: string }> = [];
+ let oi = 0, ni = 0, li = 0;
+
+ while (oi < oldLines.length || ni < newLines.length) {
+ if (li < lcs.length && oi < oldLines.length && oldLines[oi] === lcs[li] && ni < newLines.length && newLines[ni] === lcs[li]) {
+ raw.push({ type: 'ctx', html: ` ${hlOld[oi]}` });
+ oi++; ni++; li++;
+ } else if (li < lcs.length && ni < newLines.length && newLines[ni] === lcs[li]) {
+ raw.push({ type: 'del', html: `- ${hlOld[oi]}` });
+ oi++;
+ } else if (li < lcs.length && oi < oldLines.length && oldLines[oi] === lcs[li]) {
+ raw.push({ type: 'add', html: `+ ${hlNew[ni]}` });
+ ni++;
+ } else {
+ if (oi < oldLines.length && (ni >= newLines.length || li >= lcs.length)) {
+ raw.push({ type: 'del', html: `- ${hlOld[oi]}` });
+ oi++;
+ } else if (ni < newLines.length) {
+ raw.push({ type: 'add', html: `+ ${hlNew[ni]}` });
+ ni++;
+ }
+ }
+ }
+
+ // Collapse consecutive context lines, keeping 3 lines of context around changes
+ const CONTEXT = 3;
+ const result: string[] = [];
+ let i = 0;
+ while (i < raw.length) {
+ if (raw[i].type === 'ctx') {
+ // Find the extent of this context run
+ let end = i;
+ while (end < raw.length && raw[end].type === 'ctx') end++;
+ const ctxLen = end - i;
+
+ // Is there a change after this context block?
+ const hasChangeAfter = end < raw.length;
+ // Was there a change before this context block?
+ const hasChangeBefore = i > 0;
+
+ if (!hasChangeAfter && !hasChangeBefore) {
+ // Entire diff is context — show all
+ for (let j = i; j < end; j++) result.push(raw[j].html);
+ } else if (ctxLen <= CONTEXT * 2 + 1) {
+ // Small enough to show entirely
+ for (let j = i; j < end; j++) result.push(raw[j].html);
+ } else {
+ // Show head + fold + tail
+ const headEnd = hasChangeBefore ? i + CONTEXT : i;
+ const tailStart = hasChangeAfter ? end - CONTEXT : end;
+
+ if (hasChangeBefore) {
+ for (let j = i; j < headEnd; j++) result.push(raw[j].html);
+ }
+ const hidden = tailStart - headEnd;
+ if (hidden > 0) {
+ result.push(`${hidden} unchanged lines`);
+ }
+ if (hasChangeAfter) {
+ for (let j = tailStart; j < end; j++) result.push(raw[j].html);
+ }
+ }
+ i = end;
+ } else {
+ result.push(raw[i].html);
+ i++;
+ }
+ }
+
+ return `${result.join('\n')}`;
+}
+
+function computeLCS(a: string[], b: string[]): string[] {
+ const m = a.length, n = b.length;
+ // Cap to avoid OOM on huge files
+ if (m * n > 500000) {
+ // Fallback: line-by-line comparison for large files
+ const result: string[] = [];
+ const max = Math.max(m, n);
+ for (let i = 0; i < max; i++) {
+ if (i < m && i < n && a[i] === b[i]) result.push(a[i]);
+ }
+ return result;
+ }
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ const result: string[] = [];
+ let i = m, j = n;
+ while (i > 0 && j > 0) {
+ if (a[i - 1] === b[j - 1]) { result.unshift(a[i - 1]); i--; j--; }
+ else if (dp[i - 1][j] > dp[i][j - 1]) i--;
+ else j--;
+ }
+ return result;
+}
+
export function renderPart(part: ContentPart): string {
const { type, data } = part;
@@ -66,30 +411,23 @@ export function renderPart(part: ContentPart): string {
const name = (data.name as string) || 'tool';
const input = (data.input as string) || '';
const finished = data.finished as boolean | undefined;
- let inputPreview = '';
- if (input) {
- try {
- const parsed = JSON.parse(input);
- inputPreview = escapeHTML(JSON.stringify(parsed, null, 2).slice(0, 300));
- } catch {
- inputPreview = escapeHTML(input.slice(0, 300));
- }
- }
- return `${inputPreview}` : ''}
- ${escapeHTML(content.slice(0, 800))}` : ''}
+