diff --git a/Glint/Agent/ShellRcBlock.swift b/Glint/Agent/ShellRcBlock.swift index b70309d2..b475b608 100644 --- a/Glint/Agent/ShellRcBlock.swift +++ b/Glint/Agent/ShellRcBlock.swift @@ -11,25 +11,36 @@ enum ShellRcBlock { /// Insert or replace a fenced block delimited by `begin` and `end`. The /// returned text contains exactly one such block. /// - /// - Empty input → just `block + "\n"`. + /// - Empty input → just `block` (ending in one newline). /// - Existing block (located by line-anchored sentinel match) → replaced /// in place; surrounding content untouched. /// - No existing block → appended with one blank line above so we don't /// smash into a user's last line. + /// + /// `block` is normalized to end in exactly one newline. The callers' + /// blocks are Swift multiline literals, which end at the closing + /// delimiter WITHOUT one, and pasting such a block over a located range + /// used to glue whatever followed the block onto the end sentinel + /// (#124) — silently commenting it out, and re-gluing it on every + /// launch. Normalizing here also heals an already-glued file the next + /// time the block is rewritten. static func upsert(in text: String, begin: String, end: String, block: String) -> String { + var block = block + while block.hasSuffix("\n") { block.removeLast() } + block += "\n" if let range = locate(in: text, begin: begin, end: end) { var out = text out.replaceSubrange(range, with: block) return out } - if text.isEmpty { return block + "\n" } + if text.isEmpty { return block } var out = text if !out.hasSuffix("\n") { out += "\n" } // newline before block if !out.hasSuffix("\n\n") { out += "\n" } // one blank line above block - out += block + "\n" + out += block return out } diff --git a/Glint/Pane/InlineSuggestionInstaller.swift b/Glint/Pane/InlineSuggestionInstaller.swift index 47f949c7..b82e879d 100644 --- a/Glint/Pane/InlineSuggestionInstaller.swift +++ b/Glint/Pane/InlineSuggestionInstaller.swift @@ -147,17 +147,19 @@ enum InlineSuggestionInstaller { let content = """ # Glint inline suggestions — sourced via the fenced block in ~/.zshrc. # Skip if the user already has zsh-autosuggestions loaded by their own - # config (oh-my-zsh / prezto users): we respect their setup. - if [[ -z ${ZSH_AUTOSUGGEST_VERSION-} ]]; then + # config (oh-my-zsh / prezto users): we respect their setup. Probe the + # _zsh_autosuggest_start hook the plugin defines when sourced — not + # ZSH_AUTOSUGGEST_VERSION, which zsh-autosuggestions never sets, so + # the old guard here could never fire. + if (( ! $+functions[_zsh_autosuggest_start] )); then ZSH_AUTOSUGGEST_STRATEGY=(history) : ${ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE:='fg=8'} : ${ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE:=200} + # No manual bindkey needed: the plugin's default + # ZSH_AUTOSUGGEST_ACCEPT_WIDGETS already binds → (forward-char) + # and End (end-of-line) to accept the suggestion. if [[ -r "$HOME/.config/glint/zsh-autosuggestions.zsh" ]]; then source "$HOME/.config/glint/zsh-autosuggestions.zsh" - # → and End accept the full suggestion. Tab stays as zsh's - # native completion (don't repurpose it). - bindkey '^[[C' autosuggest-accept 2>/dev/null - bindkey '^E' autosuggest-accept 2>/dev/null fi fi """ diff --git a/GlintTests/ShellRcBlockTests.swift b/GlintTests/ShellRcBlockTests.swift index dda87b49..228b081a 100644 --- a/GlintTests/ShellRcBlockTests.swift +++ b/GlintTests/ShellRcBlockTests.swift @@ -52,14 +52,46 @@ final class ShellRcBlockTests: XCTestCase { } func testUpsertReplacesBlockInPlace() { - // `upsert` consumes the newline after `end`, so the replacement block - // must carry its own trailing newline (the real managed block does). + // Replacement must leave surrounding lines intact; `upsert` normalizes + // the block's trailing newline itself (see the #124 tests below). let old = "pre\n\(begin)\nold body\n\(end)\npost\n" let new = "\(begin)\nnew body\n\(end)\n" let result = ShellRcBlock.upsert(in: old, begin: begin, end: end, block: new) XCTAssertEqual(result, "pre\n\(begin)\nnew body\n\(end)\npost\n") } + /// #124: the real managed blocks are Swift multiline literals, which end + /// at the closing delimiter WITHOUT a trailing newline. Replacing a block + /// with such a literal used to glue the line after the block onto the end + /// sentinel, silently commenting it out — and re-gluing it on every + /// launch, so a manual fix didn't survive an app restart. + func testUpsertReplaceCarriesTrailingNewline() { + let old = "pre\n\(begin)\nold body\n\(end)\npost\n" + let new = "\(begin)\nnew body\n\(end)" // no trailing newline + let result = ShellRcBlock.upsert(in: old, begin: begin, end: end, block: new) + XCTAssertEqual(result, "pre\n\(begin)\nnew body\n\(end)\npost\n") + } + + /// A file already damaged by the #124 glue heals on the next launch: the + /// line stuck to the end sentinel lands back on its own line. (The real + /// damaged file ends with a newline — the glued line swallowed the one + /// after the sentinel, not the file's own.) + func testUpsertHealsLineGluedAfterEndSentinel() { + let glued = "pre\n\(begin)\nold body\n\(end)source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n" + let new = "\(begin)\nnew body\n\(end)" + let result = ShellRcBlock.upsert(in: glued, begin: begin, end: end, block: new) + XCTAssertEqual(result, "pre\n\(begin)\nnew body\n\(end)\nsource /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n") + } + + /// Launch-to-launch idempotency: re-upserting the same block must not + /// change the file (no write, no trailing-blank-line creep). + func testUpsertIsIdempotent() { + let block = "\(begin)\nx\n\(end)" + let first = ShellRcBlock.upsert(in: "user config\n", begin: begin, end: end, block: block) + let second = ShellRcBlock.upsert(in: first, begin: begin, end: end, block: block) + XCTAssertEqual(second, first) + } + func testUpsertAppendsWhenAbsent() { let result = ShellRcBlock.upsert(in: "user config\n", begin: begin, end: end,