@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,1077 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@neurocode-ai/plugin/tui"
3
+ import type { SnapshotFileDiff, VcsFileDiff } from "@neurocode-ai/sdk/v2"
4
+ import {
5
+ TextAttributes,
6
+ type BorderSides,
7
+ type BoxRenderable,
8
+ type DiffRenderable,
9
+ type ScrollBoxRenderable,
10
+ } from "@opentui/core"
11
+ import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
12
+ import { useBindings, useCommandShortcut } from "../../keymap"
13
+ import { useTheme } from "../../context/theme"
14
+ import { useTerminalDimensions } from "@opentui/solid"
15
+ import path from "path"
16
+ import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
17
+ import { DiffViewerFileTree } from "./diff-viewer-file-tree"
18
+ import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
19
+ import { DialogSelect } from "../../ui/dialog-select"
20
+ import { getScrollAcceleration } from "../../util/scroll"
21
+ import {
22
+ allExpandedFileTreeDirectories,
23
+ buildFileTree,
24
+ fileTreeFileSelection,
25
+ type FileTreeRow,
26
+ flattenFileTree,
27
+ moveFileTreeSelection,
28
+ moveFileTreeSelectionToFirstChild,
29
+ moveFileTreeSelectionToParent,
30
+ movePatchFileIndex,
31
+ orderedPatchFileIndexes,
32
+ setFileTreeDirectoryExpanded,
33
+ showDiffViewerFileTree,
34
+ singlePatchFileIndex,
35
+ toggleFileTreeDirectory,
36
+ } from "./diff-viewer-file-tree-utils"
37
+
38
+ const ROUTE = "diff"
39
+ const MIN_SPLIT_WIDTH = 100
40
+ const FILE_TREE_WIDTH = 32
41
+ const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
42
+ const VCS_DIFF_CONTEXT_LINES = 12
43
+ const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
44
+ const KV_SINGLE_PATCH = "diff_viewer_single_patch"
45
+ const KV_VIEW = "diff_viewer_view"
46
+ type DiffMode = "git" | "branch" | "last-turn"
47
+ type DiffViewerFocus = "patches" | "files"
48
+ type DiffView = "split" | "unified"
49
+ type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
50
+
51
+ type DiffFile = {
52
+ readonly file: string
53
+ readonly patch?: string
54
+ readonly additions: number
55
+ readonly deletions: number
56
+ readonly status: "added" | "deleted" | "modified"
57
+ }
58
+
59
+ const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] =>
60
+ diffs.flatMap((item) =>
61
+ item.file
62
+ ? [
63
+ {
64
+ file: item.file,
65
+ patch: item.patch,
66
+ additions: item.additions,
67
+ deletions: item.deletions,
68
+ status: item.status ?? "modified",
69
+ } satisfies DiffFile,
70
+ ]
71
+ : [],
72
+ )
73
+
74
+ function filetype(input?: string) {
75
+ if (!input) return "none"
76
+ const language = LANGUAGE_EXTENSIONS[path.extname(input)]
77
+ if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
78
+ return language
79
+ }
80
+
81
+ function storedView(value: unknown): DiffView | undefined {
82
+ if (value === "split" || value === "unified") return value
83
+ }
84
+
85
+ function diffSourceLabel(mode: DiffMode) {
86
+ if (mode === "last-turn") return "last turn"
87
+ if (mode === "branch") return "main branch"
88
+ return "working tree"
89
+ }
90
+
91
+ function DiffViewer(props: { api: TuiPluginApi }) {
92
+ const dimensions = useTerminalDimensions()
93
+ const themeState = useTheme()
94
+ const theme = () => props.api.theme.current
95
+ const params = () =>
96
+ ("params" in props.api.route.current ? props.api.route.current.params : undefined) as
97
+ | {
98
+ mode?: DiffMode
99
+ sessionID?: string
100
+ messageID?: string
101
+ returnRoute?: TuiRouteCurrent
102
+ }
103
+ | undefined
104
+ const mode = () => params()?.mode ?? "git"
105
+ const diffInput = createMemo(() => {
106
+ const sessionID = params()?.sessionID
107
+ return {
108
+ mode: mode(),
109
+ sessionID,
110
+ messageID: params()?.messageID,
111
+ directory: sessionID ? props.api.state.session.get(sessionID)?.directory : undefined,
112
+ }
113
+ })
114
+ const [diff] = createResource(diffInput, async (input) => {
115
+ if (input.mode === "last-turn") {
116
+ const sessionID = input.sessionID
117
+ if (!sessionID) return []
118
+ const result = await props.api.client.session.diff(
119
+ { sessionID, messageID: input.messageID },
120
+ { throwOnError: true },
121
+ )
122
+ return normalizeDiffs(result.data ?? [])
123
+ }
124
+
125
+ const result = await props.api.client.vcs.diff(
126
+ { directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES },
127
+ { throwOnError: true },
128
+ )
129
+ return normalizeDiffs(result.data ?? [])
130
+ })
131
+ const files = createMemo(() => diff() ?? [])
132
+ const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
133
+ const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
134
+ props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
135
+ )
136
+ const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
137
+ const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
138
+ const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
139
+ const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
140
+ const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
141
+ const defaultView = createMemo(() => {
142
+ if (props.api.tuiConfig.diff_style === "stacked") return "unified"
143
+ return splitAvailable() ? "split" : "unified"
144
+ })
145
+ const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
146
+ const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
147
+ const fileTree = createMemo(() => buildFileTree(files()))
148
+ const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
149
+ const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
150
+ const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
151
+ const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
152
+ const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
153
+ const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
154
+ const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig))
155
+ const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
156
+ const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
157
+ const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
158
+ const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
159
+ const nextHunkShortcut = useCommandShortcut("diff.next_hunk")
160
+ const previousHunkShortcut = useCommandShortcut("diff.previous_hunk")
161
+ const nextFileShortcut = useCommandShortcut("diff.next_file")
162
+ const previousFileShortcut = useCommandShortcut("diff.previous_file")
163
+ const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree")
164
+ const singlePatchShortcut = useCommandShortcut("diff.single_patch")
165
+ const switchSourceShortcut = useCommandShortcut("diff.switch_source")
166
+ const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
167
+ const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
168
+ const helpShortcut = useCommandShortcut("diff.help")
169
+ let scroll: ScrollBoxRenderable | undefined
170
+ const patchNodeByFileIndex = new Map<number, BoxRenderable>()
171
+ const diffNodeByFileIndex = new Map<number, DiffRenderable>()
172
+ const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
173
+ const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
174
+ const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
175
+
176
+ onCleanup(() => props.api.ui.dialog.clear())
177
+
178
+ createEffect(() => {
179
+ setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
180
+ setHighlightedFileNode(undefined)
181
+ setLastHighlightedFileNode(undefined)
182
+ setActivePatchFileIndex(undefined)
183
+ setSelectedFileIndex(undefined)
184
+ setSelectedHunk(undefined)
185
+ setReviewedFileNames(new Set<string>())
186
+ })
187
+
188
+ const ensureHighlightedFileNode = () => {
189
+ const highlighted = highlightedFileNode()
190
+ if (highlighted !== undefined && fileRows().some((row) => row.id === highlighted)) return
191
+ const lastHighlighted = lastHighlightedFileNode()
192
+ const next =
193
+ lastHighlighted !== undefined && fileRows().some((row) => row.id === lastHighlighted)
194
+ ? lastHighlighted
195
+ : fileRows().find((row) => row.fileIndex !== undefined)?.id
196
+ setHighlightedFileNode(next)
197
+ }
198
+
199
+ const setHighlighted = (node: number | undefined) => {
200
+ setHighlightedFileNode(node)
201
+ if (node !== undefined) setLastHighlightedFileNode(node)
202
+ }
203
+
204
+ const moveFileSelection = (offset: number) =>
205
+ setHighlighted(moveFileTreeSelection(fileRows(), highlightedFileNode(), offset))
206
+
207
+ const clearFileTreePatchState = () => {
208
+ setHighlightedFileNode(undefined)
209
+ setActivePatchFileIndex(undefined)
210
+ setSelectedHunk(undefined)
211
+ }
212
+
213
+ const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
214
+ requestAnimationFrame(() => {
215
+ if (!scroll) return
216
+ const scrollDelta = patchNode.y - scroll.viewport.y
217
+ const contentY = scroll.scrollTop + scrollDelta
218
+ const offset = contentY === 0 ? 0 : 1
219
+ scroll.scrollBy(scrollDelta + offset)
220
+ })
221
+ }
222
+
223
+ const revealFileTreeFile = (fileIndex: number) => {
224
+ const selection = fileTreeFileSelection(fileTree(), fileIndex)
225
+ if (!selection) return
226
+ setExpandedFileNodes((expanded) => {
227
+ const next = new Set(expanded)
228
+ selection.expandedNodes.forEach((node) => next.add(node))
229
+ return next
230
+ })
231
+ setHighlighted(selection.highlightedNode)
232
+ }
233
+
234
+ const selectPatchFile = (fileIndex: number) => {
235
+ revealFileTreeFile(fileIndex)
236
+ setActivePatchFileIndex(fileIndex)
237
+ setSelectedFileIndex(fileIndex)
238
+ }
239
+
240
+ const scrollToFileIndex = (fileIndex: number | undefined) => {
241
+ if (fileIndex === undefined) return
242
+ selectPatchFile(fileIndex)
243
+ const patchNode = patchNodeByFileIndex.get(fileIndex)
244
+ if (patchNode) scrollPatchNodeToTop(patchNode)
245
+ }
246
+
247
+ const jumpToFileIndex = (fileIndex: number | undefined) => {
248
+ if (fileIndex === undefined) return
249
+ setSelectedHunk(undefined)
250
+ scrollToFileIndex(fileIndex)
251
+ }
252
+
253
+ const currentPatchFileIndex = () => {
254
+ if (!scroll) return undefined
255
+ const viewportContentY = scroll.scrollTop + 1
256
+ const entries = patchFileIndexes()
257
+ .map((fileIndex) => ({
258
+ fileIndex,
259
+ node: patchNodeByFileIndex.get(fileIndex),
260
+ }))
261
+ .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
262
+ .map((entry) => ({
263
+ ...entry,
264
+ contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y,
265
+ }))
266
+ .sort((left, right) => left.contentY - right.contentY)
267
+ return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex
268
+ }
269
+
270
+ const jumpRelativePatchFile = (offset: number) => {
271
+ setSelectedHunk(undefined)
272
+ const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
273
+ if (singlePatch()) {
274
+ if (next === undefined) return
275
+ selectPatchFile(next)
276
+ scrollSinglePatchToTop()
277
+ return
278
+ }
279
+ scrollToFileIndex(next)
280
+ }
281
+
282
+ const jumpRelativeHunk = (offset: -1 | 1) => {
283
+ const patchScroll = scroll
284
+ if (!patchScroll) return
285
+ const hunks = visiblePatchFiles()
286
+ .flatMap((entry) => {
287
+ const node = diffNodeByFileIndex.get(entry.fileIndex)
288
+ if (!node || node.isDestroyed) return []
289
+ const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y
290
+ return node.diff
291
+ .split("\n")
292
+ .flatMap((line, row) => (line.startsWith("@@") ? [row] : []))
293
+ .map((row, hunkIndex) => ({
294
+ fileIndex: entry.fileIndex,
295
+ hunkIndex,
296
+ contentY: contentY + row,
297
+ }))
298
+ })
299
+ .sort((left, right) => left.contentY - right.contentY)
300
+ const selected = selectedHunk()
301
+ const selectedIndex =
302
+ selected?.scrollTop === patchScroll.scrollTop
303
+ ? hunks.findIndex((hunk) => hunk.fileIndex === selected.fileIndex && hunk.hunkIndex === selected.hunkIndex)
304
+ : -1
305
+ const next =
306
+ selectedIndex !== -1
307
+ ? hunks[selectedIndex + offset]
308
+ : offset === 1
309
+ ? hunks.find((hunk) => hunk.contentY > patchScroll.scrollTop)
310
+ : hunks.findLast((hunk) => hunk.contentY < patchScroll.scrollTop)
311
+ if (!next) return
312
+ selectPatchFile(next.fileIndex)
313
+ patchScroll.scrollTo(next.contentY)
314
+ setSelectedHunk({ fileIndex: next.fileIndex, hunkIndex: next.hunkIndex, scrollTop: patchScroll.scrollTop })
315
+ }
316
+
317
+ const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
318
+ const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
319
+ const visiblePatchFiles = createMemo(() => {
320
+ if (!singlePatch()) {
321
+ return patchFileIndexes().flatMap((fileIndex) => {
322
+ const file = files()[fileIndex]
323
+ return file ? [{ file, fileIndex }] : []
324
+ })
325
+ }
326
+ const fileIndex = singlePatchFileIndex(
327
+ selectedFileIndex(),
328
+ activePatchFileIndex(),
329
+ currentPatchFileIndex(),
330
+ firstPatchFileIndex(),
331
+ )
332
+ const file = fileIndex === undefined ? undefined : files()[fileIndex]
333
+ return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
334
+ })
335
+
336
+ const ensureHighlightedPatchFile = () => {
337
+ const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
338
+ if (fileIndex === undefined) return
339
+ selectPatchFile(fileIndex)
340
+ }
341
+
342
+ const scrollToPatchFileIndexAfterRender = (fileIndex: number) => {
343
+ setPendingPatchScrollFileIndex(fileIndex)
344
+ requestAnimationFrame(() => {
345
+ const patchNode = patchNodeByFileIndex.get(fileIndex)
346
+ if (patchNode) scrollPatchNodeToTop(patchNode)
347
+ requestAnimationFrame(() => {
348
+ const patchNode = patchNodeByFileIndex.get(fileIndex)
349
+ if (patchNode) scrollPatchNodeToTop(patchNode)
350
+ setPendingPatchScrollFileIndex(undefined)
351
+ })
352
+ })
353
+ }
354
+
355
+ const scrollSinglePatchToTop = () => {
356
+ requestAnimationFrame(() => {
357
+ scroll?.scrollTo(0)
358
+ requestAnimationFrame(() => scroll?.scrollTo(0))
359
+ })
360
+ }
361
+
362
+ const measurePatchFiller = () => {
363
+ requestAnimationFrame(() => {
364
+ if (!scroll) return
365
+ const entries = visiblePatchFiles()
366
+ .map((entry) => patchNodeByFileIndex.get(entry.fileIndex))
367
+ .filter((node): node is BoxRenderable => Boolean(node))
368
+ if (entries.length === 0) {
369
+ setPatchFillerHeight(0)
370
+ return
371
+ }
372
+ const contentHeight = Math.max(
373
+ ...entries.map((node) => scroll!.scrollTop + node.y - scroll!.viewport.y + node.height),
374
+ )
375
+ setPatchFillerHeight(Math.max(0, scroll.viewport.height - contentHeight))
376
+ })
377
+ }
378
+
379
+ const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
380
+ patchNodeByFileIndex.set(fileIndex, element)
381
+ measurePatchFiller()
382
+ if (pendingPatchScrollFileIndex() !== fileIndex) return
383
+ requestAnimationFrame(() => {
384
+ scrollPatchNodeToTop(element)
385
+ requestAnimationFrame(() => {
386
+ scrollPatchNodeToTop(element)
387
+ setPendingPatchScrollFileIndex(undefined)
388
+ })
389
+ })
390
+ }
391
+
392
+ createEffect(() => {
393
+ visiblePatchFiles()
394
+ dimensions()
395
+ view()
396
+ measurePatchFiller()
397
+ })
398
+
399
+ const toggleSelectedFileTreeRow = () => {
400
+ const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
401
+ if (highlighted?.fileIndex !== undefined) {
402
+ jumpToFileIndex(highlighted.fileIndex)
403
+ return
404
+ }
405
+ setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
406
+ }
407
+
408
+ const clickFileTreeRow = (row: FileTreeRow) => {
409
+ setFocus("files")
410
+ setHighlighted(row.id)
411
+ if (row.fileIndex !== undefined) {
412
+ jumpToFileIndex(row.fileIndex)
413
+ return
414
+ }
415
+ setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, row.id))
416
+ }
417
+
418
+ const toggleSelectedFileReviewed = () => {
419
+ const fileIndex =
420
+ focus() === "files"
421
+ ? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
422
+ : (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
423
+ const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
424
+ if (!file) return
425
+ setReviewedFileNames((reviewed) => {
426
+ const next = new Set(reviewed)
427
+ if (next.has(file)) next.delete(file)
428
+ else next.add(file)
429
+ return next
430
+ })
431
+ }
432
+
433
+ const commands = [
434
+ {
435
+ name: "diff.close",
436
+ title: "Close diff viewer",
437
+ category: "VCS",
438
+ run() {
439
+ const returnRoute = params()?.returnRoute
440
+ props.api.ui.dialog.clear()
441
+
442
+ props.api.route.navigate(
443
+ returnRoute?.name ?? "home",
444
+ returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
445
+ )
446
+ },
447
+ },
448
+ {
449
+ name: "diff.down",
450
+ title: "Move diff viewer down",
451
+ category: "VCS",
452
+ run: focusRunner({
453
+ files() {
454
+ moveFileSelection(1)
455
+ },
456
+ patches() {
457
+ clearFileTreePatchState()
458
+ scroll?.scrollBy(1)
459
+ },
460
+ }),
461
+ },
462
+ {
463
+ name: "diff.up",
464
+ title: "Move diff viewer up",
465
+ category: "VCS",
466
+ run: focusRunner({
467
+ files() {
468
+ moveFileSelection(-1)
469
+ },
470
+ patches() {
471
+ clearFileTreePatchState()
472
+ scroll?.scrollBy(-1)
473
+ },
474
+ }),
475
+ },
476
+ {
477
+ name: "diff.page.down",
478
+ title: "Page diff viewer down",
479
+ category: "VCS",
480
+ run: focusRunner({
481
+ files() {
482
+ moveFileSelection(8)
483
+ },
484
+ patches() {
485
+ clearFileTreePatchState()
486
+ if (scroll) scroll.scrollBy(scroll.height)
487
+ },
488
+ }),
489
+ },
490
+ {
491
+ name: "diff.page.up",
492
+ title: "Page diff viewer up",
493
+ category: "VCS",
494
+ run: focusRunner({
495
+ files() {
496
+ moveFileSelection(-8)
497
+ },
498
+ patches() {
499
+ clearFileTreePatchState()
500
+ if (scroll) scroll.scrollBy(-scroll.height)
501
+ },
502
+ }),
503
+ },
504
+ {
505
+ name: "diff.toggle",
506
+ title: "Toggle diff viewer item",
507
+ category: "VCS",
508
+ run: focusRunner({
509
+ files() {
510
+ toggleSelectedFileTreeRow()
511
+ },
512
+ patches() {},
513
+ }),
514
+ },
515
+ {
516
+ name: "diff.expand",
517
+ title: "Expand diff viewer item",
518
+ category: "VCS",
519
+ run: focusRunner({
520
+ files() {
521
+ const highlighted = highlightedFileNode()
522
+ if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
523
+ setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
524
+ return
525
+ }
526
+ setExpandedFileNodes((expanded) =>
527
+ setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
528
+ )
529
+ },
530
+ patches() {},
531
+ }),
532
+ },
533
+ {
534
+ name: "diff.expand_all",
535
+ title: "Expand all diff viewer folders",
536
+ category: "VCS",
537
+ run: focusRunner({
538
+ files() {
539
+ setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
540
+ },
541
+ patches() {},
542
+ }),
543
+ },
544
+ {
545
+ name: "diff.collapse",
546
+ title: "Collapse diff viewer item",
547
+ category: "VCS",
548
+ run: focusRunner({
549
+ files() {
550
+ const highlighted = highlightedFileNode()
551
+ const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
552
+ if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
553
+ setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
554
+ return
555
+ }
556
+ setExpandedFileNodes((expanded) =>
557
+ setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
558
+ )
559
+ },
560
+ patches() {},
561
+ }),
562
+ },
563
+ {
564
+ name: "diff.next_hunk",
565
+ title: "Jump to next diff hunk",
566
+ category: "VCS",
567
+ run() {
568
+ jumpRelativeHunk(1)
569
+ },
570
+ },
571
+ {
572
+ name: "diff.previous_hunk",
573
+ title: "Jump to previous diff hunk",
574
+ category: "VCS",
575
+ run() {
576
+ jumpRelativeHunk(-1)
577
+ },
578
+ },
579
+ {
580
+ name: "diff.next_file",
581
+ title: "Jump to next diff file",
582
+ category: "VCS",
583
+ run() {
584
+ jumpRelativePatchFile(1)
585
+ },
586
+ },
587
+ {
588
+ name: "diff.previous_file",
589
+ title: "Jump to previous diff file",
590
+ category: "VCS",
591
+ run() {
592
+ jumpRelativePatchFile(-1)
593
+ },
594
+ },
595
+ {
596
+ name: "diff.mark_reviewed",
597
+ title: "Toggle selected diff file reviewed",
598
+ category: "VCS",
599
+ run() {
600
+ toggleSelectedFileReviewed()
601
+ },
602
+ },
603
+ {
604
+ name: "diff.switch_focus",
605
+ title: "Switch diff viewer focus",
606
+ category: "VCS",
607
+ run() {
608
+ if (!showFileTree()) return
609
+ setFocus((current) => {
610
+ if (current === "files") return "patches"
611
+ ensureHighlightedFileNode()
612
+ return "files"
613
+ })
614
+ },
615
+ },
616
+ {
617
+ name: "diff.toggle_file_tree",
618
+ title: "Toggle diff viewer file tree",
619
+ category: "VCS",
620
+ run() {
621
+ const next = !fileTreeEnabled()
622
+ if (!next) setFocus("patches")
623
+ setFileTreeEnabled(next)
624
+ props.api.kv.set(KV_SHOW_FILE_TREE, next)
625
+ },
626
+ },
627
+ {
628
+ name: "diff.single_patch",
629
+ title: "Toggle single patch view",
630
+ category: "VCS",
631
+ run() {
632
+ setSelectedHunk(undefined)
633
+ if (!singlePatch()) {
634
+ ensureHighlightedPatchFile()
635
+ setSinglePatch(true)
636
+ props.api.kv.set(KV_SINGLE_PATCH, true)
637
+ scrollSinglePatchToTop()
638
+ return
639
+ }
640
+ const fileIndex =
641
+ visiblePatchFiles()[0]?.fileIndex ??
642
+ singlePatchFileIndex(
643
+ selectedFileIndex(),
644
+ activePatchFileIndex(),
645
+ currentPatchFileIndex(),
646
+ firstPatchFileIndex(),
647
+ )
648
+ if (fileIndex !== undefined) selectPatchFile(fileIndex)
649
+ setSinglePatch(false)
650
+ props.api.kv.set(KV_SINGLE_PATCH, false)
651
+ if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
652
+ },
653
+ },
654
+ {
655
+ name: "diff.switch_source",
656
+ title: "Switch diff viewer source",
657
+ category: "VCS",
658
+ run() {
659
+ openSwitchDiffDialog()
660
+ },
661
+ },
662
+ {
663
+ name: "diff.toggle_view",
664
+ title: "Toggle diff viewer split or unified view",
665
+ category: "VCS",
666
+ run() {
667
+ if (!splitAvailable()) return
668
+ setSelectedHunk(undefined)
669
+ const next = view() === "split" ? "unified" : "split"
670
+ setViewOverride(next)
671
+ props.api.kv.set(KV_VIEW, next)
672
+ },
673
+ },
674
+ {
675
+ name: "diff.help",
676
+ title: "Show more diff viewer shortcuts",
677
+ category: "VCS",
678
+ run() {
679
+ openHelpDialog()
680
+ },
681
+ },
682
+ ]
683
+
684
+ const switchDiffOptions = createMemo(() => {
685
+ const vcs = props.api.state.vcs
686
+ return [
687
+ {
688
+ title: "Working tree",
689
+ value: "git" as const,
690
+ description: "Show current git changes",
691
+ },
692
+ ...(vcs?.branch && vcs.default_branch && vcs.branch !== vcs.default_branch
693
+ ? [
694
+ {
695
+ title: "Main branch",
696
+ value: "branch" as const,
697
+ description: "Show changes compared to main branch",
698
+ },
699
+ ]
700
+ : []),
701
+ {
702
+ title: "Last turn",
703
+ value: "last-turn" as const,
704
+ description: "Show changes from the last assistant turn",
705
+ },
706
+ ]
707
+ })
708
+
709
+ const openSwitchDiffDialog = () => {
710
+ props.api.ui.dialog.replace(() => (
711
+ <DialogSelect
712
+ title="Switch source"
713
+ skipFilter={true}
714
+ renderFilter={false}
715
+ current={mode()}
716
+ options={switchDiffOptions().map((option) => ({
717
+ ...option,
718
+ onSelect(dialog) {
719
+ dialog.clear()
720
+ props.api.route.navigate(ROUTE, {
721
+ mode: option.value,
722
+ sessionID: params()?.sessionID,
723
+ messageID: params()?.messageID,
724
+ returnRoute: params()?.returnRoute,
725
+ })
726
+ },
727
+ }))}
728
+ />
729
+ ))
730
+ }
731
+
732
+ const openHelpDialog = () => {
733
+ props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
734
+ props.api.ui.dialog.setSize("large")
735
+ }
736
+
737
+ useBindings(() => ({
738
+ commands,
739
+ bindings: [
740
+ { key: "j,down", cmd: "diff.down", desc: "Move diff viewer down" },
741
+ { key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
742
+ { key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
743
+ { key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
744
+ { key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
745
+ ...props.api.tuiConfig.keybinds.gather(
746
+ "diff",
747
+ commands.map((command) => command.name),
748
+ ),
749
+ ],
750
+ }))
751
+
752
+ return (
753
+ <box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
754
+ <PanelGroup axis="y" width="100%" height="100%">
755
+ <Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
756
+ <text fg={theme().text}>Diff </text>
757
+ <text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
758
+ <box flexGrow={1} />
759
+ <text fg={theme().textMuted}>
760
+ {files().length} {files().length === 1 ? "file" : "files"}
761
+ </text>
762
+ </Panel>
763
+
764
+ <box flexGrow={1} minHeight={0}>
765
+ <Switch>
766
+ <Match when={diff.loading}>
767
+ <Separator axis="x" />
768
+ <box flexGrow={1} paddingLeft={1}>
769
+ <text fg={theme().textMuted}>Loading diff...</text>
770
+ </box>
771
+ </Match>
772
+ <Match when={!diff.loading && files().length === 0}>
773
+ <Separator axis="x" />
774
+ <box flexGrow={1} paddingLeft={1}>
775
+ <text fg={theme().textMuted}>No diff!</text>
776
+ </box>
777
+ </Match>
778
+ <Match when={!diff.loading && diff.error}>
779
+ <Separator axis="x" />
780
+ <box flexGrow={1} paddingLeft={1}>
781
+ <text fg={theme().error}>Failed to load diff</text>
782
+ </box>
783
+ </Match>
784
+ <Match when={!diff.loading}>
785
+ <PanelGroup axis="x">
786
+ <Show when={showFileTree()}>
787
+ <DiffViewerFileTree
788
+ files={files()}
789
+ loading={diff.loading}
790
+ error={diff.error}
791
+ theme={theme()}
792
+ focused={focus() === "files"}
793
+ width={FILE_TREE_WIDTH}
794
+ highlightedNode={highlightedFileNode()}
795
+ selectedFileIndex={selectedFileIndex()}
796
+ reviewedFileNames={reviewedFileNames()}
797
+ expandedNodes={expandedFileNodes()}
798
+ onRowClick={clickFileTreeRow}
799
+ />
800
+ </Show>
801
+
802
+ <Panel flexGrow={1} minHeight={0} border="none">
803
+ <Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
804
+ <scrollbox
805
+ ref={(element: ScrollBoxRenderable) => (scroll = element)}
806
+ flexGrow={1}
807
+ minHeight={0}
808
+ scrollAcceleration={patchScrollAcceleration()}
809
+ verticalScrollbarOptions={{ visible: false }}
810
+ horizontalScrollbarOptions={{ visible: false }}
811
+ >
812
+ <For each={visiblePatchFiles()}>
813
+ {(entry, index) => {
814
+ const reviewed = () => reviewedFileNames().has(entry.file.file)
815
+ return (
816
+ <box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
817
+ {index() !== 0 ? <Separator axis="x" start={showFileTree() ? "edge" : undefined} /> : null}
818
+ <box
819
+ flexDirection="row"
820
+ gap={1}
821
+ flexShrink={0}
822
+ paddingLeft={1}
823
+ paddingRight={1}
824
+ border={patchLeftBorder()}
825
+ borderColor={theme().border}
826
+ >
827
+ <text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
828
+ <box flexGrow={1} />
829
+ <text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
830
+ +{entry.file.additions}
831
+ </text>
832
+ <text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
833
+ -{entry.file.deletions}
834
+ </text>
835
+ </box>
836
+ <Separator axis="x" start={showFileTree() ? "edge" : undefined} />
837
+ <Show
838
+ when={entry.file.patch}
839
+ fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
840
+ >
841
+ {(patch) => (
842
+ <box border={patchLeftBorder()} borderColor={theme().border}>
843
+ <diff
844
+ ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
845
+ diff={patch()}
846
+ view={view()}
847
+ filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
848
+ syntaxStyle={themeState.syntax()}
849
+ showLineNumbers={true}
850
+ width="100%"
851
+ wrapMode="char"
852
+ fg={reviewed() ? theme().textMuted : theme().text}
853
+ addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
854
+ removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
855
+ addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
856
+ removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
857
+ lineNumberFg={theme().diffLineNumber}
858
+ addedLineNumberBg={
859
+ reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
860
+ }
861
+ removedLineNumberBg={
862
+ reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
863
+ }
864
+ />
865
+ </box>
866
+ )}
867
+ </Show>
868
+ </box>
869
+ )
870
+ }}
871
+ </For>
872
+ <Show when={patchFillerHeight() > 0}>
873
+ <box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} />
874
+ </Show>
875
+ </scrollbox>
876
+ <Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
877
+ </Panel>
878
+ </PanelGroup>
879
+ </Match>
880
+ </Switch>
881
+ </box>
882
+
883
+ <Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
884
+ <Show when={switchFocusShortcut()}>
885
+ {(shortcut) => (
886
+ <text fg={theme().text}>
887
+ {shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
888
+ </text>
889
+ )}
890
+ </Show>
891
+ <Show when={nextFileShortcut()}>
892
+ {(shortcut) => (
893
+ <text fg={theme().text}>
894
+ {shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
895
+ </text>
896
+ )}
897
+ </Show>
898
+ <Show when={nextHunkShortcut()}>
899
+ {(shortcut) => (
900
+ <text fg={theme().text}>
901
+ {shortcut()} <span style={{ fg: theme().textMuted }}>next hunk</span>
902
+ </text>
903
+ )}
904
+ </Show>
905
+ <Show when={previousHunkShortcut()}>
906
+ {(shortcut) => (
907
+ <text fg={theme().text}>
908
+ {shortcut()} <span style={{ fg: theme().textMuted }}>previous hunk</span>
909
+ </text>
910
+ )}
911
+ </Show>
912
+ <Show when={previousFileShortcut()}>
913
+ {(shortcut) => (
914
+ <text fg={theme().text}>
915
+ {shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
916
+ </text>
917
+ )}
918
+ </Show>
919
+ <Show when={switchSourceShortcut()}>
920
+ {(shortcut) => (
921
+ <text fg={theme().text}>
922
+ {shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
923
+ </text>
924
+ )}
925
+ </Show>
926
+ <Show when={markReviewedShortcut()}>
927
+ {(shortcut) => (
928
+ <text fg={theme().text}>
929
+ {shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
930
+ </text>
931
+ )}
932
+ </Show>
933
+ <Show when={helpShortcut()}>
934
+ {(shortcut) => (
935
+ <text fg={theme().text}>
936
+ {shortcut()} <span style={{ fg: theme().textMuted }}>all</span>
937
+ </text>
938
+ )}
939
+ </Show>
940
+ </Panel>
941
+ </PanelGroup>
942
+ </box>
943
+ )
944
+ }
945
+
946
+ function DiffViewerHelpDialog() {
947
+ const { theme } = useTheme()
948
+ const rows = [
949
+ {
950
+ shortcut: () => "q",
951
+ action: "Close viewer",
952
+ description: "Quit the diff viewer",
953
+ },
954
+ {
955
+ shortcut: useCommandShortcut("diff.switch_focus"),
956
+ action: "Focus file tree",
957
+ description: "Move keyboard focus between the file tree and patch pane",
958
+ },
959
+ {
960
+ shortcut: useCommandShortcut("diff.next_hunk"),
961
+ action: "Next hunk",
962
+ description: "Jump to the next diff hunk",
963
+ },
964
+ {
965
+ shortcut: useCommandShortcut("diff.previous_hunk"),
966
+ action: "Previous hunk",
967
+ description: "Jump to the previous diff hunk",
968
+ },
969
+ {
970
+ shortcut: useCommandShortcut("diff.next_file"),
971
+ action: "Next file",
972
+ description: "Select the next changed file in file-tree order",
973
+ },
974
+ {
975
+ shortcut: useCommandShortcut("diff.previous_file"),
976
+ action: "Previous file",
977
+ description: "Select the previous changed file in file-tree order",
978
+ },
979
+ {
980
+ shortcut: useCommandShortcut("diff.toggle_file_tree"),
981
+ action: "Toggle file tree",
982
+ description: "Show or hide the file tree sidebar",
983
+ },
984
+ {
985
+ shortcut: useCommandShortcut("diff.single_patch"),
986
+ action: "Toggle patches",
987
+ description: "Switch between one selected patch and all patches",
988
+ },
989
+ {
990
+ shortcut: useCommandShortcut("diff.switch_source"),
991
+ action: "Switch source",
992
+ description: "Choose working tree, main branch, or last-turn changes",
993
+ },
994
+ {
995
+ shortcut: useCommandShortcut("diff.toggle_view"),
996
+ action: "Toggle view",
997
+ description: "Switch between split and unified diff layout",
998
+ },
999
+ {
1000
+ shortcut: useCommandShortcut("diff.expand_all"),
1001
+ action: "Expand all folders",
1002
+ description: "Open every folder in the file tree",
1003
+ },
1004
+ {
1005
+ shortcut: useCommandShortcut("diff.mark_reviewed"),
1006
+ action: "Mark reviewed",
1007
+ description: "Toggle reviewed state for the selected file",
1008
+ },
1009
+ ]
1010
+
1011
+ return (
1012
+ <box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
1013
+ <box flexDirection="row" justifyContent="space-between">
1014
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
1015
+ Diff shortcuts
1016
+ </text>
1017
+ <text fg={theme.textMuted}>esc</text>
1018
+ </box>
1019
+ <box flexDirection="row">
1020
+ <text fg={theme.textMuted} width={5} wrapMode="none">
1021
+ Key
1022
+ </text>
1023
+ <text fg={theme.textMuted} width={22} wrapMode="none">
1024
+ Action
1025
+ </text>
1026
+ <text fg={theme.textMuted}>Description</text>
1027
+ </box>
1028
+ <For each={rows}>
1029
+ {(row) => (
1030
+ <box flexDirection="row">
1031
+ <text fg={theme.text} width={5} wrapMode="none">
1032
+ {row.shortcut() || "-"}
1033
+ </text>
1034
+ <text fg={theme.text} width={22} wrapMode="none">
1035
+ {row.action}
1036
+ </text>
1037
+ <text fg={theme.textMuted}>{row.description}</text>
1038
+ </box>
1039
+ )}
1040
+ </For>
1041
+ </box>
1042
+ )
1043
+ }
1044
+
1045
+ const tui: TuiPlugin = async (api) => {
1046
+ api.route.register([
1047
+ {
1048
+ name: ROUTE,
1049
+ render: () => <DiffViewer api={api} />,
1050
+ },
1051
+ ])
1052
+
1053
+ api.keymap.registerLayer({
1054
+ commands: [
1055
+ {
1056
+ name: "diff.open",
1057
+ title: "Open diff viewer",
1058
+ slashName: "diff",
1059
+ category: "VCS",
1060
+ namespace: "palette",
1061
+ run() {
1062
+ api.route.navigate(ROUTE, {
1063
+ mode: "git",
1064
+ sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
1065
+ returnRoute: api.route.current,
1066
+ })
1067
+ api.ui.dialog.clear()
1068
+ },
1069
+ },
1070
+ ],
1071
+ })
1072
+ }
1073
+
1074
+ export default {
1075
+ id: "diff-viewer",
1076
+ tui,
1077
+ }