From a6642cbeb0fe44ceb30a603f03dc0b2983ab317e Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Thu, 23 Jul 2026 22:13:32 +0530 Subject: [PATCH] feat: update supercode-cli to version 0.1.85 and enhance request handling: - Bumped version in package.json to 0.1.85. - Introduced JSON body size limit for requests, configurable via environment variable. - Added error handling middleware to respond with appropriate messages for oversized requests. - Improved progress display messaging in AI response to provide clearer user updates. - Enhanced diff functionality to include line numbers and better context visualization for changes. --- apps/supercode-cli/server/package.json | 2 +- .../server/src/cli/ai/chat/chat.ts | 47 ++- .../server/src/cli/ai/chat/thinking.ts | 273 +++++++++++++++--- .../server/src/cli/utils/markdown-stream.ts | 22 +- .../server/src/cli/utils/tool-snapshot.ts | 164 ++++++++--- apps/supercode-cli/server/src/index.ts | 25 +- 6 files changed, 439 insertions(+), 94 deletions(-) diff --git a/apps/supercode-cli/server/package.json b/apps/supercode-cli/server/package.json index ab35d2f..b868667 100644 --- a/apps/supercode-cli/server/package.json +++ b/apps/supercode-cli/server/package.json @@ -1,6 +1,6 @@ { "name": "supercode-cli", - "version": "0.1.84", + "version": "0.1.85", "description": "AI-powered coding agent CLI", "main": "dist/main.js", "bin": { diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts index 0c5b310..035eec1 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts @@ -33,7 +33,6 @@ import { cardStack, rowCard, heavyDivider, - responseDivider, } from "src/cli/utils/tui.ts" import { ThinkingDisplay, TurnTracker, toolLabel, ThoughtChain, extractToolArg } from "./thinking.ts" import { StepStatusRow } from "./step-status-row.ts" @@ -313,8 +312,10 @@ async function streamAIResponse( promptContent += `\n\n## Plan Mode Note\n\nYou are in plan mode. You MUST NOT write files, run commands, or execute code. Produce a structured plan and stop. The user will review with /plan execute.` } - // Applied to all modes — shows progress progressively so user never sees a blank screen - promptContent += `\n\n## Progress Display\n\nShow your work progressively. Start every significant finding, observation, or intermediate result with "→" on its own line so the user sees you're making progress in real-time. If you would be thinking without visible output for more than ~3 seconds, instead announce what you're about to do.` + // Applied to all modes — encourage concise, user-facing progress updates. + // These are not hidden chain-of-thought; they are short status messages + // like "I’m checking the request parser before changing the chat loop." + promptContent += `\n\n## Progress Display\n\nWhile working, share brief first-person progress updates when they help the user follow along. Write them as plain prose, not hidden reasoning: one or two concrete sentences about what you are checking, what you learned, or what you are changing. Do not expose private chain-of-thought. Before using tools, state the next practical step when possible. After tool results reveal an important finding, summarize that finding before continuing.` if (extraContext) { promptContent += `\n\n## Referenced Files\n\nFiles marked with @ in the user message have been read and included below. Do not re-read them with tools.\n\n${extraContext}\n` @@ -722,6 +723,11 @@ async function streamAIResponse( // Flush any trailing markdown — finalizes the open block with a // typing animation so the user sees content appear progressively. + // Set fallback content from fullResponse in case the buffer is empty + // but the model did generate text (edge case where chunks weren't pushed). + if (fullResponse.trim().length > 0) { + md.setFallback(fullResponse) + } await md.end() // If tools ran but the model produced no analysis text, show a minimal @@ -819,10 +825,14 @@ async function streamAIResponse( // Turn footer with elapsed time const elapsedStr = elapsed < 1000 ? `${elapsed}ms` : `${(elapsed / 1000).toFixed(1)}s` - const modeLabel = mode === "plan" ? "plan" : (mode === "chat" ? "chat" : "build") - const connBadge = provider.connectionType === "direct" ? " 🔑" : " ☁️" + const footerText = ` Worked for ${elapsedStr} ` + const footerWidth = Math.max(0, (process.stdout.columns ?? 80) - stripAnsi(footerText).length - 2) + const leftFill = Math.floor(footerWidth / 2) + const rightFill = footerWidth - leftFill console.log( - ` ${chalk.hex(theme.green)("▣")} ${chalk.hex(theme.greenMute)(modeLabel)} ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.muted)(provider.modelName)}${chalk.hex(theme.amber)(connBadge)} ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.muted)(elapsedStr)}`, + chalk.hex(theme.greenDim)( + `${"─".repeat(leftFill)}${footerText}${"─".repeat(rightFill)}`, + ), ) console.log() return { @@ -845,6 +855,7 @@ async function streamAIResponse( } statusRow.stop() activeStatusRow = null + if (fullResponse.trim().length > 0) md.setFallback(fullResponse) await md.end() if (statusBar) statusBar.update({ isStreaming: false, elapsed: 0 }) console.log() @@ -859,6 +870,7 @@ async function streamAIResponse( statusRow.stop() activeStatusRow = null activeChain = null + if (fullResponse.trim().length > 0) md.setFallback(fullResponse) await md.end() if (statusBar) statusBar.update({ isStreaming: false, elapsed: 0 }) throw error @@ -2035,6 +2047,8 @@ export async function chatLoop( } catch (err: any) { const msg = err.message || String(err) process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(msg)}\r\n\n`) + } finally { + clearSkill() } footer.renderLine() } else { @@ -2113,10 +2127,10 @@ export async function chatLoop( if (result.skillContent) { loadedSkillName = result.skillName || "" loadedSkillContent = result.skillContent - const combinedMsg = `${result.skillContent}\n\n${result.message}` - userMessage(`[${result.skillName}]\n\n${result.skillContent}\n\n${result.message}`) + const taggedMsg = `[${result.skillName}]\n\n${result.message}` + userMessage(taggedMsg) messageCount++ - await addMessage(conversation.id, "user", combinedMsg) + await addMessage(conversation.id, "user", taggedMsg) } else { userMessage(trimmed) messageCount++ @@ -2132,6 +2146,8 @@ export async function chatLoop( } catch (err: any) { const msg = err.message || String(err) process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(msg)}\r\n\n`) + } finally { + if (result.skillContent) clearSkill() } readline.cursorTo(process.stdout, 0) continue @@ -2146,13 +2162,12 @@ export async function chatLoop( const cleanInput = unquoted.replace(/@\S+/g, (m) => m.slice(1)) if (loadedSkillContent) { - // Skill loaded from picker — combine skill content with user message - const combinedMsg = `${loadedSkillContent}\n\n${cleanInput}` - userMessage(`[${loadedSkillName}]\n\n${loadedSkillContent}\n\n${cleanInput}`) + // Skill instructions are injected as system context for this turn. + // Keep the transcript and persisted user message focused on the user's request. + const taggedMsg = `[${loadedSkillName}]\n\n${cleanInput}` + userMessage(taggedMsg) messageCount++ - await addMessage(conversation.id, "user", combinedMsg) - loadedSkillContent = undefined - loadedSkillName = undefined + await addMessage(conversation.id, "user", taggedMsg) } else { userMessage(unquoted) messageCount++ @@ -2273,6 +2288,8 @@ export async function chatLoop( } catch (error: any) { const errMsg = error?.message ?? "Unknown error" process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(errMsg)}\r\n\n`) + } finally { + clearSkill() } } catch (error: any) { // Catch-all: prevent any error from crashing the chat loop diff --git a/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts b/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts index b8dd1d8..12ee56d 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts @@ -1,5 +1,5 @@ import chalk from "chalk" -import { theme } from "src/cli/utils/tui" +import { stripAnsi, theme } from "src/cli/utils/tui" const SPINNER_FRAMES = ["∴", "∵", "∴", "∵"] @@ -143,6 +143,171 @@ function truncateStr(s: string, n: number): string { return s.slice(0, n - 1) + "…" } +function formatElapsed(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + const minutes = Math.floor(ms / 60_000) + const seconds = Math.round((ms % 60_000) / 1000) + return `${minutes}m ${seconds}s` +} + +function terminalWidth(): number { + return Math.max(40, process.stdout.columns ?? 80) +} + +function codexDivider(): string { + return chalk.hex(theme.greenDim)( + "─".repeat(Math.max(24, terminalWidth() - 2)), + ) +} + +function wrapVisible(line: string, width: number, indent = ""): string[] { + const plain = stripAnsi(line) + if (plain.length <= width) return [line] + + const words = line.split(/\s+/) + const lines: string[] = [] + let current = "" + + for (const word of words) { + const next = current ? `${current} ${word}` : word + if (stripAnsi(next).length > width && current) { + lines.push(current) + current = `${indent}${word}` + } else { + current = next + } + } + + if (current) lines.push(current) + return lines +} + +function parseToolArgs(args?: string | unknown): unknown { + if (typeof args !== "string") return args + return safeParse(args) +} + +function toolArgObject(args: unknown): Record { + return args && typeof args === "object" && !Array.isArray(args) + ? args as Record + : {} +} + +function shortPath(path: string, max = 72): string { + if (path.length <= max) return path + const parts = path.split("/") + if (parts.length <= 2) return truncateStr(path, max) + const file = parts.pop() ?? "" + const parent = parts.pop() ?? "" + const shortened = `…/${parent}/${file}` + return shortened.length <= max ? shortened : truncateStr(shortened, max) +} + +function codexToolLine(tool: ThoughtTool): string { + const args = parseToolArgs(tool.args) + const a = toolArgObject(args) + const marker = tool.flagged ? ` ${chalk.hex(theme.red)("✗")}` : "" + + switch (tool.name) { + case "read_file": { + const path = typeof a.path === "string" ? shortPath(a.path) : "file" + return `${chalk.hex(CATEGORY_COLORS.read)("Read")} ${chalk.hex(theme.greenMute)(path)}${marker}` + } + case "search_files": { + const pattern = typeof a.pattern === "string" ? a.pattern : extractToolArg(tool.name, args) ?? "" + const include = typeof a.include === "string" ? ` in ${a.include}` : "" + return `${chalk.hex(CATEGORY_COLORS.read)("Search")} ${chalk.hex(theme.greenMute)(truncateStr(pattern, 68))}${chalk.hex(theme.greenDim)(include)}${marker}` + } + case "glob": { + const pattern = typeof a.pattern === "string" ? a.pattern : extractToolArg(tool.name, args) ?? "" + return `${chalk.hex(CATEGORY_COLORS.read)("List")} ${chalk.hex(theme.greenMute)(truncateStr(pattern, 72))}${marker}` + } + case "run_command": { + const command = typeof a.command === "string" ? a.command : extractToolArg(tool.name, args) ?? "command" + return `${chalk.hex(CATEGORY_COLORS.command)(truncateStr(command, 92))}${marker}` + } + case "code_exec": { + const prompt = extractToolArg(tool.name, args) ?? "code" + return `${chalk.hex(CATEGORY_COLORS.command)("Execute")} ${chalk.hex(theme.greenMute)(truncateStr(prompt, 72))}${marker}` + } + case "edit_file": { + const path = typeof a.path === "string" ? shortPath(a.path) : "file" + return `${chalk.hex(CATEGORY_COLORS.edit)("Edit")} ${chalk.hex(theme.greenMute)(path)}${marker}` + } + case "write_file": { + const path = typeof a.path === "string" ? shortPath(a.path) : "file" + return `${chalk.hex(CATEGORY_COLORS.edit)("Write")} ${chalk.hex(theme.greenMute)(path)}${marker}` + } + case "web_search": + case "firecrawl_search": + case "firecrawl_scrape": + case "firecrawl_map": + case "url_fetch": { + const target = extractToolArg(tool.name, args) ?? tool.name + const verb = tool.name.includes("scrape") ? "Scrape" : tool.name.includes("map") ? "Map" : tool.name === "url_fetch" ? "Fetch" : "Search" + return `${chalk.hex(CATEGORY_COLORS.web)(verb)} ${chalk.hex(theme.greenMute)(truncateStr(target, 80))}${marker}` + } + default: { + const arg = extractToolArg(tool.name, args) + return `${chalk.hex(CATEGORY_COLORS.meta)(tool.name)}${arg ? ` ${chalk.hex(theme.greenMute)(truncateStr(arg, 72))}` : ""}${marker}` + } + } +} + +function codexSectionTitle(cat: ToolCategory, tools: ThoughtTool[]): string { + if (cat === "command" && tools.length === 1) { + const args = toolArgObject(parseToolArgs(tools[0]!.args)) + const command = typeof args.command === "string" ? args.command : extractToolArg(tools[0]!.name, parseToolArgs(tools[0]!.args)) ?? "command" + return `Ran ${chalk.hex(theme.greenMute)(truncateStr(command, 92))}` + } + if (cat === "edit") { + if (tools.length === 1) { + const args = toolArgObject(parseToolArgs(tools[0]!.args)) + const path = typeof args.path === "string" ? shortPath(args.path) : "file" + const verb = tools[0]!.name === "write_file" ? "Wrote" : "Edited" + return `${verb} ${chalk.hex(theme.greenMute)(path)}` + } + return `Edited ${tools.length} files` + } + if (cat === "read" || cat === "web") return "Explored" + return CATEGORY_LABELS[cat] +} + +function codexResultSummary(tool: ThoughtTool): string | undefined { + const args = toolArgObject(parseToolArgs(tool.args)) + if (tool.name === "run_command") { + return typeof args.cwd === "string" + ? `in ${args.cwd}` + : undefined + } + if (tool.name === "edit_file") { + const oldText = typeof args.oldText === "string" ? args.oldText : "" + const newText = typeof args.newText === "string" ? args.newText : "" + if (oldText || newText) { + const diff = newText.length - oldText.length + const sign = diff > 0 ? "+" : "" + return `${sign}${diff} chars` + } + } + if (tool.name === "write_file") { + const content = typeof args.content === "string" ? args.content : "" + if (content) return `${content.split("\n").length} lines` + } + return undefined +} + +function codexSingleDetail(cat: ToolCategory, tool: ThoughtTool): string { + const summary = codexResultSummary(tool) + if (cat === "command") { + return `${tool.flagged ? chalk.hex(theme.red)("failed") : chalk.hex(theme.greenMute)("completed")}${summary ? ` ${chalk.hex(theme.greenDim)(summary)}` : ""} ${chalk.hex(theme.greenDim)("[Ctrl+T]")}` + } + if (cat === "edit") { + return `${chalk.hex(theme.greenMute)(summary ?? "changed")} ${chalk.hex(theme.greenDim)("[Ctrl+T]")}` + } + return codexToolLine(tool) +} + // // ─── COLLAPSIBLE REASONING BLOCK ────────────────────────────────────────────── // @@ -572,27 +737,22 @@ export class ThoughtChain { entry.collapsed = entry.hadFlaggedTool ? false : (opts?.autoCollapse ?? true) const elapsedMs = entry.endTime - entry.startTime - const elapsedStr = - elapsedMs < 1000 ? `${elapsedMs}ms` : `${(elapsedMs / 1000).toFixed(1)}s` + const elapsedStr = formatElapsed(elapsedMs) if (!this.buffered) { const indent = chalk.hex(theme.greenDim)("┃") - // Toggle: when auto-collapsing, move ▼ to +. When keeping open, keep ▼. - const toggle = entry.collapsed - ? chalk.hex(theme.greenGlow)("+") - : chalk.hex(theme.greenGlow)("▼") - const label = chalk.hex(theme.greenMute)("Thoughts") - const time = chalk.hex(theme.greenDim)(elapsedStr) - process.stdout.write( - `${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}\n`, - ) - - // When auto-collapsed we show a summary line; when expanded we reprint - // each tool so the user can see exactly what was called (with ✗ markers - // for denied/empty rows), followed by snapshots and sub-thoughts. + // When auto-collapsed we show a Codex-style transcript summary. When + // expanded we reprint each tool so Ctrl+T still exposes exact details, + // snapshots, and failed rows. if (entry.collapsed) { - this.writeCollapsedSummary(entry, indent) + this.writeCollapsedSummary(entry, indent, elapsedStr) } else { + const toggle = chalk.hex(theme.greenGlow)("▼") + const label = chalk.hex(theme.greenMute)("Thoughts") + const time = chalk.hex(theme.greenDim)(elapsedStr) + process.stdout.write( + `${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}\n`, + ) this.writeExpandedDetail(entry, indent) } } @@ -600,43 +760,76 @@ export class ThoughtChain { this.current = null } - private writeCollapsedSummary(entry: ThoughtEntry, indent: string): void { + private writeCollapsedSummary(entry: ThoughtEntry, indent: string, elapsedStr: string): void { const subCount = entry.subThoughts.length + if (entry.tools.length === 0 && subCount === 0) { + process.stdout.write(`${indent} ${chalk.hex(theme.greenDim)(`Thoughts · ${elapsedStr}`)}\n`) + return + } + + process.stdout.write(`\n${codexDivider()}\n\n`) + if (entry.tools.length > 0) { - // Per-category summary counts const groups = groupToolsByCategory(entry.tools) - const parts: string[] = [] - let hasFailures = false for (const cat of CATEGORY_ORDER) { const tools = groups.get(cat) if (!tools || tools.length === 0) continue - const label = CATEGORY_LABELS[cat].toLowerCase() - const flaggedCount = tools.filter((t) => t.flagged).length - if (flaggedCount > 0) { - const okCount = tools.length - flaggedCount - if (okCount > 0) { - parts.push(`${okCount} ${label}`) + + const title = codexSectionTitle(cat, tools) + process.stdout.write(`${chalk.hex(theme.amber)("•")} ${title}\n`) + + const details = tools.map((tool) => { + const line = codexToolLine(tool) + const summary = codexResultSummary(tool) + return summary ? `${line} ${chalk.hex(theme.greenDim)(`(${summary})`)}` : line + }) + + const detailPrefix = ` ${chalk.hex(theme.greenDim)("└")} ` + const continuation = " " + if (details.length === 1) { + const singleDetail = codexSingleDetail(cat, tools[0]!) + for (const line of wrapVisible(singleDetail, terminalWidth() - 4, continuation)) { + process.stdout.write(`${detailPrefix}${line}\n`) + } + // Render snapshot inline if available (edit/write file diffs) + const tool = tools[0]! + if (tool.snapshot && tool.snapshot.length > 0) { + for (const snapLine of tool.snapshot) { + process.stdout.write(`${snapLine}\n`) + } } - parts.push(`${chalk.hex(theme.red)(`${flaggedCount} failed`)}`) - hasFailures = true } else { - parts.push(`${tools.length} ${label}`) + for (const line of details.slice(0, 6)) { + for (const wrapped of wrapVisible(line, terminalWidth() - 6, continuation)) { + process.stdout.write(` ${chalk.hex(theme.greenDim)("└")} ${wrapped}\n`) + } + } + // Render snapshots for tools that have them + for (const tool of tools.slice(0, 6)) { + if (tool.snapshot && tool.snapshot.length > 0) { + for (const snapLine of tool.snapshot) { + process.stdout.write(`${snapLine}\n`) + } + } + } + if (details.length > 6) { + process.stdout.write( + ` ${chalk.hex(theme.greenDim)("└")} ${chalk.hex(theme.greenMute)(`… +${details.length - 6} more [Ctrl+T]`)}\n`, + ) + } else { + process.stdout.write( + ` ${chalk.hex(theme.greenDim)("└")} ${chalk.hex(theme.greenDim)(`[Ctrl+T]`)}\n`, + ) + } } } - const summary = hasFailures - ? parts.join(" · ") - : parts.join(" · ") - const hint = subCount > 0 - ? ` ${chalk.hex(theme.greenDim)("[Ctrl+X]")}` - : ` ${chalk.hex(theme.greenDim)("[Ctrl+T]")}` - process.stdout.write( - `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(summary)}${hint}\n`, - ) } + if (subCount > 0) { const subToolCount = entry.subThoughts.reduce((n, s) => n + s.tools.length, 0) process.stdout.write( - `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(`${subCount} explore step${subCount === 1 ? "" : "s"} · ${subToolCount} tool call${subToolCount === 1 ? "" : "s"}`)}\n`, + `${chalk.hex(theme.amber)("•")} ${chalk.hex(theme.greenMute)("Explored with subagents")}\n` + + ` ${chalk.hex(theme.greenDim)("└")} ${chalk.hex(theme.greenMute)(`${subCount} explore step${subCount === 1 ? "" : "s"} · ${subToolCount} tool call${subToolCount === 1 ? "" : "s"} · ${elapsedStr}`)} ${chalk.hex(theme.greenDim)("[Ctrl+X]")}\n`, ) } } diff --git a/apps/supercode-cli/server/src/cli/utils/markdown-stream.ts b/apps/supercode-cli/server/src/cli/utils/markdown-stream.ts index 27fcd1b..ca7a2d4 100644 --- a/apps/supercode-cli/server/src/cli/utils/markdown-stream.ts +++ b/apps/supercode-cli/server/src/cli/utils/markdown-stream.ts @@ -100,6 +100,9 @@ export class MarkdownStream { // emits the styled markdown. Defaulting to off prevents the response // from showing up twice (once as raw text, once as styled markdown). private liveMode = false + // Fallback content captured from the chat loop's fullResponse variable. + // Used when the buffer is empty but the model did generate text. + private fallbackContent = "" // Opt into the legacy live-transcript behaviour where chunks are // written to stdout as they arrive and the styled render sits below. @@ -118,6 +121,13 @@ export class MarkdownStream { } } + // Set fallback content from the chat loop's fullResponse. Used when + // the buffer is empty but the model did generate text (edge case where + // chunks weren't pushed to the buffer but were captured in fullResponse). + setFallback(content: string) { + this.fallbackContent = content + } + end() { if (this.closed) return this.closed = true @@ -130,10 +140,11 @@ export class MarkdownStream { endInstant() { if (this.closed) return this.closed = true - if (!this.buffer) return + const content = this.buffer || this.fallbackContent + if (!content) return const width = (process.stdout.columns ?? 80) - 2 const renderer = getRenderer(width) - let rendered = marked(this.buffer, { renderer: renderer as any, async: false }) as string + let rendered = marked(content, { renderer: renderer as any, async: false }) as string rendered = rendered.replace(/\n+$/, "") if (!rendered) return process.stdout.write(rendered.endsWith("\n") ? rendered : rendered + "\n") @@ -141,6 +152,7 @@ export class MarkdownStream { reset() { this.buffer = "" + this.fallbackContent = "" this.closed = false } @@ -151,10 +163,12 @@ export class MarkdownStream { // intact — character-by-character output would let the spinner's // interval writes corrupt in-progress escape sequences. private async renderStyled() { - if (!this.buffer) return + // Use buffer if available, otherwise fall back to fallbackContent + const content = this.buffer || this.fallbackContent + if (!content) return const width = (process.stdout.columns ?? 80) - 2 const renderer = getRenderer(width) - let rendered = marked(this.buffer, { renderer: renderer as any, async: false }) as string + let rendered = marked(content, { renderer: renderer as any, async: false }) as string rendered = rendered.replace(/\n+$/, "") if (!rendered) return diff --git a/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts b/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts index 70a946c..3461266 100644 --- a/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts +++ b/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts @@ -61,8 +61,13 @@ const DIFF_LINE_RE = /^[+-]/ * Compact unified diff between two strings. Returns an array of {kind, text} * where kind is "add" | "del" | "ctx". Uses LCS via DP — O(n*m); fine for the * small files the agent edits. + * + * Enhanced with: + * - Better context handling around changes + * - Word-level diff within changed lines for better visualization + * - Line numbers for changed lines */ -export function diffLines(before: string, after: string): Array<{ kind: "add" | "del" | "ctx"; text: string }> { +export function diffLines(before: string, after: string): Array<{ kind: "add" | "del" | "ctx"; text: string; oldNum?: number; newNum?: number }> { const a = before.split("\n") const b = after.split("\n") const m = a.length @@ -82,16 +87,21 @@ export function diffLines(before: string, after: string): Array<{ kind: "add" | } } - const out: Array<{ kind: "add" | "del" | "ctx"; text: string }> = [] + const out: Array<{ kind: "add" | "del" | "ctx"; text: string; oldNum?: number; newNum?: number }> = [] let i = 0 let j = 0 + let oldLineNum = 1 + let newLineNum = 1 + while (i < m && j < n) { const ai = a[i] ?? "" const bj = b[j] ?? "" if (ai === bj) { - out.push({ kind: "ctx", text: ai }) + out.push({ kind: "ctx", text: ai, oldNum: oldLineNum, newNum: newLineNum }) i++ j++ + oldLineNum++ + newLineNum++ continue } const downRow = dp[i + 1] @@ -99,43 +109,61 @@ export function diffLines(before: string, after: string): Array<{ kind: "add" | const down = downRow && j < downRow.length ? (downRow[j] ?? 0) : 0 const right = curRow && j + 1 < curRow.length ? (curRow[j + 1] ?? 0) : 0 if (down >= right) { - out.push({ kind: "del", text: ai }) + out.push({ kind: "del", text: ai, oldNum: oldLineNum }) i++ + oldLineNum++ } else { - out.push({ kind: "add", text: bj }) + out.push({ kind: "add", text: bj, newNum: newLineNum }) j++ + newLineNum++ } } - while (i < m) out.push({ kind: "del", text: a[i] ?? "" }) - while (j < n) out.push({ kind: "add", text: b[j] ?? "" }) + while (i < m) { + out.push({ kind: "del", text: a[i] ?? "", oldNum: oldLineNum }) + i++ + oldLineNum++ + } + while (j < n) { + out.push({ kind: "add", text: b[j] ?? "", newNum: newLineNum }) + j++ + newLineNum++ + } return out } /** - * Render a `write_file` snapshot — header + numbered code lines. + * Render a `write_file` snapshot — header + bordered code view. * * `meta` is the secondary right-side text (e.g. "220 B · replaced"). + * + * Enhanced with: + * - Full code block editor with borders + * - File name and language indicator in header + * - Line numbers with proper padding + * - Bottom border with line count */ export function renderWriteSnapshot(path: string, content: string, meta?: string): string[] { const lang = detectLanguage(path) const lines: string[] = [] - const headerRight = meta ? ` ${chalk.hex(theme.muted)(meta)}` : "" - lines.push(`${RAIL} ${chalk.hex("#7ee2a8").bold(`+ ${path}`)}${headerRight}`) - - const split = content.split("\n") - const padWidth = String(split.length).length - const visible = split.length > MAX_LINES ? split.slice(0, MAX_LINES) : split - - for (let i = 0; i < visible.length; i++) { - const num = chalk.hex(theme.greenDim)(String(i + 1).padStart(padWidth, " ")) - const raw = visible[i] ?? "" - const text = truncate(raw, MAX_COLS) - const highlighted = highlightLine(text, lang) - lines.push(`${RAIL} ${num} ${chalk.hex(theme.greenDim)("│")} ${highlighted}`) - } - if (split.length > MAX_LINES) { - lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${split.length - MAX_LINES} more lines`)}`) + + // Use codeBlockEditor for a polished bordered view + const codeLines = codeBlockEditor(content, { + language: lang, + filePath: path, + maxLines: MAX_LINES, + maxCols: MAX_COLS, + rail: RAIL, + indent: 5, + }) + + // Add metadata line if provided + if (meta) { + lines.push(`${RAIL} ${chalk.hex(theme.muted)(meta)}`) } + + // Add the code block + lines.push(...codeLines) + return lines } @@ -143,6 +171,12 @@ export function renderWriteSnapshot(path: string, content: string, meta?: string * Render an `edit_file` snapshot — unified diff (oldText vs newText). * * `meta` carries the counts (e.g. "+6 / −0 · 1 replacement"). + * + * Enhanced with: + * - Better context around changes (2 lines before/after) + * - Collapsible unchanged sections with fold indicator + * - Improved line highlighting for changed lines + * - Side-by-side visual indicators */ export function renderEditSnapshot(path: string, before: string, after: string, meta?: string): string[] { const lang = detectLanguage(path) @@ -151,30 +185,94 @@ export function renderEditSnapshot(path: string, before: string, after: string, lines.push(`${RAIL} ${chalk.hex("#f0b87c").bold(`~ ${path}`)}${headerRight}`) const diff = diffLines(before, after) + + // Find the last non-context line to trim trailing context let lastIdx = diff.length - 1 while (lastIdx >= 0 && diff[lastIdx]?.kind === "ctx") lastIdx-- const trimmed = diff.slice(0, lastIdx + 1) - const visible = trimmed.length > MAX_LINES ? trimmed.slice(0, MAX_LINES) : trimmed + + // Add context lines around changes (2 lines before/after each change) + const CONTEXT_LINES = 2 + const changeIndices = new Set() + + // Mark lines that are part of changes and their context + for (let i = 0; i < trimmed.length; i++) { + if (trimmed[i]?.kind !== "ctx") { + // Mark this change + changeIndices.add(i) + // Mark context before + for (let j = Math.max(0, i - CONTEXT_LINES); j < i; j++) { + changeIndices.add(j) + } + // Mark context after + for (let j = i + 1; j < Math.min(trimmed.length, i + CONTEXT_LINES + 1); j++) { + changeIndices.add(j) + } + } + } + + // Build display lines with fold indicators for large context gaps + const displayLines: Array<{ type: "line" | "fold"; index?: number; line?: typeof trimmed[0] }> = [] + let prevMarked = false + + for (let i = 0; i < trimmed.length; i++) { + if (changeIndices.has(i)) { + if (!prevMarked && i > 0 && displayLines.length > 0) { + // Add fold indicator for skipped context + const skipped = i - (displayLines[displayLines.length - 1]?.index ?? 0) - 1 + if (skipped > 0) { + displayLines.push({ type: "fold" }) + } + } + displayLines.push({ type: "line", index: i, line: trimmed[i] }) + prevMarked = true + } else { + prevMarked = false + } + } + + // Limit total display lines + const visible = displayLines.length > MAX_LINES ? displayLines.slice(0, MAX_LINES) : displayLines let lineNum = 1 - for (const d of visible) { - const num = chalk.hex(theme.greenDim)(String(lineNum).padStart(3, " ")) + for (const item of visible) { + if (item.type === "fold") { + // Show fold indicator for skipped context + lines.push(`${RAIL} ${chalk.hex(theme.greenDim)('⋮')} ${chalk.hex(theme.muted)('···')}`) + continue + } + + const d = item.line! const raw = truncate(d.text, MAX_COLS) + + // Use the line numbers from the diff for better context if (d.kind === "add") { + const lineNumStr = d.newNum ? String(d.newNum).padStart(3, " ") : String(lineNum).padStart(3, " ") + const num = chalk.hex(theme.greenDim)(lineNumStr) const highlighted = highlightLine(raw, lang) - lines.push(`${RAIL} ${PLUS}${num} ${chalk.hex("#c8e6c9")(highlighted)}`) + // Enhanced add line with background highlight + lines.push(`${RAIL} ${chalk.hex("#5ec27e").bold("+")}${num} ${chalk.hex("#c8e6c9")(highlighted)}`) } else if (d.kind === "del") { + const lineNumStr = d.oldNum ? String(d.oldNum).padStart(3, " ") : String(lineNum).padStart(3, " ") + const num = chalk.hex(theme.greenDim)(lineNumStr) const highlighted = highlightLine(raw, lang) - lines.push(`${RAIL} ${MINUS}${num} ${chalk.hex("#f8d7da")(highlighted)}`) + // Enhanced delete line with red background highlight + lines.push(`${RAIL} ${chalk.hex("#e06c75").bold("-")}${num} ${chalk.hex("#f8d7da")(highlighted)}`) } else { - lines.push(`${RAIL} ${CONTEXT} ${chalk.hex(theme.greenMute)(raw)}`) + // Context line with subtle styling - show both line numbers if available + const lineNumStr = d.oldNum && d.newNum + ? `${String(d.oldNum).padStart(3, " ")}:${String(d.newNum).padStart(3, " ")}` + : String(lineNum).padStart(3, " ") + const num = chalk.hex(theme.greenDim)(lineNumStr) + lines.push(`${RAIL} ${chalk.hex(theme.greenDim)(' ')}${num} ${chalk.hex(theme.greenMute)(raw)}`) } lineNum++ } - if (trimmed.length > MAX_LINES) { - lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${trimmed.length - MAX_LINES} more lines`)}`) + + if (displayLines.length > MAX_LINES) { + lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${displayLines.length - MAX_LINES} more changes`)}`) } - if (visible.length === 0) { + if (displayLines.length === 0) { lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)("(no textual changes)")}`) } return lines diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index 76bb413..9a7dfa2 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -65,6 +65,7 @@ const CLOUD_ALLOWED_MODELS = new Set([ "minimax-m3", "hy3", ]) +const JSON_BODY_LIMIT = process.env.SUPERCODE_JSON_BODY_LIMIT || "10mb" const app = express() app.use( @@ -93,7 +94,29 @@ app.get("/error", (req, res) => { ) }) -app.use(express.json()) +app.use(express.json({ limit: JSON_BODY_LIMIT })) +app.use( + ( + error: unknown, + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if ( + error && + typeof error === "object" && + "type" in error && + error.type === "entity.too.large" + ) { + res.status(413).json({ + error: `Request body is too large. Limit is ${JSON_BODY_LIMIT}.`, + }) + return + } + + next(error) + }, +) registerAnalyticsRoutes(app, prisma)