diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1defce0..31a18f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,6 +116,7 @@ codex exec --json -m {model} -C {cwd} --skip-git-repo-check \ codex exec resume {sessionId} --json -m {model} --skip-git-repo-check "{prompt}" ``` (`exec resume` does not use `-C` or `-s`.) +Image attachments are passed via `-i /path/to/image`; non-image attachments are appended to the prompt as readable file paths. **Ollama Provider:** Stateless HTTP requests to Ollama API: - POST to `/api/chat` with full message history @@ -241,6 +242,8 @@ Conversations default to sandboxed mode for safety. Sandbox configuration: | `GET` | `/api/files/content` | Get structured file content (standalone cwd) | | `GET` | `/api/files/download` | Download file | | `POST` | `/api/files/upload` | Upload file | +| `POST` | `/api/conversations/:id/upload` | Upload local file as conversation attachment | +| `POST` | `/api/conversations/:id/attachments/from-files` | Copy existing cwd file(s) into conversation attachments | | `GET` | `/api/conversations/:id/files` | List files in cwd | | `GET` | `/api/conversations/:id/files/content` | Get file content | | `GET` | `/api/conversations/:id/files/search` | Git grep search | @@ -371,7 +374,7 @@ public/js/ Five mutually exclusive views with CSS transform transitions: 1. **List View** — Conversation browser grouped by cwd, search (keyword + semantic), archive toggle -2. **Chat View** — Messages, input bar, file panel with preview +2. **Chat View** — Messages, input bar, file panel with preview, and direct file-to-chat attachment from file tree/viewer actions 3. **Stats View** — Analytics dashboard with cost tracking, activity charts 4. **Branches View** — Fork tree visualization with parent/child navigation 5. **Memory View** — Memory management (global + project-scoped) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7a49916..ed7b7f3 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -126,7 +126,7 @@ public/css/ role: 'user', text: string, timestamp: number, - attachments: [{ filename, url }] // optional + attachments: [{ path, filename, url }] // optional } ``` @@ -139,8 +139,13 @@ public/css/ cost: number, // USD duration: number, // ms sessionId: string, - inputTokens: number, - outputTokens: number + inputTokens: number, // Provider-reported input token count (raw when available) + outputTokens: number, + netInputTokens?: number, // rawInputTokens - cachedInputTokens + typedInputTokens?: number, // rough estimate of user-typed prompt tokens + rawInputTokens?: number, // full provider-reported prompt/context tokens + cachedInputTokens?: number, // provider cache hit tokens (if available) + reasoningTokens?: number // provider-reported reasoning/thinking tokens } ``` @@ -235,7 +240,7 @@ public/css/ | `loadConversations()` | conversations.js | Fetch + render list | | `openConversation(id)` | conversations.js | Load + display conversation | | `forkConversation(idx)` | conversations.js | Fork from message | -| `sendMessage(text)` | ui.js | Send with attachments | +| `sendMessage(text)` | ui.js | Send with local uploads or pre-attached server files | | `renderMarkdown(text)` | markdown.js | Markdown → HTML | --- diff --git a/lib/providers/claude.js b/lib/providers/claude.js index 5cf4822..b9d7545 100644 --- a/lib/providers/claude.js +++ b/lib/providers/claude.js @@ -117,6 +117,10 @@ function calculateMessageCost(inputTokens, outputTokens, modelId) { return inputCost + outputCost; } +function estimateTypedInputTokens(text) { + return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0; +} + // Active Claude processes per conversation const activeProcesses = new Map(); const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 minutes @@ -190,6 +194,7 @@ function buildInlineHistoryContext(messages = [], latestUserText = '', options = let totalChars = 0; for (let i = priorMessages.length - 1; i >= 0; i--) { const msg = priorMessages[i]; + if (msg?.summarized) continue; let text = String(msg?.text || '').trim(); for (const replacement of pathReplacements) { text = text.split(replacement.from).join(replacement.to); @@ -376,8 +381,20 @@ function handleResultEvent( conv.claudeForkSessionId = null; } - const inputTokens = event.total_input_tokens ?? event.input_tokens ?? event.usage?.input_tokens ?? 0; + const rawInputTokens = event.total_input_tokens ?? event.input_tokens ?? event.usage?.input_tokens ?? 0; + const cachedInputTokens = + event.cache_read_input_tokens ?? + event.usage?.cache_read_input_tokens ?? + event.usage?.input_tokens_details?.cached_tokens ?? + 0; + const netInputTokens = Math.max(0, rawInputTokens - cachedInputTokens); + const inputTokens = rawInputTokens; + const typedInputTokens = conv._typedInputTokens ?? null; const outputTokens = event.total_output_tokens ?? event.output_tokens ?? event.usage?.output_tokens ?? 0; + const reasoningTokens = + event.usage?.output_tokens_details?.reasoning_tokens ?? + event.usage?.completion_tokens_details?.reasoning_tokens ?? + 0; if (!resultText.trim() && inputTokens === 0 && outputTokens === 0) { if (canRetryWithFreshSession) { // Resumed sessions can occasionally become invalid and return empty 0/0. @@ -417,7 +434,12 @@ function handleResultEvent( duration: event.duration_ms, sessionId: event.session_id, inputTokens, + netInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, + rawInputTokens, + cachedInputTokens, }); conv.status = 'idle'; conv.thinkingStartTime = null; @@ -437,8 +459,14 @@ function handleResultEvent( duration: event.duration_ms, sessionId: event.session_id, inputTokens, + netInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, + rawInputTokens, + cachedInputTokens, }); + delete conv._typedInputTokens; broadcastStatus(conversationId, 'idle'); } @@ -542,6 +570,7 @@ class ClaudeProvider extends LLMProvider { const promptText = inlineHistory ? `${inlineHistory}\n\n[New user message]\n${text}` : text; + conv._typedInputTokens = estimateTypedInputTokens(text); const args = [ '-p', promptText, @@ -741,6 +770,7 @@ Please continue the conversation naturally, using the above context as backgroun ) { retryScheduled = true; delete conv._retryAfterEmptyResultMode; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -776,6 +806,7 @@ Please continue the conversation naturally, using the above context as backgroun isSlashOnlyPrompt, }); delete conv._retryAfterEmptyResultMode; + delete conv._typedInputTokens; }); proc.on('error', (err) => { @@ -784,6 +815,7 @@ Please continue the conversation naturally, using the above context as backgroun if (!retryScheduled && conv.status === 'thinking' && canRetryWithCompactHistory && isContextOverflowError(err?.message || err?.code)) { retryScheduled = true; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -801,6 +833,7 @@ Please continue the conversation naturally, using the above context as backgroun }); broadcastStatus(conversationId, 'idle'); delete conv._retryAfterEmptyResultMode; + delete conv._typedInputTokens; }); } diff --git a/lib/providers/codex.js b/lib/providers/codex.js index 49450f8..a1963f4 100644 --- a/lib/providers/codex.js +++ b/lib/providers/codex.js @@ -54,6 +54,10 @@ function calculateMessageCost(inputTokens, outputTokens, modelId) { return inputCost + outputCost; } +function estimateTypedInputTokens(text) { + return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0; +} + // Active Codex processes per conversation const activeProcesses = new Map(); const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 minutes @@ -112,6 +116,30 @@ function formatMemoriesForPrompt(memories) { return `\n\n[User memories]\n${sections.join('\n\n')}\n[/User memories]`; } +function partitionAttachments(attachments = []) { + const imageAttachments = []; + const fileAttachments = []; + + for (const attachment of attachments || []) { + const filePath = attachment?.path; + if (!filePath || typeof filePath !== 'string') continue; + + if (/\.(png|jpg|jpeg|gif|webp)$/i.test(filePath)) { + imageAttachments.push(attachment); + } else { + fileAttachments.push(attachment); + } + } + + return { imageAttachments, fileAttachments }; +} + +function buildFileAttachmentPrompt(fileAttachments = []) { + if (!Array.isArray(fileAttachments) || fileAttachments.length === 0) return ''; + const paths = fileAttachments.map((a) => a.path).join('\n'); + return `\n\n[Attached file${fileAttachments.length > 1 ? 's' : ''} — read for context:]\n${paths}`; +} + function buildInlineHistoryContext(messages = [], latestUserText = '', options = {}) { if (!Array.isArray(messages) || messages.length === 0) return ''; const maxChars = Number.isFinite(Number(options.maxChars)) && Number(options.maxChars) > 0 @@ -128,6 +156,7 @@ function buildInlineHistoryContext(messages = [], latestUserText = '', options = let totalChars = 0; for (let i = priorMessages.length - 1; i >= 0; i--) { const msg = priorMessages[i]; + if (msg?.summarized) continue; const text = String(msg?.text || '').trim(); if (!text) continue; @@ -496,8 +525,14 @@ function processCodexEvent( 0; // For UX, show net new input tokens instead of full cached prompt tokens. const inputTokens = Math.max(0, rawInputTokens - cachedInputTokens); + const netInputTokens = inputTokens; const displayInputTokens = conv._codexDisplayInputTokens ?? inputTokens; + const typedInputTokens = conv._typedInputTokens ?? displayInputTokens; const outputTokens = usage.output_tokens || 0; + const reasoningTokens = + usage.output_tokens_details?.reasoning_tokens ?? + usage.completion_tokens_details?.reasoning_tokens ?? + 0; if (!assistantText.trim() && inputTokens === 0 && outputTokens === 0) { if (canRetryWithFreshSession) { // Resumed sessions can occasionally become invalid and return empty 0/0. @@ -522,6 +557,7 @@ function processCodexEvent( error: 'Model returned an empty response. Please retry.', }); delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; broadcastStatus(conversationId, 'idle'); break; } @@ -538,8 +574,11 @@ function processCodexEvent( duration: event.duration_ms, sessionId: conv.codexSessionId, inputTokens, + netInputTokens, displayInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, rawInputTokens, cachedInputTokens, }); @@ -555,12 +594,16 @@ function processCodexEvent( duration: event.duration_ms, sessionId: conv.codexSessionId, inputTokens, + netInputTokens, displayInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, rawInputTokens, cachedInputTokens, }); delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; broadcastStatus(conversationId, 'idle'); break; } @@ -623,14 +666,15 @@ class CodexProvider extends LLMProvider { args.push('-s', allowWrites ? 'workspace-write' : 'read-only'); } + const { imageAttachments, fileAttachments } = partitionAttachments(attachments); + // Image attachments - const images = (attachments || []).filter(a => /\.(png|jpg|jpeg|gif|webp)$/i.test(a.path)); - for (const img of images) { + for (const img of imageAttachments) { args.push('-i', img.path); } // Grant access to uploads directory - if (!isResume && attachments && attachments.length > 0) { + if (!isResume && (imageAttachments.length > 0 || fileAttachments.length > 0)) { args.push('--add-dir', path.join(uploadDir, conversationId)); } @@ -651,8 +695,11 @@ class CodexProvider extends LLMProvider { const promptBase = inlineHistory ? `${inlineHistory}\n\n[New user message]\n${text}` : text; - const prompt = promptBase + formatMemoriesForPrompt(enabledMemories); - conv._codexDisplayInputTokens = text ? Math.max(1, Math.ceil(text.length / 4)) : 0; + const prompt = promptBase + + buildFileAttachmentPrompt(fileAttachments) + + formatMemoriesForPrompt(enabledMemories); + conv._typedInputTokens = estimateTypedInputTokens(text); + conv._codexDisplayInputTokens = conv._typedInputTokens; // Prompt must be last args.push(prompt); @@ -758,6 +805,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -797,6 +845,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; }); proc.on('error', (err) => { @@ -808,6 +857,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -828,6 +878,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; }); } @@ -951,3 +1002,8 @@ module.exports.MODELS = MODELS; module.exports.activeProcesses = activeProcesses; module.exports.processCodexEvent = processCodexEvent; module.exports.handleNoOutputClose = handleNoOutputClose; +module.exports.partitionAttachments = partitionAttachments; +module.exports.buildFileAttachmentPrompt = buildFileAttachmentPrompt; +module.exports._private = { + buildInlineHistoryContext, +}; diff --git a/lib/providers/ollama.js b/lib/providers/ollama.js index a736454..5f3be81 100644 --- a/lib/providers/ollama.js +++ b/lib/providers/ollama.js @@ -11,6 +11,10 @@ const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434'; // Active requests per conversation (for cancellation) const activeRequests = new Map(); +function estimateTypedInputTokens(text) { + return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0; +} + // Default models if Ollama is not available const DEFAULT_MODELS = [ { id: 'llama3.2', name: 'Llama 3.2', context: 128000 }, @@ -122,6 +126,7 @@ ${summaryMsg.text} */ async chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories = []) { const { onSave, broadcastStatus } = callbacks; + conv._typedInputTokens = estimateTypedInputTokens(text); // Note: Ollama doesn't support file attachments natively // We could read file contents and append to the message, but skip for now @@ -198,7 +203,11 @@ ${summaryMsg.text} cost: 0, // Ollama is free/local duration, inputTokens: json.prompt_eval_count || 0, + netInputTokens: json.prompt_eval_count || 0, + typedInputTokens: conv._typedInputTokens ?? null, outputTokens: json.eval_count || 0, + rawInputTokens: json.prompt_eval_count || 0, + cachedInputTokens: 0, }); conv.status = 'idle'; conv.thinkingStartTime = null; @@ -211,7 +220,11 @@ ${summaryMsg.text} cost: 0, duration, inputTokens: json.prompt_eval_count || 0, + netInputTokens: json.prompt_eval_count || 0, + typedInputTokens: conv._typedInputTokens ?? null, outputTokens: json.eval_count || 0, + rawInputTokens: json.prompt_eval_count || 0, + cachedInputTokens: 0, }); broadcastStatus(conversationId, 'idle'); } @@ -249,6 +262,7 @@ ${summaryMsg.text} broadcastStatus(conversationId, 'idle'); } finally { activeRequests.delete(conversationId); + delete conv._typedInputTokens; } } diff --git a/lib/routes/files.js b/lib/routes/files.js index e52b440..651c2af 100644 --- a/lib/routes/files.js +++ b/lib/routes/files.js @@ -7,6 +7,7 @@ const fsp = require('fs').promises; const readline = require('readline'); const { UPLOAD_DIR } = require('../data'); +const { MAX_UPLOAD_SIZE } = require('../constants'); // Parquet support (lazy loaded to avoid startup cost if not used) let parquet = null; @@ -311,6 +312,34 @@ function detectDelimiter(firstLine) { return tabCount > commaCount ? '\t' : ','; } +function splitFilename(filename) { + const ext = path.extname(filename); + const base = ext ? filename.slice(0, -ext.length) : filename; + return { base, ext }; +} + +async function fileExists(filePath) { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function getUniqueUploadFilename(uploadDir, filename) { + const safeName = sanitizeFilename(filename || `upload-${Date.now()}`); + const { base, ext } = splitFilename(safeName); + + let candidate = safeName; + let counter = 1; + while (await fileExists(path.join(uploadDir, candidate))) { + counter += 1; + candidate = `${base}-${counter}${ext}`; + } + return candidate; +} + /** * Parse Jupyter notebook file * Returns { cells, metadata } @@ -749,6 +778,84 @@ function setupFileRoutes(app) { })); })); + // Attach existing cwd files into conversation uploads + app.post('/api/conversations/:id/attachments/from-files', withConversation(async (req, res, conv) => { + const requestedPaths = Array.isArray(req.body?.paths) ? req.body.paths : null; + if (!requestedPaths || requestedPaths.length === 0) { + return res.status(400).json({ error: 'paths array required' }); + } + + const baseCwd = conv.cwd || process.env.HOME; + const convId = conv.id; + const uploadDir = path.join(UPLOAD_DIR, convId); + await fsp.mkdir(uploadDir, { recursive: true }); + + const attachments = []; + const failed = []; + + for (const rawPath of requestedPaths) { + const originalPath = typeof rawPath === 'string' ? rawPath : ''; + const requestPath = originalPath.trim(); + if (!requestPath) { + failed.push({ path: originalPath, error: 'Invalid path' }); + continue; + } + + const sourcePath = path.resolve(baseCwd, requestPath); + if (!isPathWithinCwd(baseCwd, sourcePath)) { + failed.push({ path: requestPath, error: 'Access denied' }); + continue; + } + + let sourceStat; + try { + sourceStat = await fsp.stat(sourcePath); + } catch (err) { + if (err?.code === 'ENOENT') { + failed.push({ path: requestPath, error: 'File not found' }); + } else { + failed.push({ path: requestPath, error: err.message || 'Unable to read file' }); + } + continue; + } + + if (!sourceStat.isFile()) { + failed.push({ path: requestPath, error: 'Path is not a file' }); + continue; + } + + if (sourceStat.size > MAX_UPLOAD_SIZE) { + const maxMB = Math.round(MAX_UPLOAD_SIZE / (1024 * 1024)); + failed.push({ path: requestPath, error: `File too large. Maximum size is ${maxMB}MB.` }); + continue; + } + + try { + const sourceName = path.basename(sourcePath); + const targetName = await getUniqueUploadFilename(uploadDir, sourceName); + const targetPath = path.join(uploadDir, targetName); + await fsp.copyFile(sourcePath, targetPath); + attachments.push({ + path: targetPath, + filename: targetName, + url: `/uploads/${convId}/${targetName}`, + }); + } catch (err) { + failed.push({ path: requestPath, error: err.message || 'Failed to attach file' }); + } + } + + if (attachments.length === 0) { + return res.status(400).json({ + error: 'No files were attached', + attachments: [], + failed, + }); + } + + res.json({ attachments, failed }); + })); + // List files in conversation's working directory app.get('/api/conversations/:id/files', withConversation(async (req, res, conv) => { const subpath = req.query.path || ''; diff --git a/public/css/file-panel.css b/public/css/file-panel.css index a802b52..6aaf1f5 100644 --- a/public/css/file-panel.css +++ b/public/css/file-panel.css @@ -416,11 +416,11 @@ } /* Load to Data tab button */ -.file-tree-load-data-btn { +.file-tree-load-data-btn, +.file-tree-attach-btn { display: none; background: none; border: none; - color: var(--accent); min-width: 32px; min-height: 32px; padding: 0; @@ -432,26 +432,48 @@ border-radius: 6px; } +.file-tree-load-data-btn { + color: var(--accent); +} + +.file-tree-attach-btn { + color: var(--text-secondary); +} + .file-tree-load-data-btn:hover { opacity: 1; background: var(--accent-bg); } +.file-tree-attach-btn:hover { + opacity: 1; + background: var(--hover-bg); + color: var(--text); +} + .file-tree-load-data-btn:active { opacity: 1; background: var(--accent); color: var(--bg); } +.file-tree-attach-btn:active { + opacity: 1; + background: var(--active-bg, var(--hover-bg)); +} + .file-tree-item:hover .file-tree-load-data-btn, -.file-tree-item:focus-within .file-tree-load-data-btn { +.file-tree-item:focus-within .file-tree-load-data-btn, +.file-tree-item:hover .file-tree-attach-btn, +.file-tree-item:focus-within .file-tree-attach-btn { display: flex; align-items: center; justify-content: center; } @media (hover: none) { - .file-tree-load-data-btn { + .file-tree-load-data-btn, + .file-tree-attach-btn { display: flex; align-items: center; justify-content: center; @@ -700,17 +722,40 @@ } .file-viewer-fullsize-btn:hover, -.file-viewer-open-tab-btn:hover { +.file-viewer-open-tab-btn:hover, +.file-viewer-attach-tab-btn:hover, +.file-viewer-attach-float-btn:hover { background: var(--hover-bg); color: var(--text); } .file-viewer-fullsize-btn svg, -.file-viewer-open-tab-btn svg { +.file-viewer-open-tab-btn svg, +.file-viewer-attach-tab-btn svg, +.file-viewer-attach-float-btn svg { width: 18px; height: 18px; } +.file-viewer-attach-float-btn { + position: absolute; + bottom: 24px; + right: 68px; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); + transition: background var(--transition-fast), color var(--transition-fast); + z-index: 10; +} + /* Open in new tab button (for text files) */ .file-viewer-open-tab-btn { position: fixed; @@ -731,6 +776,38 @@ z-index: 10; } +.file-viewer-attach-tab-btn { + position: fixed; + bottom: calc(24px + env(safe-area-inset-bottom)); + right: 68px; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); + transition: background var(--transition-fast), color var(--transition-fast); + z-index: 10; +} + +.file-viewer-error-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.file-viewer-attach-inline-btn { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); +} + /* Dragging state */ .file-panel.dragging { transition: none !important; diff --git a/public/css/messages.css b/public/css/messages.css index 695690e..4d357a7 100644 --- a/public/css/messages.css +++ b/public/css/messages.css @@ -962,9 +962,10 @@ html[data-theme="light"] .code-block::before { /* Compressed messages section */ .compressed-section { display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: 8px; + gap: 4px; padding: 10px 16px; margin: 0 auto 12px; background: var(--surface); @@ -987,6 +988,12 @@ html[data-theme="light"] .code-block::before { transition: transform 0.2s ease; } +.compressed-section-main { + display: inline-flex; + align-items: center; + gap: 8px; +} + .compressed-section[aria-expanded="true"] svg { transform: rotate(180deg); } @@ -997,6 +1004,12 @@ html[data-theme="light"] .code-block::before { font-weight: 500; } +.compressed-section-hint { + font-size: 11px; + color: var(--text-secondary); + opacity: 0.8; +} + /* Summarized/compressed messages (collapsed by default) */ .message.summarized { opacity: 0.5; @@ -1030,6 +1043,74 @@ html[data-theme="light"] .code-block::before { display: block; } +.message.summarized.show-compressed { + opacity: 0.96; + max-height: none; + overflow: visible; +} + +.message.summarized.show-compressed::after { + display: none; +} + +.message.assistant.summarized.show-compressed { + border-left: 3px dashed var(--accent-alpha-50); + background: + repeating-linear-gradient( + -45deg, + color-mix(in srgb, var(--assistant-bubble) 90%, var(--accent) 10%), + color-mix(in srgb, var(--assistant-bubble) 90%, var(--accent) 10%) 10px, + var(--assistant-bubble) 10px, + var(--assistant-bubble) 20px + ); +} + +.message.user.summarized.show-compressed { + border-right: 3px dashed var(--accent-alpha-50); + background: + repeating-linear-gradient( + -45deg, + rgba(255, 255, 255, 0.08), + rgba(255, 255, 255, 0.08) 10px, + rgba(0, 0, 0, 0.04) 10px, + rgba(0, 0, 0, 0.04) 20px + ), + linear-gradient(145deg, var(--user-bubble) 0%, var(--user-bubble-end) 100%); +} + +.compressed-context-badge { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + padding: 3px 8px; + width: fit-content; + border-radius: 999px; + background: var(--accent-alpha-12); + border: 1px solid var(--accent-alpha-30); + color: var(--accent-light); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.compressed-context-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-light); +} + +.message.user.summarized.show-compressed .compressed-context-badge { + background: rgba(0, 0, 0, 0.24); + border-color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.95); +} + +.message.user.summarized.show-compressed .compressed-context-badge-dot { + background: rgba(255, 255, 255, 0.95); +} + /* Compression summary message */ .message.compression-summary { background: var(--bg-tertiary); diff --git a/public/js/explorer/context.js b/public/js/explorer/context.js index 33f4b37..f59f47d 100644 --- a/public/js/explorer/context.js +++ b/public/js/explorer/context.js @@ -67,6 +67,12 @@ export function createConversationContext(conversationIdOrGetter) { return withQuery(`/api/conversations/${conversationId}/upload`, { filename }); }, + getAttachExistingFilesUrl() { + const conversationId = getConversationId(); + if (!conversationId) return null; + return `/api/conversations/${conversationId}/attachments/from-files`; + }, + getGitUrl(endpoint) { const conversationId = getConversationId(); if (!conversationId) return null; diff --git a/public/js/explorer/file-tree-ui.js b/public/js/explorer/file-tree-ui.js index edb38d4..b371528 100644 --- a/public/js/explorer/file-tree-ui.js +++ b/public/js/explorer/file-tree-ui.js @@ -95,7 +95,7 @@ export function renderFileTreeView({ const index = parseInt(item.dataset.index, 10); const entry = entries[index]; if (!entry) return; - await onExtra(entry); + await onExtra(entry, btn); }); }); } diff --git a/public/js/explorer/file-viewer-content.js b/public/js/explorer/file-viewer-content.js index f93693b..8bb5d4e 100644 --- a/public/js/explorer/file-viewer-content.js +++ b/public/js/explorer/file-viewer-content.js @@ -17,6 +17,7 @@ const GEO_PREVIEW_EXTS = new Set(['geojson', 'json', 'topojson', 'jsonl', 'ndjso const JSON_PREVIEW_EXTS = new Set(['json', 'geojson', 'topojson']); const DEFAULT_OPEN_EXTERNAL_ICON = ''; +const DEFAULT_ATTACH_ICON = ''; function toSafeString(value) { return value === null || value === undefined ? '' : String(value); @@ -39,6 +40,16 @@ function attachOpenButton(container, selector, url) { } } +function attachActionButton(container, selector, onAction) { + if (typeof onAction !== 'function') return; + const button = container.querySelector(selector); + if (button) { + button.addEventListener('click', () => { + void onAction(); + }); + } +} + function renderDataPreview(data, { escapeHtml, enableCopyCells }) { const columns = data.columns || []; const rows = data.rows || []; @@ -191,12 +202,15 @@ function renderGeoPreview(container, { geoResult, fileUrl, openExternalIcon, + attachIcon, + canAttach = false, escapeHtml, rawContent, rawDisabled = false, rawPreviewSize = null, formatFileSize, onRefresh = null, + onAttach = null, }) { const summary = escapeHtml(geoResult.summary || 'GeoJSON map preview'); const rawText = escapeHtml(toSafeString(rawContent)); @@ -288,10 +302,12 @@ function renderGeoPreview(container, { ${rawPanelHtml} + ${canAttach ? `` : ''} `; attachOpenButton(container, '.file-viewer-open-tab-btn', fileUrl); + attachActionButton(container, '.file-viewer-attach-tab-btn', onAttach); if (rawDisabled) { attachOpenButton(container, '.geo-preview-open-raw-btn', fileUrl); } @@ -504,6 +520,7 @@ export function renderFileViewerContent({ previewableExts = DEFAULT_PREVIEWABLE_EXTS, enableCopyCells = true, onRefresh = null, + onAttach = null, }) { if (!container || !data || !context) return false; @@ -514,6 +531,8 @@ export function renderFileViewerContent({ const downloadUrl = context.getFileDownloadUrl(filePath); const fileLikeIcon = icons.document || icons.file || ''; const openExternalIcon = icons.openExternal || DEFAULT_OPEN_EXTERNAL_ICON; + const attachIcon = icons.attach || DEFAULT_ATTACH_ICON; + const canAttach = context.kind === 'conversation' && typeof onAttach === 'function'; if (data.csv || data.parquet) { container.innerHTML = renderDataPreview(data, { escapeHtml, enableCopyCells }); @@ -534,6 +553,7 @@ export function renderFileViewerContent({ container.innerHTML = `
${escapeHtml(toSafeString(data.name))} + ${canAttach ? `` : ''}
`; @@ -542,6 +562,7 @@ export function renderFileViewerContent({ const btn = container.querySelector('.file-viewer-fullsize-btn'); if (img) img.addEventListener('click', openFullSize); if (btn) btn.addEventListener('click', openFullSize); + attachActionButton(container, '.file-viewer-attach-float-btn', onAttach); return true; } @@ -551,9 +572,13 @@ export function renderFileViewerContent({ ${fileLikeIcon}

${escapeHtml(toSafeString(data.name))}

${formatFileSize(data.size)}

- +
+ ${canAttach ? `` : ''} + +
`; + attachActionButton(container, '.file-viewer-attach-inline-btn', onAttach); attachOpenButton(container, '.file-viewer-open-btn', fileUrl); return true; } @@ -563,9 +588,13 @@ export function renderFileViewerContent({ ${icons.file || fileLikeIcon}

Binary file cannot be previewed

${formatFileSize(data.size)}

- +
+ ${canAttach ? `` : ''} + +
`; + attachActionButton(container, '.file-viewer-attach-inline-btn', onAttach); attachOpenButton(container, '.file-viewer-open-btn', downloadUrl); return true; } @@ -582,8 +611,10 @@ export function renderFileViewerContent({ ${fileLikeIcon}

${sizeErrorTitle}

${formatFileSize(data.size)} (max ${maxPreview})

+ ${canAttach ? `` : ''} `; + attachActionButton(container, '.file-viewer-attach-inline-btn', onAttach); return true; } @@ -596,12 +627,15 @@ export function renderFileViewerContent({ geoResult, fileUrl, openExternalIcon, + attachIcon, + canAttach, escapeHtml, rawContent: data.rawTruncated ? '' : content, rawDisabled: !!data.rawTruncated, rawPreviewSize: data.rawPreviewSize, formatFileSize, onRefresh, + onAttach, }); return true; } @@ -613,8 +647,10 @@ export function renderFileViewerContent({
${rendered}
+ ${canAttach ? `` : ''} `; + attachActionButton(container, '.file-viewer-attach-tab-btn', onAttach); attachOpenButton(container, '.file-viewer-open-tab-btn', fileUrl); return true; } @@ -624,9 +660,11 @@ export function renderFileViewerContent({ if (jsonHtml) { container.innerHTML = ` ${jsonHtml} + ${canAttach ? `` : ''} `; attachJsonHandlers(container); + attachActionButton(container, '.file-viewer-attach-tab-btn', onAttach); attachOpenButton(container, '.file-viewer-open-tab-btn', fileUrl); return true; } @@ -635,9 +673,11 @@ export function renderFileViewerContent({ const langClass = data.language ? `language-${data.language}` : ''; container.innerHTML = ` ${escapeHtml(content)} + ${canAttach ? `` : ''} `; highlightCodeBlocks(container); + attachActionButton(container, '.file-viewer-attach-tab-btn', onAttach); attachOpenButton(container, '.file-viewer-open-tab-btn', fileUrl); return true; } diff --git a/public/js/explorer/shell.js b/public/js/explorer/shell.js index e2efc77..80e56ba 100644 --- a/public/js/explorer/shell.js +++ b/public/js/explorer/shell.js @@ -55,6 +55,7 @@ export function createExplorerShell({ onViewerWillOpen = noop, onViewerWillClose = noop, onViewerDidClose = noop, + onAttachFromViewer = null, onNavigateHaptic = noop, isNavigationBlocked = () => false, resolveUploadTargetPath = (path) => path, @@ -212,6 +213,7 @@ export function createExplorerShell({ imageExts, enableCopyCells: true, onRefresh: () => viewFile(filePath), + onAttach: onAttachFromViewer ? () => onAttachFromViewer(filePath) : null, }); return true; diff --git a/public/js/file-panel/file-browser.js b/public/js/file-panel/file-browser.js index cffbac6..4b8c243 100644 --- a/public/js/file-panel/file-browser.js +++ b/public/js/file-panel/file-browser.js @@ -67,6 +67,52 @@ async function loadFileToDataTab(filePath) { } } +async function attachExistingFileToChat(filePath) { + if (!fileBrowserContext.isAvailable()) { + showToast('No conversation selected', { variant: 'error' }); + return; + } + + const url = fileBrowserContext.getAttachExistingFilesUrl?.(); + if (!url) { + showToast('Unable to attach file', { variant: 'error' }); + return; + } + + const res = await apiFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: [filePath] }), + }); + if (!res) return; + + const data = await res.json(); + const attachments = Array.isArray(data.attachments) ? data.attachments : []; + const failed = Array.isArray(data.failed) ? data.failed : []; + + if (attachments.length === 0) { + showToast(data.error || failed[0]?.error || 'Failed to attach file', { variant: 'error' }); + return; + } + + for (const attachment of attachments) { + if (!attachment?.path || !attachment?.filename) continue; + const ext = (attachment.filename.split('.').pop() || '').toLowerCase(); + state.addPendingAttachment({ + kind: 'server', + attachment, + name: attachment.filename, + previewUrl: IMAGE_EXTS.has(ext) ? attachment.url : undefined, + }); + } + + const fileCount = attachments.length; + showToast(`Attached ${fileCount} file${fileCount > 1 ? 's' : ''} to chat`); + if (failed.length > 0) { + showToast(`Skipped ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed[0].error}`, { variant: 'error' }); + } +} + // DOM elements (set by init) let filePanelUp = null; let filePanelPath = null; @@ -135,16 +181,30 @@ export function initFileBrowser(elements) { return baseCwd ? `${baseCwd}/${entry.path}` : entry.path; }, getExtraButtonHtml: (entry) => { + const isFile = entry.type === 'file'; const isDataFile = entry.type === 'file' && DATA_FILE_EXTS.has(entry.ext); - if (!isDataFile) return ''; - return ``; + + if (!isDataFile) return attachButton; + const dataButton = ``; + return attachButton + dataButton; }, - extraButtonSelector: '.file-tree-load-data-btn', - onExtra: async (entry) => { + extraButtonSelector: '.file-tree-extra-btn', + onExtra: async (entry, button) => { haptic(); - await loadFileToDataTab(entry.path); + if (button?.dataset.action === 'data') { + await loadFileToDataTab(entry.path); + return; + } + if (button?.dataset.action === 'attach') { + await attachExistingFileToChat(entry.path); + } }, onItemActivate: () => haptic(5), onDirectoryPathChanged: (path) => { @@ -161,6 +221,10 @@ export function initFileBrowser(elements) { viewingDiff = null; hideGranularToggle(); }, + onAttachFromViewer: async (filePath) => { + haptic(); + await attachExistingFileToChat(filePath); + }, isNavigationBlocked: () => viewingDiff, onNavigateHaptic: haptic, }); diff --git a/public/js/render.js b/public/js/render.js index 3b30707..a72d45f 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -36,8 +36,34 @@ function buildMessageMeta(msg) { meta += ` · ${(msg.duration / 1000).toFixed(1)}s`; } if (msg.inputTokens != null) { - const displayIn = msg.displayInputTokens != null ? msg.displayInputTokens : msg.inputTokens; - meta += ` · ${formatTokens(displayIn)} in / ${formatTokens(msg.outputTokens)} out`; + const netIn = msg.netInputTokens != null ? msg.netInputTokens : msg.inputTokens; + const typedIn = msg.typedInputTokens; + const rawIn = msg.rawInputTokens; + const cachedIn = msg.cachedInputTokens; + const hasDetailedInput = typedIn != null || rawIn != null || cachedIn != null || msg.netInputTokens != null; + const reasoningOut = msg.reasoningTokens; + + if (!hasDetailedInput) { + const displayIn = msg.displayInputTokens != null ? msg.displayInputTokens : msg.inputTokens; + meta += ` · ${formatTokens(displayIn)} in / ${formatTokens(msg.outputTokens)} out`; + } else { + const inputParts = []; + if (typedIn != null) inputParts.push(`${formatTokens(typedIn)} typed`); + inputParts.push(`${formatTokens(netIn)} net in`); + + const rawCachedParts = []; + if (rawIn != null) rawCachedParts.push(`${formatTokens(rawIn)} raw`); + if (cachedIn != null) rawCachedParts.push(`${formatTokens(cachedIn)} cached`); + if (rawCachedParts.length > 0) { + inputParts.push(`(${rawCachedParts.join(', ')})`); + } + + let outputPart = `${formatTokens(msg.outputTokens)} out`; + if (reasoningOut != null && reasoningOut > 0) { + outputPart += ` (${formatTokens(reasoningOut)} reasoning)`; + } + meta += ` · ${inputParts.join(' / ')} / ${outputPart}`; + } } // Show incomplete indicator if explicitly marked OR if assistant message is missing cost/tokens // (retroactive detection for messages saved before we added the incomplete flag) @@ -184,10 +210,12 @@ function attachCompressedSectionToggle() { const messagesContainer = state.getMessagesContainer(); const toggle = messagesContainer.querySelector('#compressed-section-toggle'); if (!toggle) return; + if (toggle.dataset.bound === 'true') return; + toggle.dataset.bound = 'true'; toggle.addEventListener('click', () => { const isExpanded = toggle.getAttribute('aria-expanded') === 'true'; - toggle.setAttribute('aria-expanded', !isExpanded); + toggle.setAttribute('aria-expanded', String(!isExpanded)); // Toggle visibility of summarized messages messagesContainer.querySelectorAll('.message-wrapper.summarized, .message.summarized').forEach(el => { @@ -211,8 +239,11 @@ export function renderMessageSlice(messages, startIndex) { let compressedSection = ''; if (compressedCount > 0 && startIndex === 0) { compressedSection = ``; } @@ -258,16 +289,19 @@ export function renderMessageSlice(messages, startIndex) { ? buildActionButtons({ includeTTS: !isSummarized, includeRegen: isLastAssistant, includeCopy: true }) : buildActionButtons({ includeTTS: false, includeRegen: false, includeCopy: true, isUser: true }); + const compressedBadge = isSummarized + ? `
Compressed • excluded from active context
` + : ''; const summarizedClass = isSummarized ? ' summarized' : ''; // Wrap assistant messages with avatar if (cls === 'assistant') { return `
${CLAUDE_AVATAR_SVG}
-
${attachHtml}${content}
${meta}
${actionBtns}
+
${attachHtml}${compressedBadge}${content}
${meta}
${actionBtns}
`; } - return `
${attachHtml}${content}
${meta}
${actionBtns}
`; + return `
${attachHtml}${compressedBadge}${content}
${meta}
${actionBtns}
`; }).join(''); return compressedSection + messagesHtml; @@ -292,6 +326,7 @@ export function loadMoreMessages() { attachImageHandlers(); attachCopyMsgHandlers(); attachMessageActions(); + attachCompressedSectionToggle(); state.setMessagesOffset(newOffset); // Preserve scroll position messagesContainer.scrollTop += messagesContainer.scrollHeight - prevScrollHeight; @@ -397,8 +432,13 @@ export function finalizeMessage(data) { cost: data.cost, duration: data.duration, inputTokens: data.inputTokens, + netInputTokens: data.netInputTokens, + typedInputTokens: data.typedInputTokens, displayInputTokens: data.displayInputTokens, outputTokens: data.outputTokens, + reasoningTokens: data.reasoningTokens, + rawInputTokens: data.rawInputTokens, + cachedInputTokens: data.cachedInputTokens, incomplete: data.incomplete, }); diff --git a/public/js/state.js b/public/js/state.js index e05e5e4..168d52b 100644 --- a/public/js/state.js +++ b/public/js/state.js @@ -98,6 +98,17 @@ export let semanticSearchEnabled = localStorage.getItem('semanticSearch') === 't // Pending attachments export let pendingAttachments = []; +const PENDING_ATTACHMENTS_EVENT = 'pending-attachments-changed'; + +function maybeRevokeObjectUrl(url) { + if (typeof url !== 'string') return; + if (!url.startsWith('blob:')) return; + URL.revokeObjectURL(url); +} + +function emitPendingAttachmentsChanged() { + window.dispatchEvent(new CustomEvent(PENDING_ATTACHMENTS_EVENT)); +} // Directory browser export let currentBrowsePath = ''; @@ -597,6 +608,7 @@ export function toggleSemanticSearch() { export function setPendingAttachments(attachments) { pendingAttachments = attachments; + emitPendingAttachmentsChanged(); } export function getPendingAttachments() { @@ -605,18 +617,23 @@ export function getPendingAttachments() { export function addPendingAttachment(att) { pendingAttachments.push(att); + emitPendingAttachmentsChanged(); } export function removePendingAttachment(idx) { - if (pendingAttachments[idx]?.previewUrl) { - URL.revokeObjectURL(pendingAttachments[idx].previewUrl); - } + maybeRevokeObjectUrl(pendingAttachments[idx]?.previewUrl); pendingAttachments.splice(idx, 1); + emitPendingAttachmentsChanged(); } export function clearPendingAttachments() { - pendingAttachments.forEach(a => { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); }); + pendingAttachments.forEach(a => maybeRevokeObjectUrl(a.previewUrl)); pendingAttachments = []; + emitPendingAttachmentsChanged(); +} + +export function getPendingAttachmentsEventName() { + return PENDING_ATTACHMENTS_EVENT; } export function setCurrentBrowsePath(path) { diff --git a/public/js/ui.js b/public/js/ui.js index 8e8d091..6408cd6 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -657,6 +657,8 @@ export async function sendMessage(text) { const pendingAttachments = state.getPendingAttachments(); const currentConversationId = state.getCurrentConversationId(); const ws = getWS(); + const currentConversation = state.conversations.find((c) => c.id === currentConversationId); + const currentProvider = currentConversation?.provider || 'claude'; if ((!text.trim() && pendingAttachments.length === 0) || !currentConversationId) return; haptic(5); @@ -689,12 +691,26 @@ export async function sendMessage(text) { return; } + if (pendingAttachments.length > 0 && currentProvider === 'ollama') { + showToast('Attachments are not supported with Ollama. Switch to Claude or Codex.', { variant: 'error' }); + return; + } + // Upload attachments first let attachments = []; for (const att of pendingAttachments) { + if (att?.kind === 'server' && att.attachment?.path) { + attachments.push(att.attachment); + continue; + } + + const file = att?.file; + const name = att?.name || file?.name; + if (!file || !name) continue; + const resp = await apiFetch( - `/api/conversations/${currentConversationId}/upload?filename=${encodeURIComponent(att.name)}`, - { method: 'POST', body: att.file } + `/api/conversations/${currentConversationId}/upload?filename=${encodeURIComponent(name)}`, + { method: 'POST', body: file } ); if (!resp) continue; const result = await resp.json(); @@ -773,12 +789,13 @@ export function renderAttachmentPreview() { } attachmentPreview.classList.remove('hidden'); attachmentPreview.innerHTML = pendingAttachments.map((att, i) => { + const name = att.name || att.attachment?.filename || 'attachment'; const thumb = att.previewUrl ? `` : '📎'; return `
${thumb} - ${escapeHtml(att.name)} + ${escapeHtml(name)}
`; }).join(''); @@ -1308,16 +1325,19 @@ export function setupEventListeners(createConversation) { // Attachments attachBtn.addEventListener('click', () => fileInput.click()); + window.addEventListener(state.getPendingAttachmentsEventName(), () => { + renderAttachmentPreview(); + }); + fileInput.addEventListener('change', () => { for (const file of fileInput.files) { - const att = { file, name: file.name }; + const att = { kind: 'local', file, name: file.name }; if (file.type.startsWith('image/')) { att.previewUrl = URL.createObjectURL(file); } state.addPendingAttachment(att); } fileInput.value = ''; - renderAttachmentPreview(); }); // Drag-and-drop file upload (entire chat view) @@ -1346,13 +1366,12 @@ export function setupEventListeners(createConversation) { if (e.dataTransfer.files.length === 0) return; for (const file of e.dataTransfer.files) { - const att = { file, name: file.name }; + const att = { kind: 'local', file, name: file.name }; if (file.type.startsWith('image/')) { att.previewUrl = URL.createObjectURL(file); } state.addPendingAttachment(att); } - renderAttachmentPreview(); }); // Export diff --git a/public/js/ui/context-bar.js b/public/js/ui/context-bar.js index 311b13e..a003e4b 100644 --- a/public/js/ui/context-bar.js +++ b/public/js/ui/context-bar.js @@ -45,16 +45,40 @@ export function setupContextBarEventListeners() { * active context window for the current session. */ export function calculateCumulativeTokens(messages) { - const lastAssistant = [...(messages || [])] + const allMessages = messages || []; + const lastAssistant = [...allMessages] .reverse() .find(msg => msg.role === 'assistant' && msg.inputTokens != null); + const latestCompressionAt = allMessages.reduce((latest, msg) => { + const ts = msg?.compressionMeta?.compressedAt; + return typeof ts === 'number' && ts > latest ? ts : latest; + }, 0); if (!lastAssistant) { + if (latestCompressionAt > 0) { + // No post-compression token metrics yet; estimate from remaining visible context. + const estimated = allMessages.reduce((sum, msg) => { + if (msg?.summarized) return sum; + return sum + Math.ceil((msg?.text || '').length / 4); + }, 0); + return { inputTokens: estimated, outputTokens: 0 }; + } return { inputTokens: 0, outputTokens: 0 }; } + // After compression, historical assistant token usage is stale until a new response arrives. + if (latestCompressionAt > 0 && (lastAssistant.timestamp || 0) < latestCompressionAt) { + const estimated = allMessages.reduce((sum, msg) => { + if (msg?.summarized) return sum; + return sum + Math.ceil((msg?.text || '').length / 4); + }, 0); + return { inputTokens: estimated, outputTokens: 0 }; + } + return { - inputTokens: lastAssistant.inputTokens || 0, + inputTokens: lastAssistant.rawInputTokens != null + ? (lastAssistant.rawInputTokens || 0) + : (lastAssistant.inputTokens || 0), outputTokens: lastAssistant.outputTokens || 0, }; } @@ -120,11 +144,8 @@ function showContextBreakdown() { }, 0); const systemTokens = 12000 + memoryTokens; - // Get last response's input tokens (actual context used) - const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant' && m.inputTokens); - const currentContext = lastAssistant - ? (lastAssistant.inputTokens || 0) + (lastAssistant.outputTokens || 0) - : 0; + const { inputTokens, outputTokens } = calculateCumulativeTokens(messages); + const currentContext = (inputTokens || 0) + (outputTokens || 0); // Message count and oldest message const msgCount = messages.length; @@ -214,7 +235,7 @@ async function compressConversation() { if (!res || !res.ok) { const data = res ? await res.json() : { error: 'Network error' }; - showToast(data.error || 'Compression failed', 'error'); + showToast(data.error || 'Compression failed', { variant: 'error' }); if (compressBtn) { compressBtn.disabled = false; compressBtn.textContent = 'Compress conversation'; @@ -227,10 +248,11 @@ async function compressConversation() { hideContextBreakdown(); // Reload conversation to show updated messages - const { openConversation } = await import('../conversations.js'); - openConversation(convId); + const { openConversation, loadConversations } = await import('../conversations.js'); + await openConversation(convId); + await loadConversations(); } catch (err) { - showToast('Compression failed: ' + err.message, 'error'); + showToast('Compression failed: ' + err.message, { variant: 'error' }); if (compressBtn) { compressBtn.disabled = false; compressBtn.textContent = 'Compress conversation'; diff --git a/test/claude.test.js b/test/claude.test.js index ad8c462..fd7c13b 100644 --- a/test/claude.test.js +++ b/test/claude.test.js @@ -174,6 +174,18 @@ describe('buildInlineHistoryContext', () => { assert.ok(context.includes('/Users/djlewis/Git/geoarch-xrd-ad4d4321/fix_data.py')); assert.ok(!context.includes('/Users/djlewis/Git/geoarch/fix_data.py')); }); + + it('skips summarized messages from inline history', () => { + const history = [ + { role: 'user', text: 'old message', summarized: true }, + { role: 'assistant', text: 'kept message' }, + ]; + + const context = buildInlineHistoryContext(history, ''); + + assert.ok(context.includes('kept message')); + assert.ok(!context.includes('old message')); + }); }); describe('processStreamEvent - thinking', () => { diff --git a/test/codex.test.js b/test/codex.test.js index 31c4cd7..7ef891c 100644 --- a/test/codex.test.js +++ b/test/codex.test.js @@ -1,6 +1,13 @@ const { describe, it, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); -const { processCodexEvent, MODELS, handleNoOutputClose } = require('../lib/providers/codex'); +const { + processCodexEvent, + MODELS, + handleNoOutputClose, + partitionAttachments, + buildFileAttachmentPrompt, +} = require('../lib/providers/codex'); +const { buildInlineHistoryContext } = require('../lib/providers/codex')._private; describe('Codex MODELS', () => { it('contains expected models', () => { @@ -22,6 +29,51 @@ describe('Codex MODELS', () => { }); }); +describe('Codex attachment helpers', () => { + it('partitions image and non-image attachments with valid paths', () => { + const result = partitionAttachments([ + { path: '/tmp/a.png' }, + { path: '/tmp/b.jpeg' }, + { path: '/tmp/c.pdf' }, + { path: '/tmp/d.txt' }, + { path: null }, + {}, + ]); + + assert.deepEqual(result.imageAttachments.map((item) => item.path), ['/tmp/a.png', '/tmp/b.jpeg']); + assert.deepEqual(result.fileAttachments.map((item) => item.path), ['/tmp/c.pdf', '/tmp/d.txt']); + }); + + it('builds non-image attachment prompt block', () => { + const text = buildFileAttachmentPrompt([ + { path: '/tmp/report.pdf' }, + { path: '/tmp/data.csv' }, + ]); + + assert.ok(text.includes('[Attached files')); + assert.ok(text.includes('/tmp/report.pdf')); + assert.ok(text.includes('/tmp/data.csv')); + }); + + it('returns empty prompt block when no files are provided', () => { + assert.equal(buildFileAttachmentPrompt([]), ''); + }); +}); + +describe('buildInlineHistoryContext', () => { + it('skips summarized messages from inline history', () => { + const history = [ + { role: 'user', text: 'old message', summarized: true }, + { role: 'assistant', text: 'kept message' }, + ]; + + const context = buildInlineHistoryContext(history, ''); + + assert.ok(context.includes('kept message')); + assert.ok(!context.includes('old message')); + }); +}); + describe('processCodexEvent - thread.started', () => { let sent; let fakeWs; @@ -491,7 +543,9 @@ describe('processCodexEvent - turn.completed', () => { assert.equal(conv.messages[0].rawInputTokens, 12000); assert.equal(conv.messages[0].cachedInputTokens, 11000); assert.equal(conv.messages[0].inputTokens, 1000); + assert.equal(conv.messages[0].netInputTokens, 1000); assert.equal(conv.messages[0].displayInputTokens, 1); + assert.equal(conv.messages[0].typedInputTokens, 1); }); it('renders tool calls from batched turn.completed items', () => { diff --git a/test/context-bar.test.js b/test/context-bar.test.js new file mode 100644 index 0000000..95a55dd --- /dev/null +++ b/test/context-bar.test.js @@ -0,0 +1,84 @@ +const { describe, it, after } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'public', 'js', 'ui', 'context-bar.js')).href; + +describe('context bar token calculation', async () => { + const originalLocalStorage = globalThis.localStorage; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + + globalThis.localStorage = { + getItem() { return null; }, + setItem() {}, + removeItem() {}, + }; + globalThis.document = { + title: 'test', + getElementById() { return null; }, + }; + globalThis.window = { + dispatchEvent() {}, + }; + + after(() => { + globalThis.localStorage = originalLocalStorage; + globalThis.document = originalDocument; + globalThis.window = originalWindow; + }); + + const { calculateCumulativeTokens } = await import(moduleUrl); + + it('uses the latest assistant token counts when no compression exists', () => { + const messages = [ + { role: 'assistant', inputTokens: 120, outputTokens: 40, timestamp: 100 }, + { role: 'assistant', inputTokens: 360, outputTokens: 90, timestamp: 200 }, + ]; + + assert.deepEqual( + calculateCumulativeTokens(messages), + { inputTokens: 360, outputTokens: 90 } + ); + }); + + it('falls back to estimated tokens when the latest assistant counts predate compression', () => { + const messages = [ + { role: 'user', text: 'x'.repeat(20), summarized: true, timestamp: 100 }, + { role: 'assistant', text: 'y'.repeat(20), summarized: true, inputTokens: 500, outputTokens: 100, timestamp: 200 }, + { role: 'system', text: 's'.repeat(8), compressionMeta: { compressedAt: 1000 }, timestamp: 1000 }, + { role: 'assistant', text: 'a'.repeat(16), inputTokens: 300, outputTokens: 80, timestamp: 900 }, + { role: 'user', text: 'b'.repeat(12), timestamp: 950 }, + ]; + + // Unsummarized estimate: 8/4 + 16/4 + 12/4 = 2 + 4 + 3 = 9 + assert.deepEqual( + calculateCumulativeTokens(messages), + { inputTokens: 9, outputTokens: 0 } + ); + }); + + it('uses assistant token counts again after a post-compression response', () => { + const messages = [ + { role: 'system', text: 'summary', compressionMeta: { compressedAt: 1000 }, timestamp: 1000 }, + { role: 'assistant', text: 'new', inputTokens: 42, outputTokens: 8, timestamp: 1500 }, + ]; + + assert.deepEqual( + calculateCumulativeTokens(messages), + { inputTokens: 42, outputTokens: 8 } + ); + }); + + it('prefers raw input tokens for context usage when available', () => { + const messages = [ + { role: 'assistant', text: 'new', inputTokens: 42, rawInputTokens: 2048, outputTokens: 8, timestamp: 1500 }, + ]; + + assert.deepEqual( + calculateCumulativeTokens(messages), + { inputTokens: 2048, outputTokens: 8 } + ); + }); +}); diff --git a/test/explorer-context.test.js b/test/explorer-context.test.js index c857273..81969f4 100644 --- a/test/explorer-context.test.js +++ b/test/explorer-context.test.js @@ -29,6 +29,10 @@ describe('explorer context adapters', async () => { context.getUploadUrl('a b.txt'), '/api/conversations/conv-123/upload?filename=a+b.txt' ); + assert.equal( + context.getAttachExistingFilesUrl(), + '/api/conversations/conv-123/attachments/from-files' + ); assert.equal( context.getFileSearchUrl('hello world'), '/api/conversations/conv-123/files/search?q=hello+world' diff --git a/test/files-routes.test.js b/test/files-routes.test.js index ccbbdd2..6454941 100644 --- a/test/files-routes.test.js +++ b/test/files-routes.test.js @@ -89,6 +89,9 @@ function createFileRouteFixture() { [require.resolve('../lib/data')]: { UPLOAD_DIR: '/tmp/uploads', }, + [require.resolve('../lib/constants')]: { + MAX_UPLOAD_SIZE: 16 * 1024, + }, [require.resolve('../lib/routes/helpers')]: helpers, }, __filename); @@ -107,6 +110,10 @@ function createFileRouteFixture() { getDownloads() { return [...downloads]; }, + setConversationCwd(cwd) { + const conv = conversations.get('conv-1'); + if (conv) conv.cwd = cwd; + }, }, }; } @@ -133,6 +140,7 @@ describe('file routes', () => { if (tmpRoot) { await fs.rm(tmpRoot, { recursive: true, force: true }); } + await fs.rm('/tmp/uploads/conv-1', { recursive: true, force: true }); server = null; baseUrl = null; state = null; @@ -221,6 +229,65 @@ describe('file routes', () => { assert.ok(response.body.url.startsWith('/uploads/conv-1/')); }); + it('attaches existing conversation cwd files into uploads', async () => { + state.setConversationCwd(tmpRoot); + await fs.mkdir(path.join(tmpRoot, 'images'), { recursive: true }); + await fs.writeFile(path.join(tmpRoot, 'images', 'plot.png'), 'image-bytes'); + + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/attachments/from-files', { + paths: ['images/plot.png'], + }); + + assert.equal(response.status, 200); + assert.equal(response.body.attachments.length, 1); + assert.equal(response.body.failed.length, 0); + assert.equal(response.body.attachments[0].filename, 'plot.png'); + assert.ok(response.body.attachments[0].url.startsWith('/uploads/conv-1/')); + const copied = await fs.stat(response.body.attachments[0].path); + assert.equal(copied.isFile(), true); + }); + + it('returns partial success for attachment copy failures', async () => { + state.setConversationCwd(tmpRoot); + await fs.writeFile(path.join(tmpRoot, 'ok.txt'), 'ok'); + + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/attachments/from-files', { + paths: ['ok.txt', 'missing.txt'], + }); + + assert.equal(response.status, 200); + assert.equal(response.body.attachments.length, 1); + assert.equal(response.body.failed.length, 1); + assert.equal(response.body.failed[0].path, 'missing.txt'); + }); + + it('blocks traversal for attach-existing endpoint', async () => { + state.setConversationCwd(tmpRoot); + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/attachments/from-files', { + paths: ['../outside.txt'], + }); + + assert.equal(response.status, 400); + assert.equal(response.body.attachments.length, 0); + assert.equal(response.body.failed[0].error, 'Access denied'); + }); + + it('rejects non-file and oversized entries for attach-existing endpoint', async () => { + state.setConversationCwd(tmpRoot); + await fs.mkdir(path.join(tmpRoot, 'folder'), { recursive: true }); + await fs.writeFile(path.join(tmpRoot, 'large.bin'), Buffer.alloc(20 * 1024)); + + const response = await requestJson(baseUrl, 'POST', '/api/conversations/conv-1/attachments/from-files', { + paths: ['folder', 'large.bin'], + }); + + assert.equal(response.status, 400); + assert.equal(response.body.attachments.length, 0); + assert.equal(response.body.failed.length, 2); + assert.equal(response.body.failed[0].error, 'Path is not a file'); + assert.match(response.body.failed[1].error, /File too large/); + }); + it('returns browse error for invalid directory path', async () => { const response = await requestJson(baseUrl, 'GET', '/api/browse?path=/definitely/not/a/real/path'); assert.equal(response.status, 400); diff --git a/test/render-compression.test.js b/test/render-compression.test.js new file mode 100644 index 0000000..e84312c --- /dev/null +++ b/test/render-compression.test.js @@ -0,0 +1,80 @@ +const { describe, it, after } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const stateUrl = pathToFileURL(path.join(__dirname, '..', 'public', 'js', 'state.js')).href; +const renderUrl = pathToFileURL(path.join(__dirname, '..', 'public', 'js', 'render.js')).href; + +describe('render compressed message UI', async () => { + const originalLocalStorage = globalThis.localStorage; + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalNavigator = globalThis.navigator; + + globalThis.localStorage = { + getItem() { return null; }, + setItem() {}, + removeItem() {}, + }; + globalThis.document = { + title: 'test', + getElementById() { return null; }, + createElement() { return {}; }, + }; + globalThis.window = { + dispatchEvent() {}, + speechSynthesis: null, + }; + globalThis.navigator = {}; + + after(() => { + globalThis.localStorage = originalLocalStorage; + globalThis.document = originalDocument; + globalThis.window = originalWindow; + globalThis.navigator = originalNavigator; + }); + + const state = await import(stateUrl); + const { renderMessageSlice } = await import(renderUrl); + + it('renders compressed section hint and compressed context badge', () => { + const messages = [ + { role: 'assistant', text: 'older compressed response', summarized: true, timestamp: 1 }, + { role: 'user', text: 'new message', timestamp: 2 }, + ]; + state.setAllMessages(messages); + + const html = renderMessageSlice(messages, 0); + + assert.ok(html.includes('compressed-section-hint')); + assert.ok(html.includes('Excluded from active context')); + assert.ok(html.includes('compressed-context-badge')); + assert.ok(html.includes('Compressed • excluded from active context')); + }); + + it('renders detailed token breakdown when typed/raw/cached values exist', () => { + const messages = [ + { + role: 'assistant', + text: 'token-rich response', + timestamp: 3, + inputTokens: 1000, + netInputTokens: 1000, + typedInputTokens: 5, + rawInputTokens: 12000, + cachedInputTokens: 11000, + outputTokens: 6900, + }, + ]; + state.setAllMessages(messages); + + const html = renderMessageSlice(messages, 0); + + assert.ok(html.includes('5 typed')); + assert.ok(html.includes('1.0k net in')); + assert.ok(html.includes('12.0k raw')); + assert.ok(html.includes('11.0k cached')); + assert.ok(html.includes('6.9k out')); + }); +});