diff --git a/package-lock.json b/package-lock.json index 1b2f7e0c..8eef032b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.34.3", + "version": "2.34.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.34.3", + "version": "2.34.4", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -8913,7 +8913,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.34.3", + "version": "2.34.4", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -8942,7 +8942,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.34.3", + "version": "2.34.4", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -8998,7 +8998,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.34.3", + "version": "2.34.4", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -9127,7 +9127,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.34.3", + "version": "2.34.4", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -9176,7 +9176,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.34.3", + "version": "2.34.4", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -9209,7 +9209,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.34.3", + "version": "2.34.4", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index e23619cc..4affdcb0 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "node": "22.x" }, "packageManager": "npm@11.5.1", - "version": "2.34.3", + "version": "2.34.4", "dependencies": { "@dreb/coding-agent": "*", "@mariozechner/jiti": "^2.6.5", diff --git a/packages/agent/package.json b/packages/agent/package.json index 3f58b6f9..c9eff7ea 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.34.3", + "version": "2.34.4", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index 338e6b2d..d1a97366 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.34.3", + "version": "2.34.4", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82908559..b74cbc9d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -42,6 +42,8 @@ ### Fixed +- Fixed semantic `search` results from crashing the interactive TUI when indexed content or result previews contain CRLF or lone-CR line endings. Search result rendering and semantic-search previews now normalize platform line endings before display while preserving the strict TUI raw-line-break guard. ([#308](https://github.com/aebrer/dreb/issues/308)) + - Fixed serial duplicate rows during streamed assistant/thinking markdown lists by normalizing TUI markdown render entries and rejecting raw line breaks before terminal row state can drift. ([#301](https://github.com/aebrer/dreb/issues/301)) - Fixed the TUI yanking the viewport to the top and wiping scrollback when a live-region-only change occurred while the user was scrolled up — at turn end (the working spinner disappearing) or on resize. A one-line live-region change was escalating to `recommitAll()`, which clears scrollback and repaints the whole session from the top, destroying the user's scroll position and native scrollback. The working indicator now lives on the always-present editor input line (constant live-region height, so turn boundaries no longer shrink the live region), live-region-only viewport shifts route through a new `restoreLiveViewport()` that bottom-anchors without replaying committed content, and `recommitAll()` re-establishes input modes via a RIS reset before any transcript bytes. A structural guard now makes it a loud error (with terminal teardown) for any live-only render path to reach `recommitAll()`, so the committed-replay class of bug cannot silently return. ([#292](https://github.com/aebrer/dreb/issues/292)) diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 3ad1581c..ebc432d4 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.34.3", + "version": "2.34.4", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/coding-agent/src/core/tools/search.ts b/packages/coding-agent/src/core/tools/search.ts index af899f6d..266229f6 100644 --- a/packages/coding-agent/src/core/tools/search.ts +++ b/packages/coding-agent/src/core/tools/search.ts @@ -52,6 +52,10 @@ export interface SearchToolDetails { indexStats?: { files: number; chunks: number }; } +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + // ============================================================================ // Rendering // ============================================================================ @@ -89,7 +93,7 @@ export function formatSearchResult( options: ToolRenderResultOptions, theme: typeof import("../../modes/interactive/theme/theme.js").theme, ): string { - const output = result.content[0]?.text?.trim() ?? ""; + const output = result.content[0]?.text ? normalizeLineEndings(result.content[0].text).trim() : ""; if (!output) return ""; const lines = output.split("\n"); diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index edd8d2d5..a7f900fb 100644 --- a/packages/coding-agent/test/agent-session-retry.test.ts +++ b/packages/coding-agent/test/agent-session-retry.test.ts @@ -289,8 +289,8 @@ describe("AgentSession retry", () => { // second call succeeds. This drives the _handleEvents length_retry branch // that forwards all 5 fields to the extension runner. let callCount = 0; - const model = findModel("anthropic", "sonnet")!; - // Guard the expected numbers against future model changes. + const model = { ...findModel("anthropic", "sonnet")!, maxTokens: 64000 }; + // Keep the test fixture stable even if the live registry changes Sonnet's ceiling. expect(model.maxTokens).toBe(64000); const agent = new Agent({ getApiKey: () => "test-key", @@ -370,8 +370,8 @@ describe("AgentSession retry", () => { // first retry bumps 32000 → 64000 (sonnet's ceiling); a second truncation // at the ceiling then fails loudly. let callCount = 0; - const model = findModel("anthropic", "sonnet")!; - // Guard the expected numbers against future model changes. + const model = { ...findModel("anthropic", "sonnet")!, maxTokens: 64000 }; + // Keep the test fixture stable even if the live registry changes Sonnet's ceiling. expect(model.maxTokens).toBe(64000); const agent = new Agent({ getApiKey: () => "test-key", diff --git a/packages/coding-agent/test/search/search-tool.test.ts b/packages/coding-agent/test/search/search-tool.test.ts index 305bbfdf..33d3da81 100644 --- a/packages/coding-agent/test/search/search-tool.test.ts +++ b/packages/coding-agent/test/search/search-tool.test.ts @@ -251,6 +251,19 @@ describe("createSearchToolDefinition", () => { expect(result).not.toContain("more lines"); }); + it("normalizes CRLF and lone CR result text", () => { + const result = formatSearchResult( + { content: [{ type: "text", text: "first\r\nsecond\rthird" }] }, + { expanded: false, isPartial: false }, + stubTheme, + ); + + expect(result).toContain("first"); + expect(result).toContain("second"); + expect(result).toContain("third"); + expect(result).not.toContain("\r"); + }); + it("truncates at 20 lines in collapsed mode with remaining count", () => { const lines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`); const result = formatSearchResult( diff --git a/packages/semantic-search/.claude-plugin/plugin.json b/packages/semantic-search/.claude-plugin/plugin.json index a3eea660..e175e32f 100644 --- a/packages/semantic-search/.claude-plugin/plugin.json +++ b/packages/semantic-search/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "semantic-search", "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking", - "version": "2.34.3", + "version": "2.34.4", "author": { "name": "Drew Brereton" }, diff --git a/packages/semantic-search/package.json b/packages/semantic-search/package.json index 7c0d8f50..0a90756e 100644 --- a/packages/semantic-search/package.json +++ b/packages/semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/semantic-search", - "version": "2.34.3", + "version": "2.34.4", "description": "Semantic codebase search engine with embedding-based ranking and MCP server", "publishConfig": { "access": "public" diff --git a/packages/semantic-search/src/format.ts b/packages/semantic-search/src/format.ts index 892bebd4..c3037d91 100644 --- a/packages/semantic-search/src/format.ts +++ b/packages/semantic-search/src/format.ts @@ -2,6 +2,10 @@ import type { SearchResult } from "./types.js"; +function splitLogicalLines(text: string): string[] { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +} + export function formatResults(results: SearchResult[]): string { if (results.length === 0) return "No results found."; @@ -28,7 +32,7 @@ export function formatResults(results: SearchResult[]): string { } // Content preview (first 3 lines) - const contentLines = chunk.content.split("\n"); + const contentLines = splitLogicalLines(chunk.content); const previewLines = contentLines.slice(0, 3); for (const line of previewLines) { const trimmed = line.length > 120 ? `${line.slice(0, 117)}...` : line; diff --git a/packages/semantic-search/test/mcp-server.test.ts b/packages/semantic-search/test/mcp-server.test.ts index 568a1b68..6209d69c 100644 --- a/packages/semantic-search/test/mcp-server.test.ts +++ b/packages/semantic-search/test/mcp-server.test.ts @@ -135,6 +135,19 @@ describe("formatResults", () => { expect(output).toContain("... (2 more lines)"); }); + it("normalizes CRLF and lone CR content previews", () => { + const content = "line 1\r\nline 2\rline 3\r\nline 4\r\nline 5"; + const result = makeResult({ chunk: makeChunk({ content }) }); + const output = formatResults([result]); + + expect(output).toContain("line 1"); + expect(output).toContain("line 2"); + expect(output).toContain("line 3"); + expect(output).not.toContain("line 4"); + expect(output).toContain("... (2 more lines)"); + expect(output).not.toContain("\r"); + }); + it("truncates long content lines at 120 chars", () => { const longLine = "x".repeat(200); const result = makeResult({ chunk: makeChunk({ content: longLine }) }); diff --git a/packages/telegram/package.json b/packages/telegram/package.json index b98fc1d3..5e570248 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/telegram", - "version": "2.34.3", + "version": "2.34.4", "description": "Telegram bot frontend for dreb coding agent", "license": "MIT", "type": "module", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4e7cd988..f4965f0b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `Text` rendering for CRLF and lone-CR input so platform line endings are split into separate logical render entries instead of returning raw carriage returns to the TUI guard. ([#308](https://github.com/aebrer/dreb/issues/308)) - Fixed streamed markdown list rendering from returning raw newline characters inside a single TUI render entry, preventing serial duplicate rows during soft-wrapped assistant output ([#301](https://github.com/aebrer/dreb/issues/301)) - Fixed the TUI render-contract guard so raw line breaks are rejected even when a rendered entry contains terminal image escape sequences - Fixed blockquote text color breaking after inline links (and other inline elements) due to missing style restoration prefix diff --git a/packages/tui/package.json b/packages/tui/package.json index 8e454509..010dd589 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/tui", - "version": "2.34.3", + "version": "2.34.4", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 6cead283..d4120452 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -66,8 +66,8 @@ export class Text implements Component { return result; } - // Replace tabs with 3 spaces - const normalizedText = this.text.replace(/\t/g, " "); + // Normalize line endings and replace tabs with 3 spaces. + const normalizedText = this.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " "); // Add top/bottom padding (empty lines) const emptyLine = " ".repeat(width); diff --git a/packages/tui/test/markdown-softwrap.test.ts b/packages/tui/test/markdown-softwrap.test.ts index ad40a2cc..f5623630 100644 --- a/packages/tui/test/markdown-softwrap.test.ts +++ b/packages/tui/test/markdown-softwrap.test.ts @@ -206,6 +206,29 @@ describe("Text softWrap", () => { assert.strictEqual(lines[2], " ".repeat(20)); }); + it("normalizes CRLF and lone CR input into separate soft-wrapped logical lines", () => { + const text = new Text("first\r\nsecond\rthird", 0, 0, undefined, true); + + const lines = text.render(20); + const plainLines = lines.map(plain); + + assertNoRawLineBreaks(lines); + assert.deepStrictEqual(plainLines, ["first", "second", "third"]); + assert.ok(lines.every(isWrappableLine)); + }); + + it("normalizes CRLF and lone CR input into separate hard-wrapped logical lines", () => { + const text = new Text("first\r\nsecond\rthird", 0, 0); + + const lines = text.render(20); + const plainLines = lines.map((line) => plain(line).trimEnd()); + + assertNoRawLineBreaks(lines); + assert.deepStrictEqual(plainLines, ["first", "second", "third"]); + assert.strictEqual(lines.some(isWrappableLine), false); + assert.ok(lines.every((line) => visibleWidth(line) === 20)); + }); + it("defaults to existing padded hard-wrapped behavior", () => { const text = new Text("short", 2, 0);