@neurocode-ai/tui 1.18.8

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 (241) hide show
  1. package/bunfig.toml +4 -0
  2. package/package.json +72 -0
  3. package/src/app.tsx +1134 -0
  4. package/src/attention.ts +260 -0
  5. package/src/audio.d.ts +9 -0
  6. package/src/audio.ts +53 -0
  7. package/src/clipboard.ts +124 -0
  8. package/src/component/bg-pulse-render.ts +436 -0
  9. package/src/component/bg-pulse.tsx +99 -0
  10. package/src/component/command-palette.tsx +79 -0
  11. package/src/component/dialog-agent.tsx +31 -0
  12. package/src/component/dialog-console-org.tsx +135 -0
  13. package/src/component/dialog-debug.tsx +90 -0
  14. package/src/component/dialog-mcp.tsx +85 -0
  15. package/src/component/dialog-model.tsx +197 -0
  16. package/src/component/dialog-move-session.tsx +353 -0
  17. package/src/component/dialog-provider.tsx +469 -0
  18. package/src/component/dialog-retry-action.tsx +160 -0
  19. package/src/component/dialog-session-delete-failed.tsx +99 -0
  20. package/src/component/dialog-session-list.tsx +364 -0
  21. package/src/component/dialog-session-rename.tsx +31 -0
  22. package/src/component/dialog-skill.tsx +70 -0
  23. package/src/component/dialog-stash.tsx +87 -0
  24. package/src/component/dialog-status.tsx +168 -0
  25. package/src/component/dialog-tag.tsx +47 -0
  26. package/src/component/dialog-theme-list.tsx +50 -0
  27. package/src/component/dialog-variant.tsx +39 -0
  28. package/src/component/dialog-workspace-create.tsx +308 -0
  29. package/src/component/dialog-workspace-file-changes.tsx +144 -0
  30. package/src/component/dialog-workspace-list.tsx +112 -0
  31. package/src/component/dialog-workspace-unavailable.tsx +69 -0
  32. package/src/component/error-component.tsx +240 -0
  33. package/src/component/logo.tsx +61 -0
  34. package/src/component/plugin-route-missing.tsx +14 -0
  35. package/src/component/prompt/autocomplete.tsx +781 -0
  36. package/src/component/prompt/cwd.ts +0 -0
  37. package/src/component/prompt/frecency.tsx +1 -0
  38. package/src/component/prompt/history.tsx +1 -0
  39. package/src/component/prompt/index.tsx +1713 -0
  40. package/src/component/prompt/local-attachment.ts +48 -0
  41. package/src/component/prompt/move.tsx +205 -0
  42. package/src/component/prompt/stash.tsx +1 -0
  43. package/src/component/prompt/workspace.tsx +137 -0
  44. package/src/component/register-spinner.ts +6 -0
  45. package/src/component/spinner.tsx +26 -0
  46. package/src/component/startup-loading.tsx +63 -0
  47. package/src/component/todo-item.tsx +32 -0
  48. package/src/component/use-connected.tsx +12 -0
  49. package/src/component/workspace-label.tsx +19 -0
  50. package/src/config/index.tsx +129 -0
  51. package/src/config/keybind.ts +471 -0
  52. package/src/context/args.tsx +16 -0
  53. package/src/context/clipboard.tsx +18 -0
  54. package/src/context/data.tsx +569 -0
  55. package/src/context/directory.ts +17 -0
  56. package/src/context/editor.ts +408 -0
  57. package/src/context/epilogue.tsx +6 -0
  58. package/src/context/event.ts +36 -0
  59. package/src/context/exit.tsx +8 -0
  60. package/src/context/helper.tsx +26 -0
  61. package/src/context/kv.tsx +66 -0
  62. package/src/context/local.tsx +542 -0
  63. package/src/context/location.tsx +14 -0
  64. package/src/context/path-format.tsx +24 -0
  65. package/src/context/permission.tsx +26 -0
  66. package/src/context/project.tsx +115 -0
  67. package/src/context/prompt.tsx +18 -0
  68. package/src/context/route.tsx +60 -0
  69. package/src/context/runtime.tsx +62 -0
  70. package/src/context/sdk.tsx +151 -0
  71. package/src/context/sync.tsx +666 -0
  72. package/src/context/theme.tsx +332 -0
  73. package/src/context/thinking.ts +67 -0
  74. package/src/editor-zed.ts +286 -0
  75. package/src/editor.ts +101 -0
  76. package/src/feature-plugins/builtins.ts +36 -0
  77. package/src/feature-plugins/home/footer.tsx +100 -0
  78. package/src/feature-plugins/home/tips-view.tsx +287 -0
  79. package/src/feature-plugins/home/tips.tsx +59 -0
  80. package/src/feature-plugins/sidebar/context.tsx +65 -0
  81. package/src/feature-plugins/sidebar/files.tsx +70 -0
  82. package/src/feature-plugins/sidebar/footer.tsx +98 -0
  83. package/src/feature-plugins/sidebar/lsp.tsx +65 -0
  84. package/src/feature-plugins/sidebar/mcp.tsx +97 -0
  85. package/src/feature-plugins/sidebar/todo.tsx +49 -0
  86. package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
  87. package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
  88. package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
  89. package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
  90. package/src/feature-plugins/system/notifications.ts +94 -0
  91. package/src/feature-plugins/system/plugins.tsx +269 -0
  92. package/src/feature-plugins/system/which-key.tsx +608 -0
  93. package/src/index.tsx +1 -0
  94. package/src/keymap.tsx +290 -0
  95. package/src/logo.ts +11 -0
  96. package/src/parsers-config.ts +386 -0
  97. package/src/plugin/adapters.tsx +355 -0
  98. package/src/plugin/api.ts +52 -0
  99. package/src/plugin/command-shim.ts +109 -0
  100. package/src/plugin/runtime.tsx +81 -0
  101. package/src/plugin/slots.tsx +65 -0
  102. package/src/prompt/display.ts +48 -0
  103. package/src/prompt/frecency.tsx +80 -0
  104. package/src/prompt/history.tsx +111 -0
  105. package/src/prompt/part.ts +29 -0
  106. package/src/prompt/stash.tsx +89 -0
  107. package/src/prompt/traits.ts +29 -0
  108. package/src/routes/home/session-destination.tsx +41 -0
  109. package/src/routes/home.tsx +95 -0
  110. package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
  111. package/src/routes/session/dialog-message.tsx +109 -0
  112. package/src/routes/session/dialog-subagent.tsx +26 -0
  113. package/src/routes/session/dialog-timeline.tsx +47 -0
  114. package/src/routes/session/footer.tsx +91 -0
  115. package/src/routes/session/index.tsx +2710 -0
  116. package/src/routes/session/permission.tsx +718 -0
  117. package/src/routes/session/question.tsx +514 -0
  118. package/src/routes/session/sidebar.tsx +103 -0
  119. package/src/routes/session/subagent-footer.tsx +132 -0
  120. package/src/runtime.tsx +9 -0
  121. package/src/terminal-win32.ts +130 -0
  122. package/src/theme/assets/aura.json +69 -0
  123. package/src/theme/assets/ayu.json +80 -0
  124. package/src/theme/assets/carbonfox.json +248 -0
  125. package/src/theme/assets/catppuccin-frappe.json +230 -0
  126. package/src/theme/assets/catppuccin-macchiato.json +230 -0
  127. package/src/theme/assets/catppuccin.json +112 -0
  128. package/src/theme/assets/cobalt2.json +225 -0
  129. package/src/theme/assets/cursor.json +249 -0
  130. package/src/theme/assets/dracula.json +219 -0
  131. package/src/theme/assets/everforest.json +241 -0
  132. package/src/theme/assets/flexoki.json +237 -0
  133. package/src/theme/assets/github.json +233 -0
  134. package/src/theme/assets/gruvbox.json +242 -0
  135. package/src/theme/assets/kanagawa.json +77 -0
  136. package/src/theme/assets/lucent-orng.json +234 -0
  137. package/src/theme/assets/material.json +235 -0
  138. package/src/theme/assets/matrix.json +77 -0
  139. package/src/theme/assets/mercury.json +252 -0
  140. package/src/theme/assets/monokai.json +221 -0
  141. package/src/theme/assets/nightowl.json +221 -0
  142. package/src/theme/assets/nord.json +223 -0
  143. package/src/theme/assets/one-dark.json +84 -0
  144. package/src/theme/assets/opencode.json +245 -0
  145. package/src/theme/assets/orng.json +249 -0
  146. package/src/theme/assets/osaka-jade.json +93 -0
  147. package/src/theme/assets/palenight.json +222 -0
  148. package/src/theme/assets/rosepine.json +234 -0
  149. package/src/theme/assets/solarized.json +223 -0
  150. package/src/theme/assets/synthwave84.json +226 -0
  151. package/src/theme/assets/tokyonight.json +243 -0
  152. package/src/theme/assets/vercel.json +245 -0
  153. package/src/theme/assets/vesper.json +218 -0
  154. package/src/theme/assets/zenburn.json +223 -0
  155. package/src/theme/index.ts +1089 -0
  156. package/src/ui/border.ts +21 -0
  157. package/src/ui/dialog-alert.tsx +66 -0
  158. package/src/ui/dialog-confirm.tsx +108 -0
  159. package/src/ui/dialog-export-options.tsx +217 -0
  160. package/src/ui/dialog-help.tsx +40 -0
  161. package/src/ui/dialog-prompt.tsx +126 -0
  162. package/src/ui/dialog-select.tsx +790 -0
  163. package/src/ui/dialog.tsx +231 -0
  164. package/src/ui/link.tsx +34 -0
  165. package/src/ui/spinner.ts +368 -0
  166. package/src/ui/toast.tsx +102 -0
  167. package/src/util/collapse-tool-output.ts +19 -0
  168. package/src/util/error.ts +182 -0
  169. package/src/util/filetype.ts +130 -0
  170. package/src/util/format.ts +20 -0
  171. package/src/util/layout.ts +25 -0
  172. package/src/util/locale.ts +86 -0
  173. package/src/util/model.ts +28 -0
  174. package/src/util/path.ts +12 -0
  175. package/src/util/persistence.ts +33 -0
  176. package/src/util/presentation.ts +38 -0
  177. package/src/util/provider-origin.ts +7 -0
  178. package/src/util/record.ts +3 -0
  179. package/src/util/renderer.ts +7 -0
  180. package/src/util/revert-diff.ts +18 -0
  181. package/src/util/scroll.ts +27 -0
  182. package/src/util/selection.ts +79 -0
  183. package/src/util/session.ts +3 -0
  184. package/src/util/signal.ts +51 -0
  185. package/src/util/system.ts +20 -0
  186. package/src/util/tool-display.ts +13 -0
  187. package/src/util/transcript.ts +112 -0
  188. package/sst-env.d.ts +10 -0
  189. package/test/app-lifecycle.test.tsx +128 -0
  190. package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
  191. package/test/cli/cmd/tui/model-options.test.ts +32 -0
  192. package/test/cli/cmd/tui/notifications.test.ts +267 -0
  193. package/test/cli/cmd/tui/provider-options.test.ts +41 -0
  194. package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
  195. package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
  196. package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
  197. package/test/cli/cmd/tui/sync.test.tsx +65 -0
  198. package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
  199. package/test/cli/tui/data.test.tsx +486 -0
  200. package/test/cli/tui/dialog-prompt.test.tsx +147 -0
  201. package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
  202. package/test/cli/tui/diff-viewer.test.tsx +268 -0
  203. package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
  204. package/test/cli/tui/prompt-submit-race.test.ts +98 -0
  205. package/test/cli/tui/thinking.test.ts +36 -0
  206. package/test/cli/tui/use-event.test.tsx +148 -0
  207. package/test/clipboard.test.ts +19 -0
  208. package/test/component/dialog-session-list.test.ts +46 -0
  209. package/test/config.test.tsx +127 -0
  210. package/test/context/local.test.ts +22 -0
  211. package/test/editor.test.ts +32 -0
  212. package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
  213. package/test/fixture/fixture.ts +13 -0
  214. package/test/fixture/tui-environment.tsx +32 -0
  215. package/test/fixture/tui-plugin.ts +36 -0
  216. package/test/fixture/tui-runtime.ts +12 -0
  217. package/test/fixture/tui-sdk.ts +109 -0
  218. package/test/index.test.tsx +6 -0
  219. package/test/keymap.test.tsx +141 -0
  220. package/test/plugin/runtime.test.ts +50 -0
  221. package/test/plugin/slots.test.tsx +38 -0
  222. package/test/prompt/display.test.ts +33 -0
  223. package/test/prompt/history.test.ts +39 -0
  224. package/test/prompt/jsonl.test.ts +24 -0
  225. package/test/prompt/local-attachment.test.ts +43 -0
  226. package/test/prompt/part.test.ts +53 -0
  227. package/test/prompt/persistence.test.ts +23 -0
  228. package/test/prompt/traits.test.ts +25 -0
  229. package/test/runtime.test.tsx +37 -0
  230. package/test/theme.test.ts +81 -0
  231. package/test/util/error.test.ts +49 -0
  232. package/test/util/filetype.test.ts +16 -0
  233. package/test/util/format.test.ts +59 -0
  234. package/test/util/model.test.ts +9 -0
  235. package/test/util/presentation.test.ts +8 -0
  236. package/test/util/renderer.test.ts +30 -0
  237. package/test/util/revert-diff.test.ts +35 -0
  238. package/test/util/session.test.ts +10 -0
  239. package/test/util/tool-display.test.ts +40 -0
  240. package/test/util/transcript.test.ts +421 -0
  241. package/tsconfig.json +10 -0
@@ -0,0 +1,147 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { TextareaRenderable } from "@opentui/core"
3
+ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
4
+ import { testRender, useRenderer } from "@opentui/solid"
5
+ import { expect, test } from "bun:test"
6
+ import { mkdir } from "node:fs/promises"
7
+ import path from "node:path"
8
+ import { onCleanup } from "solid-js"
9
+ import { tmpdir } from "../../fixture/fixture"
10
+ import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
11
+ import type { TuiKeybind } from "../../../src/config/keybind"
12
+ import { TestTuiContexts } from "../../fixture/tui-environment"
13
+
14
+ async function wait(fn: () => boolean, timeout = 2000) {
15
+ const start = Date.now()
16
+ while (!fn()) {
17
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
18
+ await Bun.sleep(10)
19
+ }
20
+ }
21
+
22
+ async function mountPrompt(input: {
23
+ root: string
24
+ keybinds: Partial<TuiKeybind.Keybinds>
25
+ onConfirm: (value: string) => void
26
+ }) {
27
+ const state = path.join(input.root, "state")
28
+ await mkdir(state, { recursive: true })
29
+ await Bun.write(path.join(state, "kv.json"), "{}")
30
+
31
+ const [
32
+ { DialogProvider },
33
+ { DialogPrompt },
34
+ { KVProvider },
35
+ { ThemeProvider },
36
+ { TuiConfigProvider },
37
+ { ToastProvider },
38
+ { OpencodeKeymapProvider, registerOpencodeKeymap },
39
+ ] = await Promise.all([
40
+ import("../../../src/ui/dialog"),
41
+ import("../../../src/ui/dialog-prompt"),
42
+ import("../../../src/context/kv"),
43
+ import("../../../src/context/theme"),
44
+ import("../../../src/config"),
45
+ import("../../../src/ui/toast"),
46
+ import("../../../src/keymap"),
47
+ ])
48
+
49
+ function Harness() {
50
+ const renderer = useRenderer()
51
+ const keymap = createDefaultOpenTuiKeymap(renderer)
52
+ const resolvedConfig = createTuiResolvedConfig({
53
+ keybinds: input.keybinds,
54
+ leader_timeout: 1000,
55
+ })
56
+ const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
57
+ onCleanup(off)
58
+
59
+ return (
60
+ <TestTuiContexts
61
+ directory={input.root}
62
+ paths={{
63
+ home: input.root,
64
+ state,
65
+ worktree: input.root,
66
+ }}
67
+ >
68
+ <OpencodeKeymapProvider keymap={keymap}>
69
+ <TuiConfigProvider config={resolvedConfig}>
70
+ <KVProvider>
71
+ <ThemeProvider mode="dark">
72
+ <ToastProvider>
73
+ <DialogProvider>
74
+ <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
75
+ </DialogProvider>
76
+ </ToastProvider>
77
+ </ThemeProvider>
78
+ </KVProvider>
79
+ </TuiConfigProvider>
80
+ </OpencodeKeymapProvider>
81
+ </TestTuiContexts>
82
+ )
83
+ }
84
+
85
+ const app = await testRender(() => <Harness />, { kittyKeyboard: true })
86
+ return {
87
+ app,
88
+ async cleanup() {
89
+ app.renderer.destroy()
90
+ },
91
+ }
92
+ }
93
+
94
+ test("dialog prompt submit wins when return is also input newline", async () => {
95
+ await using tmp = await tmpdir()
96
+ const confirmed: string[] = []
97
+ const prompt = await mountPrompt({
98
+ root: tmp.path,
99
+ keybinds: {
100
+ input_submit: "super+return",
101
+ input_newline: "return,shift+return,alt+return,ctrl+j",
102
+ },
103
+ onConfirm: (value) => confirmed.push(value),
104
+ })
105
+
106
+ try {
107
+ await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
108
+ const textarea = prompt.app.renderer.currentFocusedEditor
109
+ if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
110
+
111
+ prompt.app.mockInput.pressEnter()
112
+
113
+ expect(confirmed).toEqual(["draft"])
114
+ expect(textarea.plainText).toBe("draft")
115
+ } finally {
116
+ await prompt.cleanup()
117
+ }
118
+ })
119
+
120
+ test("dialog prompt submit can be rebound separately from input submit", async () => {
121
+ await using tmp = await tmpdir()
122
+ const confirmed: string[] = []
123
+ const prompt = await mountPrompt({
124
+ root: tmp.path,
125
+ keybinds: {
126
+ input_submit: "return",
127
+ "dialog.prompt.submit": "ctrl+y",
128
+ },
129
+ onConfirm: (value) => confirmed.push(value),
130
+ })
131
+
132
+ try {
133
+ await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
134
+ const textarea = prompt.app.renderer.currentFocusedEditor
135
+ if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
136
+
137
+ prompt.app.mockInput.pressEnter()
138
+ expect(confirmed).toEqual([])
139
+ expect(textarea.plainText).toBe("draft")
140
+
141
+ prompt.app.mockInput.pressKey("y", { ctrl: true })
142
+
143
+ expect(confirmed).toEqual(["draft"])
144
+ } finally {
145
+ await prompt.cleanup()
146
+ }
147
+ })
@@ -0,0 +1,200 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { describe, expect, test } from "bun:test"
3
+ import { RGBA } from "@opentui/core"
4
+ import { testRender } from "@opentui/solid"
5
+ import type { JSX } from "solid-js"
6
+ import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
7
+ import { KVProvider } from "../../../src/context/kv"
8
+ import { ThemeProvider } from "../../../src/context/theme"
9
+ import { TuiConfigProvider } from "../../../src/config"
10
+ import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
11
+ import { TestTuiContexts } from "../../fixture/tui-environment"
12
+ import {
13
+ allExpandedFileTreeDirectories,
14
+ buildFileTree,
15
+ } from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
16
+
17
+ const theme = {
18
+ background: RGBA.fromHex("#000000"),
19
+ backgroundPanel: RGBA.fromHex("#111111"),
20
+ backgroundElement: RGBA.fromHex("#333333"),
21
+ primary: RGBA.fromHex("#00ffff"),
22
+ secondary: RGBA.fromHex("#0088ff"),
23
+ selectedListItemText: RGBA.fromHex("#ffffff"),
24
+ text: RGBA.fromHex("#ffffff"),
25
+ textMuted: RGBA.fromHex("#888888"),
26
+ error: RGBA.fromHex("#ff0000"),
27
+ }
28
+
29
+ describe("DiffViewerFileTree", () => {
30
+ test.skip("renders sorted hierarchical file rows", async () => {
31
+ const app = await testRender(
32
+ () =>
33
+ withTheme(() => (
34
+ <DiffViewerFileTree
35
+ width={32}
36
+ files={[
37
+ { file: "z-file.ts" },
38
+ { file: "b/file.ts" },
39
+ { file: "a/zeta.ts" },
40
+ { file: "b/alpha.ts" },
41
+ { file: "a/alpha.ts" },
42
+ ]}
43
+ loading={false}
44
+ error={undefined}
45
+ theme={theme}
46
+ focused={true}
47
+ />
48
+ )),
49
+ { width: 40, height: 20 },
50
+ )
51
+
52
+ try {
53
+ await renderOnceSettled(app)
54
+ const lines = visibleLines(app.captureCharFrame())
55
+
56
+ expect(lines).toEqual([
57
+ "▾ a",
58
+ "│ ├─ alpha.ts ?",
59
+ "│ └─ zeta.ts ?",
60
+ "├─ ▾ b",
61
+ "│ ├─ alpha.ts ?",
62
+ "│ └─ file.ts ?",
63
+ ])
64
+ } finally {
65
+ app.renderer.destroy()
66
+ }
67
+ })
68
+
69
+ test("keeps loading and error quiet while rendering an empty settled state", async () => {
70
+ const loading = await renderFrame(() => (
71
+ <DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
72
+ ))
73
+ const failed = await renderFrame(() => (
74
+ <DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
75
+ ))
76
+ const empty = await renderFrame(() => (
77
+ <DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
78
+ ))
79
+
80
+ expect(loading).not.toContain("Loading diff...")
81
+ expect(loading).not.toContain("No files")
82
+ expect(failed).not.toContain("Failed to load diff")
83
+ expect(failed).not.toContain("No files")
84
+ expect(empty).toContain("No files")
85
+ })
86
+
87
+ test("does not render text markers for highlighted rows", async () => {
88
+ const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
89
+ const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
90
+
91
+ const focused = visibleLines(
92
+ await renderFrame(() => (
93
+ <DiffViewerFileTree
94
+ width={32}
95
+ files={files}
96
+ loading={false}
97
+ error={undefined}
98
+ theme={theme}
99
+ focused
100
+ highlightedNode={src.id}
101
+ />
102
+ )),
103
+ )
104
+ const unfocused = visibleLines(
105
+ await renderFrame(() => (
106
+ <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
107
+ )),
108
+ )
109
+
110
+ expect(focused).toContain("▾ src/config")
111
+ expect(unfocused).toContain("▾ src/config")
112
+ expect(focused.some((line) => line.includes("*"))).toBe(false)
113
+ expect(unfocused.some((line) => line.includes("*"))).toBe(false)
114
+ })
115
+
116
+ test("renders collapsed and expanded directory rows", async () => {
117
+ const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
118
+ const tree = buildFileTree(files)
119
+ const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
120
+ const collapsed = allExpandedFileTreeDirectories(tree)
121
+ collapsed.delete(src.id)
122
+
123
+ expect(
124
+ visibleLines(
125
+ await renderFrame(() => (
126
+ <DiffViewerFileTree
127
+ width={32}
128
+ files={files}
129
+ loading={false}
130
+ error={undefined}
131
+ theme={theme}
132
+ expandedNodes={collapsed}
133
+ />
134
+ )),
135
+ ),
136
+ ).toEqual(["▸ src/config"])
137
+
138
+ expect(
139
+ visibleLines(
140
+ await renderFrame(() => (
141
+ <DiffViewerFileTree
142
+ files={files}
143
+ width={32}
144
+ loading={false}
145
+ error={undefined}
146
+ theme={theme}
147
+ expandedNodes={allExpandedFileTreeDirectories(tree)}
148
+ />
149
+ )),
150
+ ),
151
+ ).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
152
+ })
153
+ })
154
+
155
+ async function renderFrame(component: () => JSX.Element) {
156
+ const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
157
+ try {
158
+ await renderOnceSettled(app)
159
+ return await captureSettledFrame(app)
160
+ } finally {
161
+ app.renderer.destroy()
162
+ }
163
+ }
164
+
165
+ async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
166
+ await app.renderOnce()
167
+ await new Promise((resolve) => setTimeout(resolve, 25))
168
+ await app.renderOnce()
169
+ }
170
+
171
+ async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
172
+ for (let attempt = 0; attempt < 5; attempt++) {
173
+ const frame = app.captureCharFrame()
174
+ if (frame.trim().length > 0) return frame
175
+ await new Promise((resolve) => setTimeout(resolve, 25))
176
+ await app.renderOnce()
177
+ }
178
+ return app.captureCharFrame()
179
+ }
180
+
181
+ function withTheme(component: () => JSX.Element) {
182
+ return (
183
+ <TestTuiContexts>
184
+ <TuiConfigProvider config={createTuiResolvedConfig()}>
185
+ <KVProvider>
186
+ <ThemeProvider mode="dark">{component()}</ThemeProvider>
187
+ </KVProvider>
188
+ </TuiConfigProvider>
189
+ </TestTuiContexts>
190
+ )
191
+ }
192
+
193
+ function visibleLines(frame: string) {
194
+ return frame
195
+ .split("\n")
196
+ .map((line) => line.trimEnd())
197
+ .map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
198
+ .map((line) => (line.startsWith(" ") ? line.slice(1) : line))
199
+ .filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
200
+ }
@@ -0,0 +1,268 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { expect, test } from "bun:test"
3
+ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
4
+ import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
5
+ import { testRender, useRenderer } from "@opentui/solid"
6
+ import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@neurocode-ai/plugin/tui"
7
+ import type { Session } from "@neurocode-ai/sdk/v2"
8
+ import { KVProvider } from "../../../src/context/kv"
9
+ import { ThemeProvider } from "../../../src/context/theme"
10
+ import { TuiConfigProvider } from "../../../src/config"
11
+ import { TuiKeybind } from "../../../src/config/keybind"
12
+ import { OpencodeKeymapProvider } from "../../../src/keymap"
13
+ import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
14
+ import { createTuiPluginApi } from "../../fixture/tui-plugin"
15
+ import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
16
+ import { TestTuiContexts } from "../../fixture/tui-environment"
17
+
18
+ test("closing the diff viewer returns to the route it opened from", async () => {
19
+ const viewer = await renderDiffViewer([])
20
+ try {
21
+ expect(viewer.current()).toEqual({
22
+ name: "diff",
23
+ params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
24
+ })
25
+ expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
26
+
27
+ expect(viewer.commands.has("diff.close")).toBe(true)
28
+ viewer.commands.get("diff.close")!.run?.({} as never)
29
+ expect(viewer.current()).toEqual(startRoute)
30
+ } finally {
31
+ viewer.app.renderer.destroy()
32
+ }
33
+ })
34
+
35
+ test("brackets navigate diff hunks", async () => {
36
+ const viewer = await renderDiffViewer(
37
+ [
38
+ {
39
+ file: "src/file.ts",
40
+ additions: 3,
41
+ deletions: 3,
42
+ status: "modified",
43
+ patch: `--- a/src/file.ts
44
+ +++ b/src/file.ts
45
+ @@ -1,3 +1,3 @@
46
+ const first = true
47
+ -const oldFirst = true
48
+ +const newFirst = true
49
+ const afterFirst = true
50
+ @@ -20,3 +20,3 @@
51
+ const second = true
52
+ -const oldSecond = true
53
+ +const newSecond = true
54
+ const afterSecond = true
55
+ @@ -40,3 +40,3 @@
56
+ const third = true
57
+ -const oldThird = true
58
+ +const newThird = true
59
+ const afterThird = true`,
60
+ },
61
+ ],
62
+ 12,
63
+ )
64
+ try {
65
+ await viewer.app.waitForFrame((frame) => frame.includes("const first"))
66
+ await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
67
+ await viewer.app.flush()
68
+ const scroll = findScrollBox(viewer.app.renderer.root)!
69
+ const initial = scroll.scrollTop
70
+
71
+ expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
72
+ expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
73
+
74
+ viewer.commands.get("diff.next_hunk")!.run?.({} as never)
75
+ await viewer.app.renderOnce()
76
+ const first = scroll.scrollTop
77
+ expect(first).toBeGreaterThan(initial)
78
+
79
+ viewer.commands.get("diff.next_hunk")!.run?.({} as never)
80
+ await viewer.app.renderOnce()
81
+ const second = scroll.scrollTop
82
+ expect(second).toBeGreaterThan(first)
83
+
84
+ viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
85
+ await viewer.app.renderOnce()
86
+ expect(scroll.scrollTop).toBe(first)
87
+
88
+ viewer.commands.get("diff.next_hunk")!.run?.({} as never)
89
+ await viewer.app.renderOnce()
90
+ expect(scroll.scrollTop).toBe(second)
91
+
92
+ scroll.scrollTo(initial)
93
+ viewer.commands.get("diff.next_hunk")!.run?.({} as never)
94
+ await viewer.app.renderOnce()
95
+ expect(scroll.scrollTop).toBe(first)
96
+ } finally {
97
+ viewer.app.renderer.destroy()
98
+ }
99
+ })
100
+
101
+ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) {
102
+ const commands = new Map<
103
+ string,
104
+ NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
105
+ >()
106
+ let current = initialRoute ?? startRoute
107
+ let renderDiff: TuiRouteDefinition["render"] | undefined
108
+ let vcsDiffInput: unknown
109
+ let sessionDiffInput: unknown
110
+ const config = createTuiResolvedConfig()
111
+ function Harness() {
112
+ const renderer = useRenderer()
113
+ const keymap = createDefaultOpenTuiKeymap(renderer)
114
+ const registerLayer = keymap.registerLayer.bind(keymap)
115
+ keymap.registerLayer = (layer) => {
116
+ layer.commands?.forEach((command) => commands.set(command.name, command))
117
+ return registerLayer(layer)
118
+ }
119
+ const base = createTuiPluginApi({
120
+ keymap,
121
+ client: {
122
+ vcs: {
123
+ diff: async (input: unknown) => {
124
+ vcsDiffInput = input
125
+ return { data: vcsDiff }
126
+ },
127
+ },
128
+ session: {
129
+ diff: async (input: unknown) => {
130
+ sessionDiffInput = input
131
+ return { data: [] }
132
+ },
133
+ },
134
+ } as unknown as TuiPluginApi["client"],
135
+ state: {
136
+ session: {
137
+ get: () => session,
138
+ },
139
+ },
140
+ })
141
+ const api = {
142
+ ...base,
143
+ route: {
144
+ register(routes) {
145
+ renderDiff = routes.find((route) => route.name === "diff")?.render
146
+ return () => {}
147
+ },
148
+ navigate(name, params) {
149
+ current = params ? { name, params } : { name }
150
+ },
151
+ get current() {
152
+ return current
153
+ },
154
+ },
155
+ } satisfies TuiPluginApi
156
+
157
+ void diffViewerPlugin.tui(api, undefined, pluginMeta)
158
+ if (!initialRoute) commands.get("diff.open")?.run?.({} as never)
159
+
160
+ return (
161
+ <TestTuiContexts>
162
+ <OpencodeKeymapProvider keymap={keymap}>
163
+ <TuiConfigProvider config={config}>
164
+ <KVProvider>
165
+ <ThemeProvider mode="dark">
166
+ {renderDiff?.({ params: "params" in current ? current.params : undefined })}
167
+ </ThemeProvider>
168
+ </KVProvider>
169
+ </TuiConfigProvider>
170
+ </OpencodeKeymapProvider>
171
+ </TestTuiContexts>
172
+ )
173
+ }
174
+
175
+ const app = await testRender(() => <Harness />, { width: 80, height })
176
+ await waitForCommand(app, commands, "diff.close")
177
+ return {
178
+ app,
179
+ commands,
180
+ current: () => current,
181
+ vcsDiffInput: () => vcsDiffInput,
182
+ sessionDiffInput: () => sessionDiffInput,
183
+ }
184
+ }
185
+
186
+ const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
187
+
188
+ function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
189
+ if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
190
+ return root.getChildren().map(findScrollBox).find(Boolean)
191
+ }
192
+
193
+ function containsDiff(root: Renderable): boolean {
194
+ if (root instanceof DiffRenderable) return true
195
+ return root.getChildren().some(containsDiff)
196
+ }
197
+
198
+ const session = {
199
+ id: "session-1",
200
+ slug: "session-1",
201
+ projectID: "project-1",
202
+ directory: "/repo/session",
203
+ title: "Session",
204
+ version: "1",
205
+ time: {
206
+ created: 0,
207
+ updated: 0,
208
+ },
209
+ } satisfies Session
210
+
211
+ test("branch diff source requests branch VCS diff", async () => {
212
+ const viewer = await renderDiffViewer([], 20, {
213
+ name: "diff",
214
+ params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
215
+ })
216
+ try {
217
+ expect(viewer.current()).toEqual({
218
+ name: "diff",
219
+ params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
220
+ })
221
+ expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "branch", context: 12 })
222
+ expect(viewer.sessionDiffInput()).toBeUndefined()
223
+ } finally {
224
+ viewer.app.renderer.destroy()
225
+ }
226
+ })
227
+
228
+ test("last-turn diff source requests session diff", async () => {
229
+ const viewer = await renderDiffViewer([], 20, {
230
+ name: "diff",
231
+ params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
232
+ })
233
+ try {
234
+ expect(viewer.current()).toEqual({
235
+ name: "diff",
236
+ params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
237
+ })
238
+ expect(viewer.sessionDiffInput()).toEqual({ sessionID: "session-1", messageID: "message-1" })
239
+ expect(viewer.vcsDiffInput()).toBeUndefined()
240
+ } finally {
241
+ viewer.app.renderer.destroy()
242
+ }
243
+ })
244
+
245
+ async function waitForCommand(
246
+ app: Awaited<ReturnType<typeof testRender>>,
247
+ commands: Map<string, unknown>,
248
+ command: string,
249
+ ) {
250
+ for (let attempt = 0; attempt < 10; attempt++) {
251
+ await app.renderOnce()
252
+ if (commands.has(command)) return
253
+ await new Promise((resolve) => setTimeout(resolve, 25))
254
+ }
255
+ }
256
+
257
+ const pluginMeta = {
258
+ id: "diff-viewer",
259
+ source: "internal",
260
+ spec: "diff-viewer",
261
+ target: "diff-viewer",
262
+ first_time: 0,
263
+ last_time: 0,
264
+ time_changed: 0,
265
+ load_count: 1,
266
+ fingerprint: "test",
267
+ state: "same",
268
+ } satisfies TuiPluginMeta