From eb55881d1e7812ba5122382c210c901353925bc5 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Tue, 3 Nov 2026 21:00:51 +0100 Subject: [PATCH] Find a command again on its own line, not on output ending with it After a rewrap, each command's block was found again on the newest line ending with the command. Output can end with it too: `ls` printing `Pictures Tools`, or `cat list` printing `wildcat list`. The block then started on that line of output, which threw off Copy Output, Select Output, Go to Command and everything else reading the blocks after a resize. A block now keeps its command line's text from when it was first found, when the screen was captured at the 133;C mark and no output could be mistaken for it, and is only found again on a line that reads the same. The prompt's end is marked for the current prompt only, so the text is what's left to tell older prompts from output. Spaces don't count in the comparison, since a rewrap can add or drop a blank cell where it breaks a line. Blocks saved before this have no command line and are found as before. Co-Authored-By: Claude Opus 5.5 --- Sources/CodecCore/CommandBlocks.swift | 40 ++++++++--- Sources/CodecCore/CommandTimeline.swift | 3 +- Sources/CodecUI/Pane.swift | 9 +-- Tests/CodecCoreTests/CommandBlocksTests.swift | 70 +++++++++++++++++-- .../CodecCoreTests/SavedWorkspaceTests.swift | 4 +- Tests/CodecUITests/SelectionTests.swift | 31 ++++++++ 6 files changed, 134 insertions(+), 23 deletions(-) diff --git a/Sources/CodecCore/CommandBlocks.swift b/Sources/CodecCore/CommandBlocks.swift index a7d9053..fb9c470 100644 --- a/Sources/CodecCore/CommandBlocks.swift +++ b/Sources/CodecCore/CommandBlocks.swift @@ -25,10 +25,14 @@ public struct CommandBlocks: Equatable, Sendable { public var prompt: Int /// The first row after the command line, where output begins. public var output: Int + /// The command line as it was found, the end of the prompt and the command, so it can be told + /// from output that happens to end with the same command. Nil when not known. + public var commandLine: String? - public init(prompt: Int, output: Int) { + public init(prompt: Int, output: Int, commandLine: String? = nil) { self.prompt = prompt self.output = output + self.commandLine = commandLine } } @@ -64,7 +68,14 @@ public struct CommandBlocks: Equatable, Sendable { /// Finds the command line that ends with `command` at or above `cursorRow`, and returns the block it /// opens. `promptLines` is how many lines the prompt has, its command line included. - public static func locate(command: String, in rows: [ScreenRow], cursorRow: Int, promptLines: Int) -> Block? { + /// + /// Any line can end with the command, its output's too: `ls` printing `Pictures Tools`, or `cat list` + /// printing `wildcat list`. That's harmless while the output hasn't started, as when a command is first + /// found, but not when finding it again later. Then `line`, the command line as first found, must + /// match too. Older prompts' ends aren't marked anywhere, only the current one's, so the line's text + /// is what tells the prompt apart from output. + public static func locate(command: String, line: String? = nil, in rows: [ScreenRow], cursorRow: Int, + promptLines: Int) -> Block? { let wanted = command.trimmingCharacters(in: .whitespaces) guard !wanted.isEmpty else { return nil } let candidates = rows.filter { $0.row <= cursorRow } @@ -73,9 +84,10 @@ public struct CommandBlocks: Equatable, Sendable { while index >= 0 { var first = index while first > 0, candidates[first].isWrapped { first -= 1 } - let text = candidates[first...index].map(\.text).joined() - if trimmingTrailingSpace(text).hasSuffix(wanted) { - return Block(prompt: candidates[first].row - max(promptLines - 1, 0), output: candidates[index].row + 1) + let text = trimmingTrailingSpace(candidates[first...index].map(\.text).joined()) + if text.hasSuffix(wanted), line.map({ sameLine(text, $0) }) ?? true { + return Block(prompt: candidates[first].row - max(promptLines - 1, 0), output: candidates[index].row + 1, + commandLine: String(text)) } index = first - 1 } @@ -88,13 +100,15 @@ public struct CommandBlocks: Equatable, Sendable { } /// Finds commands' blocks again after their rows moved, as they do when the terminal rewraps its lines - /// on a resize. `commands` are oldest first; each is looked for above the one after it, starting from - /// `lastRow`. Nil for a command that isn't on screen anymore. - public static func relocate(commands: [String], in rows: [ScreenRow], lastRow: Int, promptLines: Int) -> [Block?] { + /// on a resize. `commands` are oldest first, each with its command line as first found (see `locate`); + /// each is looked for above the one after it, starting from `lastRow`. Nil for a command that isn't on + /// screen anymore. + public static func relocate(commands: [(command: String, line: String?)], in rows: [ScreenRow], lastRow: Int, + promptLines: Int) -> [Block?] { var found: [Block?] = [] var below = lastRow - for command in commands.reversed() { - let block = locate(command: command, in: rows, cursorRow: below, promptLines: promptLines) + for (command, line) in commands.reversed() { + let block = locate(command: command, line: line, in: rows, cursorRow: below, promptLines: promptLines) if let block { below = block.prompt - 1 } found.append(block) } @@ -115,6 +129,12 @@ public struct CommandBlocks: Equatable, Sendable { return rows[position].row - remaining } + /// Rewrapping keeps a line's text and only breaks it into rows elsewhere, but where it breaks can add + /// or drop a blank cell, as when a wide character doesn't fit at a row's end. So spaces don't count. + private static func sameLine(_ text: Substring, _ line: String) -> Bool { + text.filter { !$0.isWhitespace } == line.filter { !$0.isWhitespace } + } + private static func trimmingTrailingSpace(_ text: String) -> Substring { var end = text.endIndex while end > text.startIndex, text[text.index(before: end)].isWhitespace { end = text.index(before: end) } diff --git a/Sources/CodecCore/CommandTimeline.swift b/Sources/CodecCore/CommandTimeline.swift index 2a887a6..7747198 100644 --- a/Sources/CodecCore/CommandTimeline.swift +++ b/Sources/CodecCore/CommandTimeline.swift @@ -220,7 +220,8 @@ public struct CommandTimeline: Codable, Equatable, Sendable { for index in copy.entries.indices { guard let block = copy.entries[index].block else { continue } copy.entries[index].block = block.prompt >= firstRow - ? CommandBlocks.Block(prompt: block.prompt - firstRow, output: block.output - firstRow) : nil + ? CommandBlocks.Block(prompt: block.prompt - firstRow, output: block.output - firstRow, + commandLine: block.commandLine) : nil } return copy } diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index dec22f1..adfe574 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -658,8 +658,8 @@ final class Pane: LocalProcessTerminalViewDelegate { max(live.liveTopRow - block.prompt, 0), CodecTerminalView.scrollback))) else { return nil } let screen = Pane.screenRows(snapshot) // Row numbers start over when the scrollback is cleared, so check the command is still on its line. - guard CommandBlocks.locate(command: entry.screenCommand, in: screen.rows, cursorRow: commandLine, - promptLines: 1)?.output == block.output else { return nil } + guard CommandBlocks.locate(command: entry.screenCommand, line: block.commandLine, in: screen.rows, + cursorRow: commandLine, promptLines: 1)?.output == block.output else { return nil } let end: Int if let index = blocks.blocks.firstIndex(of: block), let next = blocks.blocks.dropFirst(index + 1).first { end = next.prompt @@ -684,8 +684,9 @@ final class Pane: LocalProcessTerminalViewDelegate { else { return } rowsMayHaveMoved = false let placed = timeline.entries.filter { $0.block != nil } - let found = CommandBlocks.relocate(commands: placed.map(\.screenCommand), in: Pane.screenRows(snapshot).rows, - lastRow: end - 1, promptLines: session.promptLines) + let found = CommandBlocks.relocate(commands: placed.map { ($0.screenCommand, $0.block?.commandLine) }, + in: Pane.screenRows(snapshot).rows, lastRow: end - 1, + promptLines: session.promptLines) timeline.setBlocks(Dictionary(uniqueKeysWithValues: zip(placed.map(\.id), found))) blocks.replace(with: found.compactMap { $0 }) } diff --git a/Tests/CodecCoreTests/CommandBlocksTests.swift b/Tests/CodecCoreTests/CommandBlocksTests.swift index f4d4077..35ce08b 100644 --- a/Tests/CodecCoreTests/CommandBlocksTests.swift +++ b/Tests/CodecCoreTests/CommandBlocksTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodecCore @@ -10,18 +11,20 @@ struct CommandBlocksTests { @Test func locatesTheCommandLineAndItsPrompt() { let screen = rows(["", "~/code ⎇ main", "", "❯ make test ", "building…", "done"], from: 40) let block = CommandBlocks.locate(command: "make test", in: screen, cursorRow: 45, promptLines: 4) - #expect(block == .init(prompt: 40, output: 44)) + #expect(block == .init(prompt: 40, output: 44, commandLine: "❯ make test")) } /// Output may already be on screen when Codec looks; the command line is found above it. @Test func ignoresOutputBelowTheCommandLine() { let screen = rows(["% echo hi", "hi", "% "], from: 7) - #expect(CommandBlocks.locate(command: "echo hi", in: screen, cursorRow: 9, promptLines: 1) == .init(prompt: 7, output: 8)) + #expect(CommandBlocks.locate(command: "echo hi", in: screen, cursorRow: 9, promptLines: 1) + == .init(prompt: 7, output: 8, commandLine: "% echo hi")) } @Test func joinsSoftWrappedCommandLines() { let screen = rows(["% echo aaaa", "aaaa bbb", "out"], from: 0, wrapped: [1]) - #expect(CommandBlocks.locate(command: "echo aaaaaaaa bbb", in: screen, cursorRow: 2, promptLines: 1) == .init(prompt: 0, output: 2)) + #expect(CommandBlocks.locate(command: "echo aaaaaaaa bbb", in: screen, cursorRow: 2, promptLines: 1) + == .init(prompt: 0, output: 2, commandLine: "% echo aaaaaaaa bbb")) } @Test func returnsNothingWhenTheCommandIsntOnScreen() { @@ -34,9 +37,64 @@ struct CommandBlocksTests { @Test func relocatesCommandsAfterARewrap() { let screen = rows(["% echo a", "a", "% ls -la /some/long/pa", "th", "x", "% echo a", "a", "% "], from: 30, wrapped: [33]) - let found = CommandBlocks.relocate(commands: ["echo a", "ls -la /some/long/path", "echo a", "gone"], in: screen, - lastRow: 36, promptLines: 1) - #expect(found == [.init(prompt: 30, output: 31), .init(prompt: 32, output: 34), .init(prompt: 35, output: 36), nil]) + let found = CommandBlocks.relocate(commands: [("echo a", "% echo a"), ("ls -la /some/long/path", nil), + ("echo a", "% echo a"), ("gone", "% gone")], + in: screen, lastRow: 36, promptLines: 1) + #expect(found == [.init(prompt: 30, output: 31, commandLine: "% echo a"), + .init(prompt: 32, output: 34, commandLine: "% ls -la /some/long/path"), + .init(prompt: 35, output: 36, commandLine: "% echo a"), nil]) + } + + /// Output can end with its own command, as `ls` printing `Tools` does. Found again, the command goes + /// on its command line, not on that line of output. + @Test(arguments: [ + (["❯ ls", "Desktop Pictures Tools", "❯ "], 1), + (["~/code ⎇ main", "", "❯ ls", "Desktop Pictures Tools", "~/code ⎇ main", "", "❯ "], 3), + ]) + func relocatesACommandAboveOutputThatEndsWithIt(lines: [String], promptLines: Int) throws { + let screen = rows(lines, from: 20) + let commandLine = try #require(screen.first { $0.text == "❯ ls" }).row + // As the screen was when the command started, before its output. + let started = rows(Array(lines.prefix(commandLine - 19)) + [""], from: 20) + let first = try #require(CommandBlocks.locate(command: "ls", in: started, cursorRow: commandLine + 1, + promptLines: promptLines)) + #expect(first == .init(prompt: 20, output: commandLine + 1, commandLine: "❯ ls")) + let found = CommandBlocks.relocate(commands: [("ls", first.commandLine)], in: screen, + lastRow: 20 + lines.count - promptLines - 1, promptLines: promptLines) + #expect(found == [first]) + } + + /// The same, for output that ends with the whole command: `cat list` printing `wildcat list`, here + /// after a rewrap broke both lines differently than before. + @Test(arguments: [1, 3]) + func relocatesACommandAboveOutputThatEndsWithAllOfIt(promptLines: Int) { + let prompt = promptLines == 1 ? [] : ["~/code ⎇ main", ""] + let before = rows(prompt + ["❯ cat list", "first.txt", "wildcat list"] + prompt + ["❯ "], from: 0) + let first = CommandBlocks.locate(command: "cat list", in: before, cursorRow: promptLines, promptLines: promptLines) + #expect(first?.prompt == 0) + // Narrower now, the long lines take two rows. + let after = rows(prompt + ["❯ cat l", "ist", "first.txt", "wildc", "at list"] + prompt + ["❯ "], from: 0, + wrapped: [promptLines, promptLines + 3]) + let found = CommandBlocks.relocate(commands: [("cat list", first?.commandLine)], in: after, + lastRow: promptLines + 3, promptLines: promptLines) + #expect(found == [.init(prompt: 0, output: promptLines + 1, commandLine: "❯ cat list")]) + // Without the command line, the output's line would have won. + #expect(CommandBlocks.relocate(commands: [("cat list", nil)], in: after, lastRow: promptLines + 3, + promptLines: promptLines).first??.output == promptLines + 4) + } + + /// Spaces may come and go where a rewrap breaks a line, so they don't count when comparing it. + @Test func comparesCommandLinesWithoutSpaces() { + // 日 didn't fit at the end of the first row, which was left with a blank cell. + let screen = rows(["~/a/long/dir ", "日 ❯ ls", "x"], from: 0, wrapped: [1]) + let found = CommandBlocks.relocate(commands: [("ls", "~/a/long/dir 日 ❯ ls")], in: screen, lastRow: 2, promptLines: 1) + #expect(found.first??.prompt == 0) + } + + /// Blocks saved before they kept their command line still load. + @Test func decodesBlocksWithoutTheirCommandLine() throws { + let block = try JSONDecoder().decode(CommandBlocks.Block.self, from: Data(#"{"prompt":4,"output":5}"#.utf8)) + #expect(block == .init(prompt: 4, output: 5)) } @Test func replacesItsBlocks() { diff --git a/Tests/CodecCoreTests/SavedWorkspaceTests.swift b/Tests/CodecCoreTests/SavedWorkspaceTests.swift index 0cbd92a..2e0514e 100644 --- a/Tests/CodecCoreTests/SavedWorkspaceTests.swift +++ b/Tests/CodecCoreTests/SavedWorkspaceTests.swift @@ -72,12 +72,12 @@ struct SavedWorkspaceTests { var timeline = CommandTimeline() timeline.begin(command: "old", directory: "/", started: Date(timeIntervalSince1970: 0), block: .init(prompt: 2, output: 3)) timeline.finish(exitCode: 0, duration: 1, output: CapturedOutput(lines: ["gone"])) - timeline.begin(command: "make", directory: "/", started: Date(timeIntervalSince1970: 10), block: .init(prompt: 50, output: 51)) + timeline.begin(command: "make", directory: "/", started: Date(timeIntervalSince1970: 10), block: .init(prompt: 50, output: 51, commandLine: "% make")) timeline.finish(exitCode: 2, duration: 3, output: CapturedOutput(lines: ["error"])) timeline.begin(command: "npm run dev", directory: "/", started: Date(timeIntervalSince1970: 20), block: .init(prompt: 60, output: 61)) let rebased = timeline.rebased(firstRow: 40) - #expect(rebased.entries.map(\.block) == [nil, .init(prompt: 10, output: 11), .init(prompt: 20, output: 21)]) + #expect(rebased.entries.map(\.block) == [nil, .init(prompt: 10, output: 11, commandLine: "% make"), .init(prompt: 20, output: 21)]) // What was running didn't report back. #expect(rebased.entries.last?.exitCode == -1) #expect(rebased.entries[1].output?.text == "error") diff --git a/Tests/CodecUITests/SelectionTests.swift b/Tests/CodecUITests/SelectionTests.swift index ee10f14..dff8396 100644 --- a/Tests/CodecUITests/SelectionTests.swift +++ b/Tests/CodecUITests/SelectionTests.swift @@ -113,6 +113,37 @@ struct ZshSelectionTests { #expect(selected.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } == ["one", "two", "three"]) } + /// Regression: after a rewrap, a command was found again on the newest line ending with it, and + /// `ls` printing `Tools` ends with it. Its output then started below that line, at the next prompt. + @Test(arguments: ["%# ", "top\n\n%# "]) + func copiesTheSameOutputAfterBlocksMove(prompt: String) async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let project = env.directory.appendingPathComponent("project") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + for name in ["Pictures", "Tools"] { + try "".write(to: project.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + let (pane, window) = try zshPane(env, prompt: prompt, directory: project.path) + defer { pane.shutDown(); window.close() } + let promptLines = prompt.split(separator: "\n", omittingEmptySubsequences: false).count + #expect(await waitUntil { pane.isAtCommandLine && pane.session.promptLines == promptLines }) + pane.terminal.send(txt: "ls\r") + #expect(await waitUntil { pane.lastOutputRange != nil && pane.isAtCommandLine }) + let output = try #require(pane.lastOutputRange.flatMap(pane.text(of:))) + #expect(output.hasPrefix("Pictures") && output.hasSuffix("Tools")) + + // As a resize would. + pane.sizeChanged(source: pane.terminal, newCols: 80, newRows: 24) + let range = try #require(pane.lastOutputRange) + #expect(pane.text(of: range) == output) + let pasteboard = NSPasteboard.general + let saved = pasteboard.string(forType: .string) + defer { pasteboard.clearContents(); if let saved { pasteboard.setString(saved, forType: .string) } } + pane.copyOutput(range) + #expect(pasteboard.string(forType: .string) == output) + } + /// Dragging on the command line selects through zsh only: no second, terminal highlight. @Test func draggingOnTheCommandLineSelectsInTheShell() async throws { let env = try TestEnvironment(shell: "/bin/zsh", integration: true)