@kitlangton/ghui 0.1.11 → 0.1.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -11,12 +11,12 @@ import { formatShortDate, formatTimestamp } from "./date.js"
11
11
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
12
12
  import { Observability } from "./observability.js"
13
13
  import { GitHubService } from "./services/GitHubService.js"
14
- import { colors } from "./ui/colors.js"
14
+ import { colors, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
15
15
  import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
16
16
  import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
17
17
  import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
18
18
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
19
- import { initialLabelModalState, initialMergeModalState, LabelModal, MergeModal } from "./ui/modals.js"
19
+ import { initialLabelModalState, initialMergeModalState, initialThemeModalState, LabelModal, MergeModal, ThemeModal } from "./ui/modals.js"
20
20
  import { groupBy, reviewLabel } from "./ui/pullRequests.js"
21
21
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
22
22
  import { PullRequestList } from "./ui/PullRequestList.js"
@@ -81,6 +81,8 @@ const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>
81
81
 
82
82
  const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
83
83
  const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
84
+ const themeIdAtom = Atom.make<ThemeId>("ghui").pipe(Atom.keepAlive)
85
+ const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
84
86
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
85
87
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
86
88
  const usernameAtom = githubRuntime.atom(
@@ -118,6 +120,64 @@ const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
118
120
 
119
121
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
120
122
 
123
+ const clipboardCommands = (): readonly (readonly string[])[] => {
124
+ if (process.platform === "darwin") return [["pbcopy"]]
125
+ if (process.platform === "linux") {
126
+ return [
127
+ ...(process.env.WAYLAND_DISPLAY ? [["wl-copy"]] : []),
128
+ ["xclip", "-selection", "clipboard"],
129
+ ["xsel", "--clipboard", "--input"],
130
+ ]
131
+ }
132
+ return []
133
+ }
134
+
135
+ const copyToClipboard = async (text: string) => {
136
+ const commands = clipboardCommands()
137
+ let lastError = ""
138
+
139
+ for (const command of commands) {
140
+ let proc: Bun.Subprocess<"pipe", "ignore", "pipe">
141
+ try {
142
+ proc = Bun.spawn({
143
+ cmd: [...command],
144
+ stdin: "pipe",
145
+ stdout: "ignore",
146
+ stderr: "pipe",
147
+ })
148
+ } catch (error) {
149
+ lastError = errorMessage(error)
150
+ continue
151
+ }
152
+
153
+ proc.stdin.write(text)
154
+ proc.stdin.end()
155
+
156
+ const exitCode = await proc.exited
157
+ if (exitCode === 0) return
158
+
159
+ const stderr = await Bun.readableStreamToText(proc.stderr)
160
+ lastError = stderr.trim()
161
+ }
162
+
163
+ const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
164
+ throw new Error(lastError || `Clipboard is not available.${installHint}`)
165
+ }
166
+
167
+ const openPullRequestInBrowser = async (pullRequest: PullRequestItem) => {
168
+ const proc = Bun.spawn({
169
+ cmd: ["gh", "pr", "view", String(pullRequest.number), "--repo", pullRequest.repository, "--web"],
170
+ stdout: "ignore",
171
+ stderr: "pipe",
172
+ })
173
+
174
+ const exitCode = await proc.exited
175
+ if (exitCode === 0) return
176
+
177
+ const stderr = await Bun.readableStreamToText(proc.stderr)
178
+ throw new Error(stderr.trim() || "Could not open PR in browser")
179
+ }
180
+
121
181
  const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
122
182
  const lines = [
123
183
  pullRequest.title,
@@ -133,29 +193,13 @@ const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
133
193
  lines.push(pullRequest.checkSummary)
134
194
  }
135
195
 
136
- const proc = Bun.spawn({
137
- cmd: ["pbcopy"],
138
- stdin: "pipe",
139
- stdout: "ignore",
140
- stderr: "pipe",
141
- })
142
-
143
- if (!proc.stdin) {
144
- throw new Error("Clipboard is not available")
145
- }
146
-
147
- proc.stdin.write(lines.join("\n"))
148
- proc.stdin.end()
149
-
150
- const exitCode = await proc.exited
151
- if (exitCode !== 0) {
152
- const stderr = await Bun.readableStreamToText(proc.stderr)
153
- throw new Error(stderr.trim() || "Could not copy PR metadata")
154
- }
196
+ await copyToClipboard(lines.join("\n"))
155
197
  }
156
198
 
157
199
  const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
158
200
 
201
+ const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
202
+
159
203
  const getDetailPlaceholderContent = ({
160
204
  status,
161
205
  retryProgress,
@@ -217,6 +261,9 @@ export const App = () => {
217
261
  const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
218
262
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
219
263
  const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
264
+ const [themeId, setThemeId] = useAtom(themeIdAtom)
265
+ const [themeModal, setThemeModal] = useAtom(themeModalAtom)
266
+ setActiveTheme(themeId)
220
267
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
221
268
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
222
269
  const retryProgress = useAtomValue(retryProgressAtom)
@@ -231,8 +278,10 @@ export const App = () => {
231
278
  const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
232
279
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
233
280
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
234
- const contentWidth = Math.max(60, width ?? 100)
235
- const isWideLayout = (width ?? 100) >= 100
281
+ const terminalWidth = width ?? 100
282
+ const terminalHeight = height ?? 24
283
+ const contentWidth = Math.max(1, terminalWidth)
284
+ const isWideLayout = terminalWidth >= 100
236
285
  const splitGap = 1
237
286
  const sectionPadding = 1
238
287
  const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
@@ -240,8 +289,8 @@ export const App = () => {
240
289
  const dividerJunctionAt = Math.max(1, leftPaneWidth)
241
290
  const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
242
291
  const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
243
- const wideDetailLines = Math.max(8, (height ?? 24) - 8) // fill available vertical space
244
- const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
292
+ const wideDetailLines = Math.max(8, terminalHeight - 8) // fill available vertical space
293
+ const wideBodyHeight = Math.max(8, terminalHeight - 4)
245
294
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
246
295
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
247
296
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -266,6 +315,10 @@ export const App = () => {
266
315
  }, 2500)
267
316
  }
268
317
 
318
+ useEffect(() => {
319
+ renderer.setBackgroundColor(colors.background)
320
+ }, [renderer, themeId])
321
+
269
322
  useEffect(() => () => {
270
323
  if (noticeTimeoutRef.current !== null) {
271
324
  clearTimeout(noticeTimeoutRef.current)
@@ -495,9 +548,43 @@ export const App = () => {
495
548
  loadPullRequestDiff(selectedPullRequest)
496
549
  }
497
550
 
551
+ const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
552
+ void openPullRequestInBrowser(pullRequest)
553
+ .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
554
+ .catch((error) => flashNotice(errorMessage(error)))
555
+ }
556
+
557
+ const openThemeModal = () => {
558
+ setLabelModal(initialLabelModalState)
559
+ setMergeModal(initialMergeModalState)
560
+ setThemeModal({
561
+ open: true,
562
+ initialThemeId: themeId,
563
+ })
564
+ }
565
+
566
+ const closeThemeModal = (confirm: boolean) => {
567
+ const selectedTheme = themeDefinitions.find((theme) => theme.id === themeId)
568
+ if (!confirm) {
569
+ setThemeId(themeModal.initialThemeId)
570
+ } else if (selectedTheme) {
571
+ flashNotice(`Theme: ${selectedTheme.name}`)
572
+ }
573
+ setThemeModal(initialThemeModalState)
574
+ }
575
+
576
+ const moveThemeSelection = (delta: number) => {
577
+ const currentIndex = Math.max(0, themeDefinitions.findIndex((theme) => theme.id === themeId))
578
+ const selectedIndex = Math.max(0, Math.min(themeDefinitions.length - 1, currentIndex + delta))
579
+ if (selectedIndex === currentIndex) return
580
+ const theme = themeDefinitions[selectedIndex]
581
+ if (theme && theme.id !== themeId) setThemeId(theme.id)
582
+ }
583
+
498
584
  const openLabelModal = () => {
499
585
  if (!selectedPullRequest) return
500
586
  setMergeModal(initialMergeModalState)
587
+ setThemeModal(initialThemeModalState)
501
588
  const repository = selectedPullRequest.repository
502
589
  const cachedLabels = labelCache[repository]
503
590
  if (cachedLabels) {
@@ -526,6 +613,7 @@ export const App = () => {
526
613
 
527
614
  const openMergeModal = () => {
528
615
  if (!selectedPullRequest) return
616
+ setThemeModal(initialThemeModalState)
529
617
  const repository = selectedPullRequest.repository
530
618
  const number = selectedPullRequest.number
531
619
  const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
@@ -627,6 +715,10 @@ export const App = () => {
627
715
 
628
716
  useKeyboard((key) => {
629
717
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
718
+ if (themeModal.open) {
719
+ closeThemeModal(false)
720
+ return
721
+ }
630
722
  if (mergeModal.open) {
631
723
  setMergeModal(initialMergeModalState)
632
724
  return
@@ -639,6 +731,26 @@ export const App = () => {
639
731
  return
640
732
  }
641
733
 
734
+ if (themeModal.open) {
735
+ if (key.name === "escape") {
736
+ closeThemeModal(false)
737
+ return
738
+ }
739
+ if (key.name === "return" || key.name === "enter") {
740
+ closeThemeModal(true)
741
+ return
742
+ }
743
+ if (key.name === "up" || key.name === "k") {
744
+ moveThemeSelection(-1)
745
+ return
746
+ }
747
+ if (key.name === "down" || key.name === "j") {
748
+ moveThemeSelection(1)
749
+ return
750
+ }
751
+ return
752
+ }
753
+
642
754
  if (mergeModal.open) {
643
755
  const options = availableMergeActions(mergeModal.info)
644
756
  if (key.name === "escape") {
@@ -803,8 +915,7 @@ export const App = () => {
803
915
  return
804
916
  }
805
917
  if (key.name === "o" && selectedPullRequest) {
806
- void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
807
- flashNotice(`Opened #${selectedPullRequest.number} in browser`)
918
+ openSelectedPullRequestInBrowser(selectedPullRequest)
808
919
  return
809
920
  }
810
921
  return
@@ -881,8 +992,7 @@ export const App = () => {
881
992
  return
882
993
  }
883
994
  if (key.name === "o" && selectedPullRequest) {
884
- void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
885
- flashNotice(`Opened #${selectedPullRequest.number} in browser`)
995
+ openSelectedPullRequestInBrowser(selectedPullRequest)
886
996
  return
887
997
  }
888
998
  if (key.name === "y" && selectedPullRequest) {
@@ -923,6 +1033,11 @@ export const App = () => {
923
1033
  }
924
1034
  }
925
1035
 
1036
+ if (isThemeKey(key)) {
1037
+ openThemeModal()
1038
+ return
1039
+ }
1040
+
926
1041
  if (key.name === "/") {
927
1042
  setFilterDraft(filterQuery)
928
1043
  setFilterMode(true)
@@ -1037,8 +1152,7 @@ export const App = () => {
1037
1152
  return
1038
1153
  }
1039
1154
  if (key.name === "o" && selectedPullRequest) {
1040
- void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1041
- flashNotice(`Opened #${selectedPullRequest.number} in browser`)
1155
+ openSelectedPullRequestInBrowser(selectedPullRequest)
1042
1156
  return
1043
1157
  }
1044
1158
  if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
@@ -1070,7 +1184,7 @@ export const App = () => {
1070
1184
  })
1071
1185
 
1072
1186
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
1073
- const fullscreenBodyLines = Math.max(8, (height ?? 24) - 8)
1187
+ const fullscreenBodyLines = Math.max(8, terminalHeight - 8)
1074
1188
  const wideFullscreenDetailScrollable = getDetailsPaneHeight({
1075
1189
  pullRequest: selectedPullRequest,
1076
1190
  contentWidth: fullscreenContentWidth,
@@ -1101,17 +1215,21 @@ export const App = () => {
1101
1215
 
1102
1216
  const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
1103
1217
  const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
1104
- const labelModalHeight = Math.min(20, (height ?? 24) - 4)
1218
+ const labelModalHeight = Math.min(20, terminalHeight - 4)
1105
1219
  const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1106
- const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
1220
+ const labelModalTop = Math.floor((terminalHeight - labelModalHeight) / 2)
1107
1221
  const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1108
- const mergeModalHeight = Math.min(16, (height ?? 24) - 4)
1222
+ const mergeModalHeight = Math.min(16, terminalHeight - 4)
1109
1223
  const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1110
- const mergeModalTop = Math.floor(((height ?? 24) - mergeModalHeight) / 2)
1224
+ const mergeModalTop = Math.floor((terminalHeight - mergeModalHeight) / 2)
1225
+ const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
1226
+ const themeModalHeight = Math.min(16, terminalHeight - 4)
1227
+ const themeModalLeft = Math.floor((contentWidth - themeModalWidth) / 2)
1228
+ const themeModalTop = Math.floor((terminalHeight - themeModalHeight) / 2)
1111
1229
 
1112
1230
  return (
1113
- <box flexGrow={1} flexDirection="column">
1114
- <box paddingLeft={1} paddingRight={1} flexDirection="column">
1231
+ <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
1232
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.panel}>
1115
1233
  <PlainLine text={headerLine} fg={colors.muted} bold />
1116
1234
  </box>
1117
1235
  {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
@@ -1132,6 +1250,7 @@ export const App = () => {
1132
1250
  height={wideBodyHeight}
1133
1251
  loadingIndicator={loadingIndicator}
1134
1252
  scrollRef={diffScrollRef}
1253
+ themeId={themeId}
1135
1254
  />
1136
1255
  ) : isWideLayout && detailFullView ? (
1137
1256
  <box flexGrow={1} flexDirection="column">
@@ -1182,7 +1301,7 @@ export const App = () => {
1182
1301
  </scrollbox>
1183
1302
  </box>
1184
1303
  ) : (
1185
- <>
1304
+ <box height={wideBodyHeight} flexDirection="column">
1186
1305
  <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} />
1187
1306
  <Divider width={contentWidth} />
1188
1307
  <box flexGrow={1} flexDirection="column">
@@ -1192,7 +1311,7 @@ export const App = () => {
1192
1311
  </box>
1193
1312
  </scrollbox>
1194
1313
  </box>
1195
- </>
1314
+ </box>
1196
1315
  )}
1197
1316
 
1198
1317
  {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
@@ -1200,7 +1319,7 @@ export const App = () => {
1200
1319
  ) : (
1201
1320
  <Divider width={contentWidth} />
1202
1321
  )}
1203
- <box paddingLeft={1} paddingRight={1}>
1322
+ <box paddingLeft={1} paddingRight={1} backgroundColor={colors.footer}>
1204
1323
  {footerNotice ? (
1205
1324
  <PlainLine text={footerNotice} fg={colors.count} />
1206
1325
  ) : (
@@ -1238,6 +1357,16 @@ export const App = () => {
1238
1357
  loadingIndicator={loadingIndicator}
1239
1358
  />
1240
1359
  ) : null}
1360
+ {themeModal.open ? (
1361
+ <ThemeModal
1362
+ state={themeModal}
1363
+ activeThemeId={themeId}
1364
+ modalWidth={themeModalWidth}
1365
+ modalHeight={themeModalHeight}
1366
+ offsetLeft={themeModalLeft}
1367
+ offsetTop={themeModalTop}
1368
+ />
1369
+ ) : null}
1241
1370
  </box>
1242
1371
  )
1243
1372
  }
@@ -86,6 +86,8 @@ export const FooterHints = ({
86
86
  <TextLine>
87
87
  <span fg={colors.count}>/</span>
88
88
  <span fg={colors.muted}> filter </span>
89
+ <span fg={colors.count}>t</span>
90
+ <span fg={colors.muted}> theme </span>
89
91
  {showFilterClear ? (
90
92
  <>
91
93
  <span fg={colors.count}>esc</span>
@@ -1,8 +1,8 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useMemo, type Ref } from "react"
3
3
  import type { PullRequestItem } from "../domain.js"
4
- import { colors } from "./colors.js"
5
- import { diffStatText, diffSyntaxStyle, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
4
+ import { colors, type ThemeId } from "./colors.js"
5
+ import { createDiffSyntaxStyle, diffStatText, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
6
6
  import { LoadingPane, StatusCard } from "./DetailsPane.js"
7
7
  import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
8
8
  import { shortRepoName } from "./pullRequests.js"
@@ -37,6 +37,7 @@ export const PullRequestDiffPane = ({
37
37
  height,
38
38
  loadingIndicator,
39
39
  scrollRef,
40
+ themeId,
40
41
  }: {
41
42
  pullRequest: PullRequestItem | null
42
43
  diffState: PullRequestDiffState | undefined
@@ -47,6 +48,7 @@ export const PullRequestDiffPane = ({
47
48
  height: number
48
49
  loadingIndicator: string
49
50
  scrollRef: Ref<ScrollBoxRenderable>
51
+ themeId: ThemeId
50
52
  }) => {
51
53
  const readyFiles = diffState?.status === "ready" ? diffState.files : []
52
54
  const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
@@ -55,6 +57,7 @@ export const PullRequestDiffPane = ({
55
57
  () => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
56
58
  [file?.patch, view, wrapMode, paneWidth],
57
59
  )
60
+ const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
58
61
 
59
62
  if (!pullRequest) {
60
63
  return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
@@ -125,18 +128,18 @@ export const PullRequestDiffPane = ({
125
128
  view={view}
126
129
  syncScroll
127
130
  filetype={file.filetype ?? "text"}
128
- syntaxStyle={diffSyntaxStyle}
131
+ syntaxStyle={syntaxStyle}
129
132
  showLineNumbers
130
133
  wrapMode={wrapMode}
131
- addedBg="#17351f"
132
- removedBg="#3a1e22"
133
- contextBg="transparent"
134
+ addedBg={colors.diff.addedBg}
135
+ removedBg={colors.diff.removedBg}
136
+ contextBg={colors.diff.contextBg}
134
137
  addedSignColor={colors.status.passing}
135
138
  removedSignColor={colors.status.failing}
136
139
  lineNumberFg={colors.muted}
137
- lineNumberBg="#151515"
138
- addedLineNumberBg="#12301a"
139
- removedLineNumberBg="#35171b"
140
+ lineNumberBg={colors.diff.lineNumberBg}
141
+ addedLineNumberBg={colors.diff.addedLineNumberBg}
142
+ removedLineNumberBg={colors.diff.removedLineNumberBg}
140
143
  selectionBg={colors.selectedBg}
141
144
  selectionFg={colors.selectedText}
142
145
  height={diffHeight}
package/src/ui/colors.ts CHANGED
@@ -1,4 +1,58 @@
1
- export const colors = {
1
+ export type ThemeId = "ghui" | "tokyo-night" | "catppuccin" | "rose-pine" | "gruvbox" | "nord" | "dracula" | "opencode"
2
+
3
+ export interface ColorPalette {
4
+ readonly background: string
5
+ readonly panel: string
6
+ readonly footer: string
7
+ readonly modalBackground: string
8
+ readonly text: string
9
+ readonly muted: string
10
+ readonly separator: string
11
+ readonly accent: string
12
+ readonly inlineCode: string
13
+ readonly error: string
14
+ readonly selectedBg: string
15
+ readonly selectedText: string
16
+ readonly count: string
17
+ readonly status: {
18
+ readonly draft: string
19
+ readonly approved: string
20
+ readonly changes: string
21
+ readonly review: string
22
+ readonly none: string
23
+ readonly passing: string
24
+ readonly pending: string
25
+ readonly failing: string
26
+ }
27
+ readonly repos: {
28
+ readonly opencode: string
29
+ readonly "effect-smol": string
30
+ readonly "opencode-console": string
31
+ readonly opencontrol: string
32
+ readonly default: string
33
+ }
34
+ readonly diff: {
35
+ readonly addedBg: string
36
+ readonly removedBg: string
37
+ readonly contextBg: string
38
+ readonly lineNumberBg: string
39
+ readonly addedLineNumberBg: string
40
+ readonly removedLineNumberBg: string
41
+ }
42
+ }
43
+
44
+ export interface ThemeDefinition {
45
+ readonly id: ThemeId
46
+ readonly name: string
47
+ readonly description: string
48
+ readonly colors: ColorPalette
49
+ }
50
+
51
+ const ghuiColors: ColorPalette = {
52
+ background: "#111018",
53
+ panel: "#161923",
54
+ footer: "#1d2430",
55
+ modalBackground: "#1a1a2e",
2
56
  text: "#ede7da",
3
57
  muted: "#9f9788",
4
58
  separator: "#6f685d",
@@ -25,4 +79,322 @@ export const colors = {
25
79
  opencontrol: "#f59e0b",
26
80
  default: "#93c5fd",
27
81
  },
28
- } as const
82
+ diff: {
83
+ addedBg: "#17351f",
84
+ removedBg: "#3a1e22",
85
+ contextBg: "transparent",
86
+ lineNumberBg: "#151515",
87
+ addedLineNumberBg: "#12301a",
88
+ removedLineNumberBg: "#35171b",
89
+ },
90
+ }
91
+
92
+ const tokyoNightColors: ColorPalette = {
93
+ background: "#1a1b26",
94
+ panel: "#16161e",
95
+ footer: "#24283b",
96
+ modalBackground: "#24283b",
97
+ text: "#c0caf5",
98
+ muted: "#787c99",
99
+ separator: "#3b4261",
100
+ accent: "#7aa2f7",
101
+ inlineCode: "#bb9af7",
102
+ error: "#f7768e",
103
+ selectedBg: "#283457",
104
+ selectedText: "#ffffff",
105
+ count: "#ff9e64",
106
+ status: {
107
+ draft: "#e0af68",
108
+ approved: "#9ece6a",
109
+ changes: "#f7768e",
110
+ review: "#7dcfff",
111
+ none: "#787c99",
112
+ passing: "#9ece6a",
113
+ pending: "#e0af68",
114
+ failing: "#f7768e",
115
+ },
116
+ repos: {
117
+ opencode: "#7aa2f7",
118
+ "effect-smol": "#9ece6a",
119
+ "opencode-console": "#bb9af7",
120
+ opencontrol: "#ff9e64",
121
+ default: "#7dcfff",
122
+ },
123
+ diff: {
124
+ addedBg: "#203326",
125
+ removedBg: "#3a222c",
126
+ contextBg: "transparent",
127
+ lineNumberBg: "#16161e",
128
+ addedLineNumberBg: "#1b2f23",
129
+ removedLineNumberBg: "#33202a",
130
+ },
131
+ }
132
+
133
+ const opencodeColors: ColorPalette = {
134
+ background: "#0a0a0a",
135
+ panel: "#141414",
136
+ footer: "#1e1e1e",
137
+ modalBackground: "#1e1e1e",
138
+ text: "#eeeeee",
139
+ muted: "#808080",
140
+ separator: "#484848",
141
+ accent: "#fab283",
142
+ inlineCode: "#7fd88f",
143
+ error: "#e06c75",
144
+ selectedBg: "#323232",
145
+ selectedText: "#eeeeee",
146
+ count: "#fab283",
147
+ status: {
148
+ draft: "#f5a742",
149
+ approved: "#7fd88f",
150
+ changes: "#e06c75",
151
+ review: "#5c9cf5",
152
+ none: "#808080",
153
+ passing: "#7fd88f",
154
+ pending: "#f5a742",
155
+ failing: "#e06c75",
156
+ },
157
+ repos: {
158
+ opencode: "#fab283",
159
+ "effect-smol": "#7fd88f",
160
+ "opencode-console": "#9d7cd8",
161
+ opencontrol: "#f5a742",
162
+ default: "#5c9cf5",
163
+ },
164
+ diff: {
165
+ addedBg: "#20303b",
166
+ removedBg: "#37222c",
167
+ contextBg: "transparent",
168
+ lineNumberBg: "#141414",
169
+ addedLineNumberBg: "#1b2b34",
170
+ removedLineNumberBg: "#2d1f26",
171
+ },
172
+ }
173
+
174
+ const catppuccinColors: ColorPalette = {
175
+ background: "#1e1e2e",
176
+ panel: "#181825",
177
+ footer: "#313244",
178
+ modalBackground: "#313244",
179
+ text: "#cdd6f4",
180
+ muted: "#7f849c",
181
+ separator: "#45475a",
182
+ accent: "#cba6f7",
183
+ inlineCode: "#f5c2e7",
184
+ error: "#f38ba8",
185
+ selectedBg: "#45475a",
186
+ selectedText: "#f5e0dc",
187
+ count: "#fab387",
188
+ status: {
189
+ draft: "#f9e2af",
190
+ approved: "#a6e3a1",
191
+ changes: "#f38ba8",
192
+ review: "#89b4fa",
193
+ none: "#7f849c",
194
+ passing: "#a6e3a1",
195
+ pending: "#f9e2af",
196
+ failing: "#f38ba8",
197
+ },
198
+ repos: {
199
+ opencode: "#89b4fa",
200
+ "effect-smol": "#a6e3a1",
201
+ "opencode-console": "#f5c2e7",
202
+ opencontrol: "#fab387",
203
+ default: "#74c7ec",
204
+ },
205
+ diff: {
206
+ addedBg: "#243927",
207
+ removedBg: "#3b2532",
208
+ contextBg: "transparent",
209
+ lineNumberBg: "#181825",
210
+ addedLineNumberBg: "#203524",
211
+ removedLineNumberBg: "#36232f",
212
+ },
213
+ }
214
+
215
+ const rosePineColors: ColorPalette = {
216
+ background: "#191724",
217
+ panel: "#1f1d2e",
218
+ footer: "#26233a",
219
+ modalBackground: "#26233a",
220
+ text: "#e0def4",
221
+ muted: "#908caa",
222
+ separator: "#524f67",
223
+ accent: "#c4a7e7",
224
+ inlineCode: "#f6c177",
225
+ error: "#eb6f92",
226
+ selectedBg: "#403d52",
227
+ selectedText: "#f6f1ff",
228
+ count: "#ebbcba",
229
+ status: {
230
+ draft: "#f6c177",
231
+ approved: "#9ccfd8",
232
+ changes: "#eb6f92",
233
+ review: "#31748f",
234
+ none: "#908caa",
235
+ passing: "#9ccfd8",
236
+ pending: "#f6c177",
237
+ failing: "#eb6f92",
238
+ },
239
+ repos: {
240
+ opencode: "#31748f",
241
+ "effect-smol": "#9ccfd8",
242
+ "opencode-console": "#c4a7e7",
243
+ opencontrol: "#f6c177",
244
+ default: "#ebbcba",
245
+ },
246
+ diff: {
247
+ addedBg: "#23343a",
248
+ removedBg: "#3a2534",
249
+ contextBg: "transparent",
250
+ lineNumberBg: "#1f1d2e",
251
+ addedLineNumberBg: "#203137",
252
+ removedLineNumberBg: "#352330",
253
+ },
254
+ }
255
+
256
+ const gruvboxColors: ColorPalette = {
257
+ background: "#282828",
258
+ panel: "#1d2021",
259
+ footer: "#3c3836",
260
+ modalBackground: "#3c3836",
261
+ text: "#ebdbb2",
262
+ muted: "#928374",
263
+ separator: "#665c54",
264
+ accent: "#fabd2f",
265
+ inlineCode: "#d3869b",
266
+ error: "#fb4934",
267
+ selectedBg: "#504945",
268
+ selectedText: "#fbf1c7",
269
+ count: "#fe8019",
270
+ status: {
271
+ draft: "#fabd2f",
272
+ approved: "#b8bb26",
273
+ changes: "#fb4934",
274
+ review: "#83a598",
275
+ none: "#928374",
276
+ passing: "#b8bb26",
277
+ pending: "#fabd2f",
278
+ failing: "#fb4934",
279
+ },
280
+ repos: {
281
+ opencode: "#83a598",
282
+ "effect-smol": "#b8bb26",
283
+ "opencode-console": "#d3869b",
284
+ opencontrol: "#fe8019",
285
+ default: "#8ec07c",
286
+ },
287
+ diff: {
288
+ addedBg: "#32361f",
289
+ removedBg: "#3c2927",
290
+ contextBg: "transparent",
291
+ lineNumberBg: "#1d2021",
292
+ addedLineNumberBg: "#2f331e",
293
+ removedLineNumberBg: "#382726",
294
+ },
295
+ }
296
+
297
+ const nordColors: ColorPalette = {
298
+ background: "#2e3440",
299
+ panel: "#242933",
300
+ footer: "#3b4252",
301
+ modalBackground: "#3b4252",
302
+ text: "#eceff4",
303
+ muted: "#8892a7",
304
+ separator: "#4c566a",
305
+ accent: "#88c0d0",
306
+ inlineCode: "#b48ead",
307
+ error: "#bf616a",
308
+ selectedBg: "#434c5e",
309
+ selectedText: "#eceff4",
310
+ count: "#ebcb8b",
311
+ status: {
312
+ draft: "#ebcb8b",
313
+ approved: "#a3be8c",
314
+ changes: "#bf616a",
315
+ review: "#81a1c1",
316
+ none: "#8892a7",
317
+ passing: "#a3be8c",
318
+ pending: "#ebcb8b",
319
+ failing: "#bf616a",
320
+ },
321
+ repos: {
322
+ opencode: "#81a1c1",
323
+ "effect-smol": "#a3be8c",
324
+ "opencode-console": "#b48ead",
325
+ opencontrol: "#d08770",
326
+ default: "#88c0d0",
327
+ },
328
+ diff: {
329
+ addedBg: "#334033",
330
+ removedBg: "#433238",
331
+ contextBg: "transparent",
332
+ lineNumberBg: "#242933",
333
+ addedLineNumberBg: "#303d31",
334
+ removedLineNumberBg: "#3f3036",
335
+ },
336
+ }
337
+
338
+ const draculaColors: ColorPalette = {
339
+ background: "#282a36",
340
+ panel: "#21222c",
341
+ footer: "#343746",
342
+ modalBackground: "#343746",
343
+ text: "#f8f8f2",
344
+ muted: "#8f94b8",
345
+ separator: "#4f5268",
346
+ accent: "#bd93f9",
347
+ inlineCode: "#ff79c6",
348
+ error: "#ff5555",
349
+ selectedBg: "#44475a",
350
+ selectedText: "#f8f8f2",
351
+ count: "#ffb86c",
352
+ status: {
353
+ draft: "#f1fa8c",
354
+ approved: "#50fa7b",
355
+ changes: "#ff5555",
356
+ review: "#8be9fd",
357
+ none: "#8f94b8",
358
+ passing: "#50fa7b",
359
+ pending: "#f1fa8c",
360
+ failing: "#ff5555",
361
+ },
362
+ repos: {
363
+ opencode: "#8be9fd",
364
+ "effect-smol": "#50fa7b",
365
+ "opencode-console": "#ff79c6",
366
+ opencontrol: "#ffb86c",
367
+ default: "#bd93f9",
368
+ },
369
+ diff: {
370
+ addedBg: "#203a29",
371
+ removedBg: "#43272f",
372
+ contextBg: "transparent",
373
+ lineNumberBg: "#21222c",
374
+ addedLineNumberBg: "#1d3627",
375
+ removedLineNumberBg: "#3d252c",
376
+ },
377
+ }
378
+
379
+ export const themeDefinitions: readonly ThemeDefinition[] = [
380
+ { id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
381
+ { id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
382
+ { id: "catppuccin", name: "Catppuccin", description: "Mocha lavender, peach, and soft pastel contrast", colors: catppuccinColors },
383
+ { id: "rose-pine", name: "Rose Pine", description: "Muted rose, pine, and gold on dusky violet", colors: rosePineColors },
384
+ { id: "gruvbox", name: "Gruvbox", description: "Retro warm earth tones with punchy semantic accents", colors: gruvboxColors },
385
+ { id: "nord", name: "Nord", description: "Arctic blue-gray surfaces with frosty accents", colors: nordColors },
386
+ { id: "dracula", name: "Dracula", description: "High-contrast purple, pink, cyan, and green", colors: draculaColors },
387
+ { id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
388
+ ] as const
389
+
390
+ let activeTheme = themeDefinitions[0]!
391
+
392
+ export const colors: ColorPalette = { ...ghuiColors }
393
+
394
+ export const getThemeDefinition = (id: ThemeId) => themeDefinitions.find((theme) => theme.id === id) ?? themeDefinitions[0]!
395
+
396
+ export const setActiveTheme = (id: ThemeId) => {
397
+ if (activeTheme.id === id) return
398
+ activeTheme = getThemeDefinition(id)
399
+ Object.assign(colors, activeTheme.colors)
400
+ }
package/src/ui/diff.ts CHANGED
@@ -13,21 +13,21 @@ export type PullRequestDiffState =
13
13
  | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
14
14
  | { readonly status: "error"; readonly error: string }
15
15
 
16
- export const diffSyntaxStyle = SyntaxStyle.fromStyles({
17
- keyword: { fg: parseColor("#f4a51c"), bold: true },
18
- "keyword.import": { fg: parseColor("#f4a51c"), bold: true },
19
- string: { fg: parseColor("#d7c5a1") },
16
+ export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
17
+ keyword: { fg: parseColor(colors.accent), bold: true },
18
+ "keyword.import": { fg: parseColor(colors.accent), bold: true },
19
+ string: { fg: parseColor(colors.inlineCode) },
20
20
  comment: { fg: parseColor(colors.muted), italic: true },
21
- number: { fg: parseColor("#93c5fd") },
22
- boolean: { fg: parseColor("#93c5fd") },
23
- constant: { fg: parseColor("#93c5fd") },
24
- function: { fg: parseColor("#7dd3a3") },
25
- "function.call": { fg: parseColor("#7dd3a3") },
26
- constructor: { fg: parseColor("#f59e0b") },
27
- type: { fg: parseColor("#f59e0b") },
28
- operator: { fg: parseColor("#f87171") },
21
+ number: { fg: parseColor(colors.status.review) },
22
+ boolean: { fg: parseColor(colors.status.review) },
23
+ constant: { fg: parseColor(colors.status.review) },
24
+ function: { fg: parseColor(colors.status.passing) },
25
+ "function.call": { fg: parseColor(colors.status.passing) },
26
+ constructor: { fg: parseColor(colors.status.draft) },
27
+ type: { fg: parseColor(colors.status.draft) },
28
+ operator: { fg: parseColor(colors.status.failing) },
29
29
  variable: { fg: parseColor(colors.text) },
30
- property: { fg: parseColor("#93c5fd") },
30
+ property: { fg: parseColor(colors.status.review) },
31
31
  bracket: { fg: parseColor(colors.text) },
32
32
  punctuation: { fg: parseColor(colors.text) },
33
33
  default: { fg: parseColor(colors.text) },
package/src/ui/modals.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
2
  import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
3
3
  import { availableMergeActions } from "../mergeActions.js"
4
- import { colors } from "./colors.js"
4
+ import { colors, themeDefinitions, type ThemeId } from "./colors.js"
5
5
  import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
6
6
  import { labelColor, shortRepoName } from "./pullRequests.js"
7
7
 
@@ -25,6 +25,11 @@ export interface MergeModalState {
25
25
  readonly error: string | null
26
26
  }
27
27
 
28
+ export interface ThemeModalState {
29
+ readonly open: boolean
30
+ readonly initialThemeId: ThemeId
31
+ }
32
+
28
33
  export const initialLabelModalState: LabelModalState = {
29
34
  open: false,
30
35
  repository: null,
@@ -45,6 +50,11 @@ export const initialMergeModalState: MergeModalState = {
45
50
  error: null,
46
51
  }
47
52
 
53
+ export const initialThemeModalState: ThemeModalState = {
54
+ open: false,
55
+ initialThemeId: "ghui",
56
+ }
57
+
48
58
  const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
49
59
  if (!info) return "Loading merge status from GitHub."
50
60
  if (info.state !== "open") return "This pull request is not open."
@@ -247,3 +257,86 @@ export const MergeModal = ({
247
257
  </ModalFrame>
248
258
  )
249
259
  }
260
+
261
+ export const ThemeModal = ({
262
+ state,
263
+ activeThemeId,
264
+ modalWidth,
265
+ modalHeight,
266
+ offsetLeft,
267
+ offsetTop,
268
+ }: {
269
+ state: ThemeModalState
270
+ activeThemeId: ThemeId
271
+ modalWidth: number
272
+ modalHeight: number
273
+ offsetLeft: number
274
+ offsetTop: number
275
+ }) => {
276
+ const innerWidth = Math.max(16, modalWidth - 2)
277
+ const contentWidth = Math.max(14, innerWidth - 2)
278
+ const rowWidth = innerWidth
279
+ const maxVisible = Math.max(1, modalHeight - 7)
280
+ const activeIndex = themeDefinitions.findIndex((theme) => theme.id === activeThemeId)
281
+ const selectedIndex = Math.max(0, activeIndex)
282
+ const selectedTheme = themeDefinitions[selectedIndex]!
283
+ const scrollStart = Math.min(
284
+ Math.max(0, themeDefinitions.length - maxVisible),
285
+ Math.max(0, selectedIndex - maxVisible + 1),
286
+ )
287
+ const visibleThemes = themeDefinitions.slice(scrollStart, scrollStart + maxVisible)
288
+ const countText = `${selectedIndex + 1}/${themeDefinitions.length}`
289
+ const title = "Themes"
290
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
291
+
292
+ return (
293
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
294
+ <box height={1} paddingLeft={1} paddingRight={1}>
295
+ <TextLine>
296
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
297
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
298
+ <span fg={colors.muted}>{countText}</span>
299
+ </TextLine>
300
+ </box>
301
+ <box height={1} paddingLeft={1} paddingRight={1}>
302
+ <PlainLine text={fitCell(selectedTheme.description, contentWidth)} fg={colors.muted} />
303
+ </box>
304
+ <Divider width={innerWidth} />
305
+ <box height={maxVisible} flexDirection="column">
306
+ {visibleThemes.map((theme, index) => {
307
+ const actualIndex = scrollStart + index
308
+ const isSelected = actualIndex === selectedIndex
309
+ const isActive = theme.id === activeThemeId
310
+ const marker = isActive ? "✓" : " "
311
+ const swatchWidth = 6
312
+ const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
313
+
314
+ return (
315
+ <TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
316
+ <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
317
+ <span> </span>
318
+ <span>{fitCell(theme.name, nameWidth)}</span>
319
+ <span bg={theme.colors.background}> </span>
320
+ <span bg={theme.colors.panel}> </span>
321
+ <span bg={theme.colors.accent}> </span>
322
+ <span bg={theme.colors.status.passing}> </span>
323
+ <span bg={theme.colors.status.failing}> </span>
324
+ <span bg={theme.colors.status.review}> </span>
325
+ </TextLine>
326
+ )
327
+ })}
328
+ </box>
329
+ <Divider width={innerWidth} />
330
+ <box height={1} paddingLeft={1} paddingRight={1}>
331
+ <TextLine>
332
+ <span fg={colors.count}>↑↓</span>
333
+ <span fg={colors.muted}> preview </span>
334
+ <span fg={colors.count}>enter</span>
335
+ <span fg={colors.muted}> select </span>
336
+ <span fg={colors.count}>esc</span>
337
+ <span fg={colors.muted}> cancel</span>
338
+ </TextLine>
339
+ </box>
340
+ </ModalFrame>
341
+ )
342
+ }
@@ -77,7 +77,7 @@ export const ModalFrame = ({
77
77
  width,
78
78
  height,
79
79
  junctionRows = [],
80
- backgroundColor = "#1a1a2e",
80
+ backgroundColor = colors.modalBackground,
81
81
  }: {
82
82
  children: React.ReactNode
83
83
  left: number