From 139426c22bece7d0a949610451bdf29105cf5489 Mon Sep 17 00:00:00 2001 From: Eivind Date: Wed, 15 Apr 2026 23:59:31 +0200 Subject: [PATCH] fix: copy-all reads raw output instead of wrapped terminal text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Y key (copy all) was reading from ghostty's getText() which returns text wrapped to terminal width. Now reads from the log file which has the raw, unwrapped process output. Also preserves log files on pane clear (Alt+L) — clear is now UI-only. A byte offset tracks where "copy all" should start reading from after a clear, without deleting any log data. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/app.ts | 8 ++--- src/utils/log-writer.test.ts | 70 ++++++++++++++++++++++++++++++++---- src/utils/log-writer.ts | 49 +++++++++++++++++-------- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/src/ui/app.ts b/src/ui/app.ts index aa1f7a9..145f3a0 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -247,7 +247,7 @@ export class App { if (name === SHORTCUTS.clear.key) { this.panes.get(this.activePane)?.clear() - this.logWriter.truncate(this.activePane) + this.logWriter.markCopyStart(this.activePane) return } @@ -413,13 +413,11 @@ export class App { } } - /** Copy all text in the active pane to clipboard. */ + /** Copy all text in the active pane to clipboard (unwrapped, from log file). */ private copyAllText(): void { if (!this.activePane) return - const pane = this.panes.get(this.activePane) - if (!pane) return - const text = pane.getText() + const text = this.logWriter.readLog(this.activePane) if (!text) { this.statusBar.showTemporaryMessage('No output to copy') return diff --git a/src/utils/log-writer.test.ts b/src/utils/log-writer.test.ts index cea6d07..cc01812 100644 --- a/src/utils/log-writer.test.ts +++ b/src/utils/log-writer.test.ts @@ -128,20 +128,76 @@ describe('LogWriter', () => { writer.close() }) - test('truncate clears log file', () => { + test('readLog returns all content without markCopyStart', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', 'line 1\n')) + writer.handleEvent(outputEvent('api', 'line 2\n')) + + const content = writer.readLog('api') + expect(content).toBe('line 1\nline 2\n') + writer.close() + }) + + test('readLog returns undefined for unknown process', () => { + const writer = new LogWriter(dir) + expect(writer.readLog('unknown')).toBeUndefined() + writer.close() + }) + + test('readLog returns undefined when no output after markCopyStart', () => { const writer = new LogWriter(dir) writer.handleEvent(outputEvent('api', 'old content\n')) + writer.markCopyStart('api') - const contentBefore = readFileSync(join(dir, 'api.log'), 'utf-8') - expect(contentBefore).toContain('old content') + expect(writer.readLog('api')).toBeUndefined() + writer.close() + }) - writer.truncate('api') + test('markCopyStart makes readLog return only new content', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', 'old content\n')) + writer.markCopyStart('api') writer.handleEvent(outputEvent('api', 'new content\n')) + + const content = writer.readLog('api') + expect(content).toBe('new content\n') + expect(content).not.toContain('old content') + + // Full file still has everything + const fullFile = readFileSync(join(dir, 'api.log'), 'utf-8') + expect(fullFile).toContain('old content') + expect(fullFile).toContain('new content') + writer.close() + }) + + test('markCopyStart can be called multiple times', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', 'first\n')) + writer.markCopyStart('api') + writer.handleEvent(outputEvent('api', 'second\n')) + writer.markCopyStart('api') + writer.handleEvent(outputEvent('api', 'third\n')) + + const content = writer.readLog('api') + expect(content).toBe('third\n') writer.close() + }) - const contentAfter = readFileSync(join(dir, 'api.log'), 'utf-8') - expect(contentAfter).toBe('new content\n') - expect(contentAfter).not.toContain('old content') + test('markCopyStart on unknown process is a no-op', () => { + const writer = new LogWriter(dir) + writer.markCopyStart('unknown') // should not throw + expect(writer.readLog('unknown')).toBeUndefined() + writer.close() + }) + + test('readLog strips ANSI from content', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', '\x1b[31mred\x1b[0m\n')) + + const content = writer.readLog('api') + expect(content).toBe('red\n') + expect(content).not.toContain('\x1b') + writer.close() }) test('createTemp creates a temp directory', () => { diff --git a/src/utils/log-writer.ts b/src/utils/log-writer.ts index 1e15081..b2d38e7 100644 --- a/src/utils/log-writer.ts +++ b/src/utils/log-writer.ts @@ -1,4 +1,4 @@ -import { closeSync, mkdirSync, openSync, rmSync, symlinkSync, unlinkSync, writeSync } from 'node:fs' +import { closeSync, mkdirSync, openSync, readSync, rmSync, symlinkSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import type { ProcessEvent } from '../types' @@ -17,6 +17,7 @@ export class LogWriter { private dir: string private isTemp: boolean private files = new Map() + private copyOffsets = new Map() private decoder = new TextDecoder() private encoder = new TextEncoder() @@ -95,6 +96,38 @@ export class LogWriter { } } + /** Mark the current end of the log file as the start point for readLog. */ + markCopyStart(name: string): void { + const path = this.getLogPath(name) + if (!path) return + try { + this.copyOffsets.set(name, Bun.file(path).size) + } catch { + // Ignore — file may not exist yet + } + } + + /** Read log file content for a process (from last clear point). */ + readLog(name: string): string | undefined { + const path = this.getLogPath(name) + if (!path) return undefined + try { + const size = Bun.file(path).size + const offset = this.copyOffsets.get(name) ?? 0 + if (size <= offset) return undefined + const fd = openSync(path, 'r') + try { + const buf = Buffer.alloc(size - offset) + readSync(fd, buf, 0, buf.length, offset) + return buf.toString('utf-8') + } finally { + closeSync(fd) + } + } catch { + return undefined + } + } + /** Get the log file path for a process, or undefined if no output yet. */ getLogPath(name: string): string | undefined { if (this.files.has(name)) { @@ -207,20 +240,6 @@ export class LogWriter { } } - /** Truncate a process's log file (used when pane is cleared). */ - truncate(name: string): void { - const fd = this.files.get(name) - if (fd === undefined) return - try { - closeSync(fd) - const path = join(this.dir, `${name}.log`) - const newFd = openSync(path, 'w') - this.files.set(name, newFd) - } catch { - // Ignore errors — file may have been deleted - } - } - close(): void { for (const fd of this.files.values()) { closeSync(fd)