@abelfubu/dv 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +111 -0
  2. package/dist/cli-copy-notification.test.js +7 -3
  3. package/dist/cli.d.ts +3 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +2 -2
  6. package/dist/components/diff-view.d.ts +1 -1
  7. package/dist/components/diff-view.d.ts.map +1 -1
  8. package/dist/components/diff-view.js +69 -8
  9. package/dist/components/diff-view.test.d.ts.map +1 -1
  10. package/dist/components/diff-view.test.js +54 -0
  11. package/dist/components/toast.js +1 -1
  12. package/dist/themes/aura.json +69 -0
  13. package/dist/themes/ayu.json +80 -0
  14. package/dist/themes/catppuccin-frappe.json +233 -0
  15. package/dist/themes/catppuccin-macchiato.json +233 -0
  16. package/dist/themes/catppuccin.json +112 -0
  17. package/dist/themes/cobalt2.json +228 -0
  18. package/dist/themes/cursor.json +249 -0
  19. package/dist/themes/dracula.json +219 -0
  20. package/dist/themes/everforest.json +241 -0
  21. package/dist/themes/flexoki.json +237 -0
  22. package/dist/themes/github-light.json +56 -0
  23. package/dist/themes/github.json +244 -244
  24. package/dist/themes/gruvbox.json +95 -0
  25. package/dist/themes/kanagawa.json +77 -0
  26. package/dist/themes/lucent-orng.json +227 -0
  27. package/dist/themes/material.json +235 -0
  28. package/dist/themes/matrix.json +77 -0
  29. package/dist/themes/mercury.json +252 -0
  30. package/dist/themes/monokai.json +221 -0
  31. package/dist/themes/muted-slate.json +56 -0
  32. package/dist/themes/nightowl.json +221 -0
  33. package/dist/themes/nord.json +223 -0
  34. package/dist/themes/one-dark.json +84 -0
  35. package/dist/themes/opencode-light.json +62 -0
  36. package/dist/themes/opencode.json +245 -0
  37. package/dist/themes/orng.json +245 -0
  38. package/dist/themes/palenight.json +222 -0
  39. package/dist/themes/rosepine.json +234 -0
  40. package/dist/themes/solarized.json +223 -0
  41. package/dist/themes/synthwave84.json +226 -0
  42. package/dist/themes/tokyonight.json +243 -0
  43. package/dist/themes/vercel.json +255 -0
  44. package/dist/themes/vesper.json +218 -0
  45. package/dist/themes/zenburn.json +223 -0
  46. package/dist/themes.d.ts.map +1 -1
  47. package/dist/themes.js +4 -4
  48. package/package.json +11 -3
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # `dv` — Diffview
2
+
3
+ A fast, keyboard-driven diff viewer for the terminal. Pipe any `git diff` into it and navigate changes with a TUI, copy selections, switch themes, and more.
4
+
5
+ ![license](https://img.shields.io/npm/l/@abelfubu/dv)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun install -g @abelfubu/dv
11
+ ```
12
+
13
+ Requires [Bun](https://bun.sh/).
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Pipe git diff output
19
+ git diff | dv
20
+
21
+ # Diff specific refs
22
+ git diff main...feature | dv
23
+
24
+ # Show working tree changes (default when stdin is empty)
25
+ dv
26
+
27
+ # Scrollback mode for non-TTY output
28
+ git diff | dv --scrollback
29
+
30
+ # Transparent background
31
+ dv --transparent
32
+
33
+ # Watch for changes
34
+ dv --watch
35
+ ```
36
+
37
+ ## Features
38
+
39
+ - **Interactive TUI** built with [`@opentuah/react`](https://github.com/opentuah/opentuah)
40
+ - **Unified and split view** modes (auto-switches based on terminal width)
41
+ - **Directory tree** sidebar for jumping between files
42
+ - **Keyboard selection** and **clipboard copy** of diff text
43
+ - **Syntax highlighting** via Tree-sitter parsers
44
+ - **Themes** — switch at runtime
45
+ - **Transparent background** mode
46
+ - **Scrollback output** when stdout isn't a TTY
47
+ - **Watch mode** for live diff updates
48
+ - **Submodule support** — dirty submodules shown inline
49
+ - **Untracked files** displayed via synthetic diffs
50
+
51
+ ## Keybindings
52
+
53
+ | Key | Action |
54
+ | --- | --- |
55
+ | `↑` / `↓` or `k` / `j` | Move cursor |
56
+ | `←` / `→` or `h` / `l` | Switch focus (tree / diff) |
57
+ | `Enter` | Jump to file under cursor |
58
+ | `Tab` | Toggle focus between tree and diff |
59
+ | `u` | Toggle unified / split view |
60
+ | `v` | Set selection anchor |
61
+ | `y` | Copy selected diff content |
62
+ | `t` | Cycle themes |
63
+ | `T` | Toggle transparent background |
64
+ | `c` | Change context lines |
65
+ | `r` | Refresh diff |
66
+ | `q` / `Esc` | Quit |
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ # Install dependencies
72
+ bun install
73
+
74
+ # Run in development
75
+ bun run cli
76
+
77
+ # Watch mode
78
+ bun run cli:watch
79
+
80
+ # Build
81
+ cd /Users/abelfubu/dev/diffview && bun run build
82
+
83
+ # Run tests
84
+ bun test
85
+ ```
86
+
87
+ ## Project Structure
88
+
89
+ ```
90
+ .
91
+ ├── src/
92
+ │ ├── cli.tsx # CLI entrypoint
93
+ │ ├── components/ # React/TUI components
94
+ │ ├── diff-*.ts # Diff parsing, cursor, copy utilities
95
+ │ ├── themes.ts # Theme definitions
96
+ │ ├── store.ts # Persistent state
97
+ │ └── parsers/ # Tree-sitter syntax parsers
98
+ ├── docs/ # ADRs and feature docs
99
+ ├── dist/ # Compiled output
100
+ └── package.json
101
+ ```
102
+
103
+ ## Docs
104
+
105
+ - [`docs/adr-cursor-selection.md`](docs/adr-cursor-selection.md)
106
+ - [`docs/transparent-background.md`](docs/transparent-background.md)
107
+ - [`CONTEXT.md`](CONTEXT.md) — domain glossary
108
+
109
+ ## License
110
+
111
+ MIT
@@ -43,7 +43,7 @@ describe("App copy notification", () => {
43
43
  });
44
44
  it("shows a transient success notification after copying selected lines", async () => {
45
45
  const parsedFiles = [createParsedFile("a.ts")];
46
- testSetup = await testRender(_jsx(App, { parsedFiles: parsedFiles }), {
46
+ testSetup = await testRender(_jsx(App, { parsedFiles: parsedFiles, copyNotificationDuration: 100 }), {
47
47
  width: 120,
48
48
  height: 16,
49
49
  });
@@ -61,15 +61,19 @@ describe("App copy notification", () => {
61
61
  testSetup.mockInput.pressKey("j");
62
62
  await testSetup.renderOnce();
63
63
  });
64
+ testSetup.mockInput.pressKey("y");
65
+ // The toast registers a post-render callback from an effect, and on the
66
+ // single-threaded Linux test renderer the state update isn't flushed in
67
+ // time for the next renderOnce(). Yield briefly before rendering.
68
+ await new Promise((resolve) => setTimeout(resolve, 50));
64
69
  await act(async () => {
65
- testSetup.mockInput.pressKey("y");
66
70
  await testSetup.renderOnce();
67
71
  });
68
72
  const frame = testSetup.captureCharFrame();
69
73
  expect(frame).toContain("Copied 2 lines");
70
74
  expect(frame).toContain("✓");
71
75
  // Wait for the auto-hide timeout to clear the notification.
72
- await new Promise((resolve) => setTimeout(resolve, 2500));
76
+ await new Promise((resolve) => setTimeout(resolve, 150));
73
77
  await act(async () => {
74
78
  await testSetup.renderOnce();
75
79
  });
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,8 @@ import * as React from "react";
4
4
  import { type ParsedFile } from "./diff-utils.js";
5
5
  export interface AppProps {
6
6
  parsedFiles: ParsedFile[];
7
+ /** Duration (ms) the copy notification toast stays visible. Defaults to 2000. */
8
+ copyNotificationDuration?: number;
7
9
  }
8
- export declare function App({ parsedFiles }: AppProps): React.ReactNode;
10
+ export declare function App({ parsedFiles, copyNotificationDuration }: AppProps): React.ReactNode;
9
11
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.tsx"],"names":[],"mappings":";AAOA,OAAO,gCAAgC,CAAC;AAoBxC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAgB/B,OAAO,EAoBN,KAAK,UAAU,EACf,MAAM,iBAAiB,CAAC;AAuIzB,MAAM,WAAW,QAAQ;IACxB,WAAW,EAAE,UAAU,EAAE,CAAC;CAC1B;AAKD,wBAAgB,GAAG,CAAC,EAAE,WAAW,EAAE,EAAE,QAAQ,GAAG,KAAK,CAAC,SAAS,CAuzB9D"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.tsx"],"names":[],"mappings":";AAOA,OAAO,gCAAgC,CAAC;AAoBxC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAgB/B,OAAO,EAoBN,KAAK,UAAU,EACf,MAAM,iBAAiB,CAAC;AAuIzB,MAAM,WAAW,QAAQ;IACxB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,iFAAiF;IACjF,wBAAwB,CAAC,EAAE,MAAM,CAAC;CAClC;AAKD,wBAAgB,GAAG,CAAC,EAAE,WAAW,EAAE,wBAA+B,EAAE,EAAE,QAAQ,GAAG,KAAK,CAAC,SAAS,CAuzB/F"}
package/dist/cli.js CHANGED
@@ -110,7 +110,7 @@ class ScrollAcceleration {
110
110
  }
111
111
  const SIDEBAR_GAP = 2;
112
112
  const APP_HORIZONTAL_PADDING = 2;
113
- export function App({ parsedFiles }) {
113
+ export function App({ parsedFiles, copyNotificationDuration = 2000 }) {
114
114
  const { width: initialWidth, height: initialHeight } = useTerminalDimensions();
115
115
  const [width, setWidth] = React.useState(initialWidth);
116
116
  const [_terminalHeight, setTerminalHeight] = React.useState(initialHeight);
@@ -231,7 +231,7 @@ export function App({ parsedFiles }) {
231
231
  copyNotificationTimeoutRef.current = setTimeout(() => {
232
232
  setCopyNotification(null);
233
233
  copyNotificationTimeoutRef.current = null;
234
- }, 2000);
234
+ }, copyNotificationDuration);
235
235
  return () => {
236
236
  if (copyNotificationTimeoutRef.current) {
237
237
  clearTimeout(copyNotificationTimeoutRef.current);
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { DiffRenderable } from "@opentuah/core";
2
+ import { type DiffRenderable } from "@opentuah/core";
3
3
  export interface DiffViewRef {
4
4
  getDiffRenderable(): DiffRenderable | null;
5
5
  }
@@ -1 +1 @@
1
- {"version":3,"file":"diff-view.d.ts","sourceRoot":"","sources":["../../src/components/diff-view.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,EAAE,cAAc,EAAqB,MAAM,gBAAgB,CAAA;AAIlE,MAAM,WAAW,WAAW;IAC1B,iBAAiB,IAAI,cAAc,GAAG,IAAI,CAAA;CAC3C;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,GAAG,SAAS,CAAA;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;IACnC,4DAA4D;IAC5D,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,+EAA+E;IAC/E,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4DAA4D;IAC5D,SAAS,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACjD,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAwDD,eAAO,MAAM,QAAQ,mFA4InB,CAAA"}
1
+ {"version":3,"file":"diff-view.d.ts","sourceRoot":"","sources":["../../src/components/diff-view.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,EAAqB,KAAK,cAAc,EAAmD,MAAM,gBAAgB,CAAA;AAIxH,MAAM,WAAW,WAAW;IAC1B,iBAAiB,IAAI,cAAc,GAAG,IAAI,CAAA;CAC3C;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,GAAG,SAAS,CAAA;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;IACnC,4DAA4D;IAC5D,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,+EAA+E;IAC/E,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4DAA4D;IAC5D,SAAS,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACjD,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAuHD,eAAO,MAAM,QAAQ,mFAiKnB,CAAA"}
@@ -3,9 +3,55 @@ import { jsx as _jsx } from "@opentuah/react/jsx-runtime";
3
3
  // Wraps opentui's <diff> element with theme-aware colors and syntax styles.
4
4
  // Supports split and unified view modes with line numbers.
5
5
  import * as React from "react";
6
- import { DiffRenderable, RGBA, SyntaxStyle } from "@opentuah/core";
6
+ import { RGBA, SyntaxStyle } from "@opentuah/core";
7
7
  import { getSyntaxTheme, getResolvedTheme, rgbaToHex } from "../themes.js";
8
8
  import { balanceDelimiters } from "../balance-delimiters.js";
9
+ function getSideLineColorConfig(side, line) {
10
+ if (!side)
11
+ return null;
12
+ const { gutter, content } = side.getLineColors();
13
+ const g = gutter.get(line);
14
+ const c = content.get(line);
15
+ if (!g && !c)
16
+ return null;
17
+ const config = {};
18
+ if (g)
19
+ config.gutter = g;
20
+ if (c)
21
+ config.content = c;
22
+ return config;
23
+ }
24
+ function snapshotLineColors(diffRenderable, line) {
25
+ const { leftSide, rightSide } = diffRenderable;
26
+ return {
27
+ left: getSideLineColorConfig(leftSide, line),
28
+ right: getSideLineColorConfig(rightSide, line),
29
+ };
30
+ }
31
+ function isTransparent(color) {
32
+ if (!color)
33
+ return true;
34
+ if (typeof color === "string")
35
+ return color.toLowerCase() === "transparent" || color === "#00000000";
36
+ return color.a === 0;
37
+ }
38
+ function restoreSideLineColor(side, line, color) {
39
+ if (!side)
40
+ return;
41
+ const hasVisibleColor = color && ((color.gutter && !isTransparent(color.gutter)) ||
42
+ (color.content && !isTransparent(color.content)));
43
+ if (hasVisibleColor) {
44
+ side.setLineColor(line, color);
45
+ }
46
+ else {
47
+ side.clearLineColor(line);
48
+ }
49
+ }
50
+ function restoreLineColors(diffRenderable, line, base) {
51
+ const { leftSide, rightSide } = diffRenderable;
52
+ restoreSideLineColor(leftSide, line, base.left);
53
+ restoreSideLineColor(rightSide, line, base.right);
54
+ }
9
55
  function getLuminance(color) {
10
56
  return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722;
11
57
  }
@@ -83,36 +129,51 @@ export const DiffView = React.forwardRef(function DiffView({ diff, view, filetyp
83
129
  const activeSelectionColor = React.useMemo(() => {
84
130
  return selectionColor ?? "#264F78";
85
131
  }, [selectionColor]);
86
- // Track previously-applied highlights so we can clear only our own overrides.
87
132
  const prevCursorRef = React.useRef(null);
88
133
  const prevSelectionRef = React.useRef(null);
134
+ // Reset highlight tracking when the underlying diff surface is rebuilt so
135
+ // we do not try to clear lines on a stale renderable.
136
+ React.useEffect(() => {
137
+ prevCursorRef.current = null;
138
+ prevSelectionRef.current = null;
139
+ }, [diff, view, themeName]);
89
140
  // Apply cursor line and selection highlights to the underlying DiffRenderable.
90
141
  React.useEffect(() => {
91
142
  const diffRenderable = diffRef.current;
92
143
  if (!diffRenderable)
93
144
  return;
94
- // Clear previous cursor override.
145
+ // Restore previous cursor override.
95
146
  if (prevCursorRef.current) {
96
- diffRenderable.clearLineColor(prevCursorRef.current.line);
147
+ restoreLineColors(diffRenderable, prevCursorRef.current.line, prevCursorRef.current.base);
97
148
  }
98
- // Clear previous selection overrides.
149
+ // Restore previous selection overrides.
99
150
  if (prevSelectionRef.current) {
100
- diffRenderable.clearHighlightLines(prevSelectionRef.current.start, prevSelectionRef.current.end);
151
+ const { start, end, base } = prevSelectionRef.current;
152
+ for (let line = start; line <= end; line++) {
153
+ restoreLineColors(diffRenderable, line, base.get(line) ?? { left: null, right: null });
154
+ }
101
155
  }
102
156
  prevCursorRef.current = null;
103
157
  prevSelectionRef.current = null;
104
158
  if (!focused)
105
159
  return;
160
+ // Snapshot the cursor line's base color before applying any override.
161
+ const cursorBase = snapshotLineColors(diffRenderable, cursorLine);
162
+ // Snapshot and apply selection range.
106
163
  if (selection) {
107
164
  const start = Math.min(selection.start, selection.end);
108
165
  const end = Math.max(selection.start, selection.end);
109
166
  if (end >= start) {
167
+ const selectionBase = new Map();
168
+ for (let line = start; line <= end; line++) {
169
+ selectionBase.set(line, snapshotLineColors(diffRenderable, line));
170
+ }
110
171
  diffRenderable.highlightLines(start, end, activeSelectionColor);
111
- prevSelectionRef.current = { start, end, color: activeSelectionColor };
172
+ prevSelectionRef.current = { start, end, color: activeSelectionColor, base: selectionBase };
112
173
  }
113
174
  }
114
175
  diffRenderable.setLineColor(cursorLine, activeCursorColor);
115
- prevCursorRef.current = { line: cursorLine, color: activeCursorColor };
176
+ prevCursorRef.current = { line: cursorLine, base: cursorBase };
116
177
  }, [focused, cursorLine, selection, activeCursorColor, activeSelectionColor]);
117
178
  return (_jsx("box", { style: { backgroundColor: colors.bgPanel }, children: _jsx("diff", { ref: diffRef, diff: balancedDiff, view: view, fg: colors.text, treeSitterClient: undefined, filetype: filetype, syntaxStyle: syntaxStyle, showLineNumbers: true, wrapMode: wrapMode,
118
179
  // `addedBg`/`removedBg` are used by opentui as the base colors for word-level highlights.
@@ -1 +1 @@
1
- {"version":3,"file":"diff-view.test.d.ts","sourceRoot":"","sources":["../../src/components/diff-view.test.tsx"],"names":[],"mappings":"AASA,OAAO,CAAC,MAAM,CAAC;IACb,IAAI,wBAAwB,EAAE,OAAO,GAAG,SAAS,CAAA;CAClD"}
1
+ {"version":3,"file":"diff-view.test.d.ts","sourceRoot":"","sources":["../../src/components/diff-view.test.tsx"],"names":[],"mappings":"AAUA,OAAO,CAAC,MAAM,CAAC;IACb,IAAI,wBAAwB,EAAE,OAAO,GAAG,SAAS,CAAA;CAClD"}
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "@opentuah/react/jsx-runtime";
2
2
  // Tests for DiffView theme reactivity when switching themes at runtime.
3
3
  import * as React from "react";
4
4
  import { afterEach, describe, expect, it } from "bun:test";
5
+ import { act } from "react";
5
6
  import { testRender } from "@opentuah/react/test-utils";
6
7
  import { getDataPaths } from "@opentuah/core";
7
8
  import { DiffView } from "./diff-view.js";
@@ -186,6 +187,19 @@ describe("DiffView", () => {
186
187
  }
187
188
  useAppStore.setState({ themeName: "github" });
188
189
  });
190
+ it("uses transparent background for context lines when transparentBackground is enabled", async () => {
191
+ testSetup = await setupTest(_jsx(DiffView, { diff: sampleDiff, view: "unified", filetype: "txt", themeName: "github", transparentBackground: true }), {
192
+ width: 80,
193
+ height: 8,
194
+ });
195
+ await testSetup.renderOnce();
196
+ const frame = testSetup.captureSpans();
197
+ const contextLine = getLineWithToken(frame, "keep");
198
+ const contextSpan = contextLine.spans.find((span) => span.text === "keep");
199
+ expect(contextSpan).toBeDefined();
200
+ const bg = Array.from(contextSpan.bg.buffer);
201
+ expect(bg[3]).toBeCloseTo(0, 4);
202
+ });
189
203
  it("updates diff background colors after theme switch", async () => {
190
204
  useAppStore.setState({ themeName: "github" });
191
205
  testSetup = await setupTest(_jsx(ThemeToggleHarness, {}), {
@@ -293,6 +307,46 @@ describe("DiffView", () => {
293
307
  expect.closeTo(86 / 255, 4),
294
308
  ]);
295
309
  });
310
+ it("restores the diff background color when the cursor moves past a line", async () => {
311
+ let setCursorLine = () => { };
312
+ function CursorMoveHarness() {
313
+ const [line, setLine] = React.useState(2);
314
+ setCursorLine = setLine;
315
+ return (_jsx(DiffView, { diff: sampleDiff, view: "unified", filetype: "txt", themeName: "github", focused: true, cursorLine: line, cursorColor: "#123456" }));
316
+ }
317
+ testSetup = await setupTest(_jsx(CursorMoveHarness, {}), {
318
+ width: 80,
319
+ height: 8,
320
+ });
321
+ await testSetup.renderOnce();
322
+ // Capture the original context background before the cursor moves over it.
323
+ const frameBefore = testSetup.captureSpans();
324
+ const contextLineBefore = getLineWithToken(frameBefore, "keep");
325
+ expect(contextLineBefore).toBeDefined();
326
+ const contextBg = Array.from(contextLineBefore.spans[0].bg.buffer).slice(0, 3);
327
+ // Move the cursor from the added line to the context line.
328
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
329
+ act(() => setCursorLine(3));
330
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
331
+ await testSetup.renderOnce();
332
+ const frameAfter = testSetup.captureSpans();
333
+ const addedLine = getLineWithToken(frameAfter, "new");
334
+ const removedLine = getLineWithToken(frameAfter, "old");
335
+ expect(addedLine).toBeDefined();
336
+ expect(removedLine).toBeDefined();
337
+ const addedSpan = addedLine.spans.find((span) => span.text === "new");
338
+ const removedSpan = removedLine.spans.find((span) => span.text === "old");
339
+ expect(addedSpan).toBeDefined();
340
+ expect(removedSpan).toBeDefined();
341
+ const addedBg = Array.from(addedSpan.bg.buffer).slice(0, 3);
342
+ const removedBg = Array.from(removedSpan.bg.buffer).slice(0, 3);
343
+ // After moving the cursor, the added and removed lines should show their
344
+ // original diff backgrounds, not the default context background.
345
+ expect(addedBg).not.toEqual(contextBg);
346
+ expect(removedBg).not.toEqual(contextBg);
347
+ // They should also be different from each other (added vs removed).
348
+ expect(addedBg).not.toEqual(removedBg);
349
+ });
296
350
  it("does not highlight a cursor line when not focused", async () => {
297
351
  testSetup = await setupTest(_jsx(DiffView, { diff: sampleDiff, view: "unified", filetype: "txt", themeName: "github", focused: false, cursorLine: 2, cursorColor: "#123456" }), {
298
352
  width: 80,
@@ -10,7 +10,7 @@ import {} from "../themes.js";
10
10
  */
11
11
  export function Toast({ message, type, title, theme, transparentBackground }) {
12
12
  const renderer = useRenderer();
13
- React.useEffect(() => {
13
+ React.useLayoutEffect(() => {
14
14
  const borderColor = RGBA.fromHex(type === "success" ? "#2d8a47" : "#c53b53");
15
15
  const bgColor = transparentBackground ? RGBA.fromInts(0, 0, 0, 0) : theme.background;
16
16
  const fgColor = theme.text;
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "darkBg": "#0f0f0f",
5
+ "darkBgPanel": "#15141b",
6
+ "darkBorder": "#2d2d2d",
7
+ "darkFgMuted": "#6d6d6d",
8
+ "darkFg": "#edecee",
9
+ "purple": "#a277ff",
10
+ "pink": "#f694ff",
11
+ "blue": "#82e2ff",
12
+ "red": "#ff6767",
13
+ "orange": "#ffca85",
14
+ "cyan": "#61ffca",
15
+ "green": "#9dff65"
16
+ },
17
+ "theme": {
18
+ "primary": "purple",
19
+ "secondary": "pink",
20
+ "accent": "purple",
21
+ "error": "red",
22
+ "warning": "orange",
23
+ "success": "cyan",
24
+ "info": "purple",
25
+ "text": "darkFg",
26
+ "textMuted": "darkFgMuted",
27
+ "background": "darkBg",
28
+ "backgroundPanel": "darkBgPanel",
29
+ "backgroundElement": "darkBgPanel",
30
+ "border": "darkBorder",
31
+ "borderActive": "darkFgMuted",
32
+ "borderSubtle": "darkBorder",
33
+ "diffAdded": "cyan",
34
+ "diffRemoved": "red",
35
+ "diffContext": "darkFgMuted",
36
+ "diffHunkHeader": "darkFgMuted",
37
+ "diffHighlightAdded": "cyan",
38
+ "diffHighlightRemoved": "red",
39
+ "diffAddedBg": "#354933",
40
+ "diffRemovedBg": "#3f191a",
41
+ "diffContextBg": "darkBgPanel",
42
+ "diffLineNumber": "darkBorder",
43
+ "diffAddedLineNumberBg": "#162620",
44
+ "diffRemovedLineNumberBg": "#26161a",
45
+ "markdownText": "darkFg",
46
+ "markdownHeading": "purple",
47
+ "markdownLink": "pink",
48
+ "markdownLinkText": "purple",
49
+ "markdownCode": "cyan",
50
+ "markdownBlockQuote": "darkFgMuted",
51
+ "markdownEmph": "orange",
52
+ "markdownStrong": "purple",
53
+ "markdownHorizontalRule": "darkFgMuted",
54
+ "markdownListItem": "purple",
55
+ "markdownListEnumeration": "purple",
56
+ "markdownImage": "pink",
57
+ "markdownImageText": "purple",
58
+ "markdownCodeBlock": "darkFg",
59
+ "syntaxComment": "darkFgMuted",
60
+ "syntaxKeyword": "pink",
61
+ "syntaxFunction": "purple",
62
+ "syntaxVariable": "purple",
63
+ "syntaxString": "cyan",
64
+ "syntaxNumber": "green",
65
+ "syntaxType": "purple",
66
+ "syntaxOperator": "pink",
67
+ "syntaxPunctuation": "darkFg"
68
+ }
69
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "darkBg": "#0B0E14",
5
+ "darkBgAlt": "#0D1017",
6
+ "darkLine": "#11151C",
7
+ "darkPanel": "#0F131A",
8
+ "darkFg": "#BFBDB6",
9
+ "darkFgMuted": "#565B66",
10
+ "darkGutter": "#6C7380",
11
+ "darkTag": "#39BAE6",
12
+ "darkFunc": "#FFB454",
13
+ "darkEntity": "#59C2FF",
14
+ "darkString": "#AAD94C",
15
+ "darkRegexp": "#95E6CB",
16
+ "darkMarkup": "#F07178",
17
+ "darkKeyword": "#FF8F40",
18
+ "darkSpecial": "#E6B673",
19
+ "darkComment": "#ACB6BF",
20
+ "darkConstant": "#D2A6FF",
21
+ "darkOperator": "#F29668",
22
+ "darkAdded": "#7FD962",
23
+ "darkRemoved": "#F26D78",
24
+ "darkAccent": "#E6B450",
25
+ "darkError": "#D95757",
26
+ "darkIndentActive": "#6C7380"
27
+ },
28
+ "theme": {
29
+ "primary": "darkEntity",
30
+ "secondary": "darkConstant",
31
+ "accent": "darkAccent",
32
+ "error": "darkError",
33
+ "warning": "darkSpecial",
34
+ "success": "darkAdded",
35
+ "info": "darkTag",
36
+ "text": "darkFg",
37
+ "textMuted": "darkFgMuted",
38
+ "background": "darkBg",
39
+ "backgroundPanel": "darkPanel",
40
+ "backgroundElement": "darkBgAlt",
41
+ "border": "darkGutter",
42
+ "borderActive": "darkIndentActive",
43
+ "borderSubtle": "darkLine",
44
+ "diffAdded": "darkAdded",
45
+ "diffRemoved": "darkRemoved",
46
+ "diffContext": "darkComment",
47
+ "diffHunkHeader": "darkComment",
48
+ "diffHighlightAdded": "darkString",
49
+ "diffHighlightRemoved": "darkMarkup",
50
+ "diffAddedBg": "#20303b",
51
+ "diffRemovedBg": "#37222c",
52
+ "diffContextBg": "darkPanel",
53
+ "diffLineNumber": "darkGutter",
54
+ "diffAddedLineNumberBg": "#1b2b34",
55
+ "diffRemovedLineNumberBg": "#2d1f26",
56
+ "markdownText": "darkFg",
57
+ "markdownHeading": "darkConstant",
58
+ "markdownLink": "darkEntity",
59
+ "markdownLinkText": "darkTag",
60
+ "markdownCode": "darkString",
61
+ "markdownBlockQuote": "darkSpecial",
62
+ "markdownEmph": "darkSpecial",
63
+ "markdownStrong": "darkFunc",
64
+ "markdownHorizontalRule": "darkFgMuted",
65
+ "markdownListItem": "darkEntity",
66
+ "markdownListEnumeration": "darkTag",
67
+ "markdownImage": "darkEntity",
68
+ "markdownImageText": "darkTag",
69
+ "markdownCodeBlock": "darkFg",
70
+ "syntaxComment": "darkComment",
71
+ "syntaxKeyword": "darkKeyword",
72
+ "syntaxFunction": "darkFunc",
73
+ "syntaxVariable": "darkEntity",
74
+ "syntaxString": "darkString",
75
+ "syntaxNumber": "darkConstant",
76
+ "syntaxType": "darkSpecial",
77
+ "syntaxOperator": "darkOperator",
78
+ "syntaxPunctuation": "darkFg"
79
+ }
80
+ }