···132132133133Place custom themes at `~/.config/sdiff/themes/<name>.toml` and reference them with `-theme <name>`.
134134135135+## hookable integration
136136+137137+[hookable](https://tangled.org/adriano.tngl.sh/hookable) is a Claude Code hook runner that exposes tool call inputs as environment variables and forwards them to an arbitrary command. sdiff reads these variables natively, so it can be dropped in as the `--cmd` to preview file changes before Claude applies them.
138138+139139+When invoked with no file arguments and `HOOKABLE_TOOL_NAME` is set, sdiff constructs the before/after diff automatically:
140140+141141+| Tool | Before | After |
142142+|------|--------|-------|
143143+| `Edit` | Current file on disk | File with `old_string` replaced by `new_string` |
144144+| `Write` | Current file on disk (empty if new) | Incoming content |
145145+146146+### Claude Code hook configuration
147147+148148+Add to `~/.claude/settings.json`:
149149+150150+```json
151151+{
152152+ "hooks": {
153153+ "PreToolUse": [
154154+ {
155155+ "matcher": "Edit",
156156+ "hooks": [{"type": "command", "command": "hookable --interactive --no-exit-code --cmd 'sdiff -i'"}]
157157+ },
158158+ {
159159+ "matcher": "Write",
160160+ "hooks": [{"type": "command", "command": "hookable --interactive --no-exit-code --cmd 'sdiff -i'"}]
161161+ }
162162+ ]
163163+ }
164164+}
165165+```
166166+167167+`--interactive` runs sdiff under a PTY so the full TUI renders. `--no-exit-code` tells hookable to ignore sdiff's exit code and always wait for a keypress — press `y` to allow the change or `n` to deny it.
168168+135169## Development
136170137171```sh
+25
internal/diff.go
···223223 addRuns := runsOf(added, isChangedNew)
224224225225 if len(remRuns) != len(addRuns) {
226226+ // If all removed lines are semantically unchanged, we can partially
227227+ // promote: emit genuinely-new added lines (e.g. new comments) as
228228+ // KindAdded and promote the style-only added lines as KindEqual.
229229+ allRemUnchanged := true
230230+ for _, rr := range remRuns {
231231+ if rr.changed {
232232+ allRemUnchanged = false
233233+ break
234234+ }
235235+ }
236236+ if allRemUnchanged {
237237+ var changedAdded, unchangedAdded []DiffLine
238238+ for _, ar := range addRuns {
239239+ if ar.changed {
240240+ changedAdded = append(changedAdded, ar.lines...)
241241+ } else {
242242+ unchangedAdded = append(unchangedAdded, ar.lines...)
243243+ }
244244+ }
245245+ if len(unchangedAdded) > 0 {
246246+ result = append(result, changedAdded...)
247247+ emitEqualGroup(&result, removed, unchangedAdded)
248248+ continue
249249+ }
250250+ }
226251 result = append(result, removed...)
227252 result = append(result, added...)
228253 continue
+8-1
internal/highlight.go
···76767777// RenderCell renders a pre-highlighted ANSI string into a fixed-width cell,
7878// applying bgHex as the background colour (empty string = no background).
7979-func RenderCell(highlighted string, width int, bgHex string) string {
7979+// If fillToEOL is true the background is extended to the end of the terminal
8080+// line via \033[K — use this on the rightmost cell in a row so that screenshot
8181+// tools (which render in a wider virtual terminal) show a full-width highlight
8282+// rather than a truncated one.
8383+func RenderCell(highlighted string, width int, bgHex string, fillToEOL bool) string {
8084 plain := StripANSI(highlighted)
8185 runeCount := len([]rune(plain))
8286···96100 }
97101 r, g, b := parseHex(bgHex)
98102 bg := fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
103103+ if fillToEOL {
104104+ return bg + content + padding + bg + "\033[K\033[0m"
105105+ }
99106 return bg + content + padding + "\033[0m"
100107}
101108
···2233import "fmt"
4455+// We've formatted add's params on multiple lines instead of one
66+// because this is a semantic diff, and one vs. multiple lines is
77+// semantically, equivalent, no diff is shown for that change.
58func add(
69 a int,
710 b int,