@kitlangton/ghui 0.1.11 → 0.1.12

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.12",
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 isShiftT = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "T" || key.name === "t" && key.shift
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)
@@ -266,6 +313,10 @@ export const App = () => {
266
313
  }, 2500)
267
314
  }
268
315
 
316
+ useEffect(() => {
317
+ renderer.setBackgroundColor(colors.background)
318
+ }, [renderer, themeId])
319
+
269
320
  useEffect(() => () => {
270
321
  if (noticeTimeoutRef.current !== null) {
271
322
  clearTimeout(noticeTimeoutRef.current)
@@ -495,9 +546,44 @@ export const App = () => {
495
546
  loadPullRequestDiff(selectedPullRequest)
496
547
  }
497
548
 
549
+ const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
550
+ void openPullRequestInBrowser(pullRequest)
551
+ .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
552
+ .catch((error) => flashNotice(errorMessage(error)))
553
+ }
554
+
555
+ const openThemeModal = () => {
556
+ setLabelModal(initialLabelModalState)
557
+ setMergeModal(initialMergeModalState)
558
+ setThemeModal({
559
+ open: true,
560
+ selectedIndex: Math.max(0, themeDefinitions.findIndex((theme) => theme.id === themeId)),
561
+ initialThemeId: themeId,
562
+ })
563
+ }
564
+
565
+ const closeThemeModal = (confirm: boolean) => {
566
+ const selectedTheme = themeDefinitions[themeModal.selectedIndex]
567
+ if (!confirm) {
568
+ setThemeId(themeModal.initialThemeId)
569
+ } else if (selectedTheme) {
570
+ flashNotice(`Theme: ${selectedTheme.name}`)
571
+ }
572
+ setThemeModal(initialThemeModalState)
573
+ }
574
+
575
+ const moveThemeSelection = (delta: number) => {
576
+ const selectedIndex = Math.max(0, Math.min(themeDefinitions.length - 1, themeModal.selectedIndex + delta))
577
+ if (selectedIndex === themeModal.selectedIndex) return
578
+ const theme = themeDefinitions[selectedIndex]
579
+ if (theme && theme.id !== themeId) setThemeId(theme.id)
580
+ setThemeModal((current) => ({ ...current, selectedIndex }))
581
+ }
582
+
498
583
  const openLabelModal = () => {
499
584
  if (!selectedPullRequest) return
500
585
  setMergeModal(initialMergeModalState)
586
+ setThemeModal(initialThemeModalState)
501
587
  const repository = selectedPullRequest.repository
502
588
  const cachedLabels = labelCache[repository]
503
589
  if (cachedLabels) {
@@ -526,6 +612,7 @@ export const App = () => {
526
612
 
527
613
  const openMergeModal = () => {
528
614
  if (!selectedPullRequest) return
615
+ setThemeModal(initialThemeModalState)
529
616
  const repository = selectedPullRequest.repository
530
617
  const number = selectedPullRequest.number
531
618
  const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
@@ -627,6 +714,10 @@ export const App = () => {
627
714
 
628
715
  useKeyboard((key) => {
629
716
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
717
+ if (themeModal.open) {
718
+ closeThemeModal(false)
719
+ return
720
+ }
630
721
  if (mergeModal.open) {
631
722
  setMergeModal(initialMergeModalState)
632
723
  return
@@ -639,6 +730,26 @@ export const App = () => {
639
730
  return
640
731
  }
641
732
 
733
+ if (themeModal.open) {
734
+ if (key.name === "escape") {
735
+ closeThemeModal(false)
736
+ return
737
+ }
738
+ if (key.name === "return" || key.name === "enter") {
739
+ closeThemeModal(true)
740
+ return
741
+ }
742
+ if (key.name === "up" || key.name === "k") {
743
+ moveThemeSelection(-1)
744
+ return
745
+ }
746
+ if (key.name === "down" || key.name === "j") {
747
+ moveThemeSelection(1)
748
+ return
749
+ }
750
+ return
751
+ }
752
+
642
753
  if (mergeModal.open) {
643
754
  const options = availableMergeActions(mergeModal.info)
644
755
  if (key.name === "escape") {
@@ -803,8 +914,7 @@ export const App = () => {
803
914
  return
804
915
  }
805
916
  if (key.name === "o" && selectedPullRequest) {
806
- void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
807
- flashNotice(`Opened #${selectedPullRequest.number} in browser`)
917
+ openSelectedPullRequestInBrowser(selectedPullRequest)
808
918
  return
809
919
  }
810
920
  return
@@ -881,8 +991,7 @@ export const App = () => {
881
991
  return
882
992
  }
883
993
  if (key.name === "o" && selectedPullRequest) {
884
- void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
885
- flashNotice(`Opened #${selectedPullRequest.number} in browser`)
994
+ openSelectedPullRequestInBrowser(selectedPullRequest)
886
995
  return
887
996
  }
888
997
  if (key.name === "y" && selectedPullRequest) {
@@ -923,6 +1032,11 @@ export const App = () => {
923
1032
  }
924
1033
  }
925
1034
 
1035
+ if (isShiftT(key)) {
1036
+ openThemeModal()
1037
+ return
1038
+ }
1039
+
926
1040
  if (key.name === "/") {
927
1041
  setFilterDraft(filterQuery)
928
1042
  setFilterMode(true)
@@ -1037,8 +1151,7 @@ export const App = () => {
1037
1151
  return
1038
1152
  }
1039
1153
  if (key.name === "o" && selectedPullRequest) {
1040
- void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1041
- flashNotice(`Opened #${selectedPullRequest.number} in browser`)
1154
+ openSelectedPullRequestInBrowser(selectedPullRequest)
1042
1155
  return
1043
1156
  }
1044
1157
  if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
@@ -1108,10 +1221,14 @@ export const App = () => {
1108
1221
  const mergeModalHeight = Math.min(16, (height ?? 24) - 4)
1109
1222
  const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1110
1223
  const mergeModalTop = Math.floor(((height ?? 24) - mergeModalHeight) / 2)
1224
+ const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
1225
+ const themeModalHeight = Math.min(10, (height ?? 24) - 4)
1226
+ const themeModalLeft = Math.floor((contentWidth - themeModalWidth) / 2)
1227
+ const themeModalTop = Math.floor(((height ?? 24) - themeModalHeight) / 2)
1111
1228
 
1112
1229
  return (
1113
- <box flexGrow={1} flexDirection="column">
1114
- <box paddingLeft={1} paddingRight={1} flexDirection="column">
1230
+ <box flexGrow={1} flexDirection="column" backgroundColor={colors.background}>
1231
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.panel}>
1115
1232
  <PlainLine text={headerLine} fg={colors.muted} bold />
1116
1233
  </box>
1117
1234
  {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
@@ -1132,6 +1249,7 @@ export const App = () => {
1132
1249
  height={wideBodyHeight}
1133
1250
  loadingIndicator={loadingIndicator}
1134
1251
  scrollRef={diffScrollRef}
1252
+ themeId={themeId}
1135
1253
  />
1136
1254
  ) : isWideLayout && detailFullView ? (
1137
1255
  <box flexGrow={1} flexDirection="column">
@@ -1200,7 +1318,7 @@ export const App = () => {
1200
1318
  ) : (
1201
1319
  <Divider width={contentWidth} />
1202
1320
  )}
1203
- <box paddingLeft={1} paddingRight={1}>
1321
+ <box paddingLeft={1} paddingRight={1} backgroundColor={colors.panel}>
1204
1322
  {footerNotice ? (
1205
1323
  <PlainLine text={footerNotice} fg={colors.count} />
1206
1324
  ) : (
@@ -1238,6 +1356,16 @@ export const App = () => {
1238
1356
  loadingIndicator={loadingIndicator}
1239
1357
  />
1240
1358
  ) : null}
1359
+ {themeModal.open ? (
1360
+ <ThemeModal
1361
+ state={themeModal}
1362
+ activeThemeId={themeId}
1363
+ modalWidth={themeModalWidth}
1364
+ modalHeight={themeModalHeight}
1365
+ offsetLeft={themeModalLeft}
1366
+ offsetTop={themeModalTop}
1367
+ />
1368
+ ) : null}
1241
1369
  </box>
1242
1370
  )
1243
1371
  }
@@ -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,56 @@
1
- export const colors = {
1
+ export type ThemeId = "ghui" | "tokyo-night" | "opencode"
2
+
3
+ export interface ColorPalette {
4
+ readonly background: string
5
+ readonly panel: string
6
+ readonly modalBackground: string
7
+ readonly text: string
8
+ readonly muted: string
9
+ readonly separator: string
10
+ readonly accent: string
11
+ readonly inlineCode: string
12
+ readonly error: string
13
+ readonly selectedBg: string
14
+ readonly selectedText: string
15
+ readonly count: string
16
+ readonly status: {
17
+ readonly draft: string
18
+ readonly approved: string
19
+ readonly changes: string
20
+ readonly review: string
21
+ readonly none: string
22
+ readonly passing: string
23
+ readonly pending: string
24
+ readonly failing: string
25
+ }
26
+ readonly repos: {
27
+ readonly opencode: string
28
+ readonly "effect-smol": string
29
+ readonly "opencode-console": string
30
+ readonly opencontrol: string
31
+ readonly default: string
32
+ }
33
+ readonly diff: {
34
+ readonly addedBg: string
35
+ readonly removedBg: string
36
+ readonly contextBg: string
37
+ readonly lineNumberBg: string
38
+ readonly addedLineNumberBg: string
39
+ readonly removedLineNumberBg: string
40
+ }
41
+ }
42
+
43
+ export interface ThemeDefinition {
44
+ readonly id: ThemeId
45
+ readonly name: string
46
+ readonly description: string
47
+ readonly colors: ColorPalette
48
+ }
49
+
50
+ const ghuiColors: ColorPalette = {
51
+ background: "#111018",
52
+ panel: "#161923",
53
+ modalBackground: "#1a1a2e",
2
54
  text: "#ede7da",
3
55
  muted: "#9f9788",
4
56
  separator: "#6f685d",
@@ -25,4 +77,109 @@ export const colors = {
25
77
  opencontrol: "#f59e0b",
26
78
  default: "#93c5fd",
27
79
  },
28
- } as const
80
+ diff: {
81
+ addedBg: "#17351f",
82
+ removedBg: "#3a1e22",
83
+ contextBg: "transparent",
84
+ lineNumberBg: "#151515",
85
+ addedLineNumberBg: "#12301a",
86
+ removedLineNumberBg: "#35171b",
87
+ },
88
+ }
89
+
90
+ const tokyoNightColors: ColorPalette = {
91
+ background: "#1a1b26",
92
+ panel: "#16161e",
93
+ modalBackground: "#24283b",
94
+ text: "#c0caf5",
95
+ muted: "#787c99",
96
+ separator: "#3b4261",
97
+ accent: "#7aa2f7",
98
+ inlineCode: "#bb9af7",
99
+ error: "#f7768e",
100
+ selectedBg: "#283457",
101
+ selectedText: "#ffffff",
102
+ count: "#ff9e64",
103
+ status: {
104
+ draft: "#e0af68",
105
+ approved: "#9ece6a",
106
+ changes: "#f7768e",
107
+ review: "#7dcfff",
108
+ none: "#787c99",
109
+ passing: "#9ece6a",
110
+ pending: "#e0af68",
111
+ failing: "#f7768e",
112
+ },
113
+ repos: {
114
+ opencode: "#7aa2f7",
115
+ "effect-smol": "#9ece6a",
116
+ "opencode-console": "#bb9af7",
117
+ opencontrol: "#ff9e64",
118
+ default: "#7dcfff",
119
+ },
120
+ diff: {
121
+ addedBg: "#203326",
122
+ removedBg: "#3a222c",
123
+ contextBg: "transparent",
124
+ lineNumberBg: "#16161e",
125
+ addedLineNumberBg: "#1b2f23",
126
+ removedLineNumberBg: "#33202a",
127
+ },
128
+ }
129
+
130
+ const opencodeColors: ColorPalette = {
131
+ background: "#0a0a0a",
132
+ panel: "#141414",
133
+ modalBackground: "#1e1e1e",
134
+ text: "#eeeeee",
135
+ muted: "#808080",
136
+ separator: "#484848",
137
+ accent: "#fab283",
138
+ inlineCode: "#7fd88f",
139
+ error: "#e06c75",
140
+ selectedBg: "#323232",
141
+ selectedText: "#eeeeee",
142
+ count: "#fab283",
143
+ status: {
144
+ draft: "#f5a742",
145
+ approved: "#7fd88f",
146
+ changes: "#e06c75",
147
+ review: "#5c9cf5",
148
+ none: "#808080",
149
+ passing: "#7fd88f",
150
+ pending: "#f5a742",
151
+ failing: "#e06c75",
152
+ },
153
+ repos: {
154
+ opencode: "#fab283",
155
+ "effect-smol": "#7fd88f",
156
+ "opencode-console": "#9d7cd8",
157
+ opencontrol: "#f5a742",
158
+ default: "#5c9cf5",
159
+ },
160
+ diff: {
161
+ addedBg: "#20303b",
162
+ removedBg: "#37222c",
163
+ contextBg: "transparent",
164
+ lineNumberBg: "#141414",
165
+ addedLineNumberBg: "#1b2b34",
166
+ removedLineNumberBg: "#2d1f26",
167
+ },
168
+ }
169
+
170
+ export const themeDefinitions: readonly ThemeDefinition[] = [
171
+ { id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
172
+ { id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
173
+ { id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
174
+ ] as const
175
+
176
+ let activeTheme = themeDefinitions[0]!
177
+
178
+ export const colors: ColorPalette = { ...ghuiColors }
179
+
180
+ export const getThemeDefinition = (id: ThemeId) => themeDefinitions.find((theme) => theme.id === id) ?? themeDefinitions[0]!
181
+
182
+ export const setActiveTheme = (id: ThemeId) => {
183
+ activeTheme = getThemeDefinition(id)
184
+ Object.assign(colors, activeTheme.colors)
185
+ }
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,12 @@ export interface MergeModalState {
25
25
  readonly error: string | null
26
26
  }
27
27
 
28
+ export interface ThemeModalState {
29
+ readonly open: boolean
30
+ readonly selectedIndex: number
31
+ readonly initialThemeId: ThemeId
32
+ }
33
+
28
34
  export const initialLabelModalState: LabelModalState = {
29
35
  open: false,
30
36
  repository: null,
@@ -45,6 +51,12 @@ export const initialMergeModalState: MergeModalState = {
45
51
  error: null,
46
52
  }
47
53
 
54
+ export const initialThemeModalState: ThemeModalState = {
55
+ open: false,
56
+ selectedIndex: 0,
57
+ initialThemeId: "ghui",
58
+ }
59
+
48
60
  const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
49
61
  if (!info) return "Loading merge status from GitHub."
50
62
  if (info.state !== "open") return "This pull request is not open."
@@ -247,3 +259,85 @@ export const MergeModal = ({
247
259
  </ModalFrame>
248
260
  )
249
261
  }
262
+
263
+ export const ThemeModal = ({
264
+ state,
265
+ activeThemeId,
266
+ modalWidth,
267
+ modalHeight,
268
+ offsetLeft,
269
+ offsetTop,
270
+ }: {
271
+ state: ThemeModalState
272
+ activeThemeId: ThemeId
273
+ modalWidth: number
274
+ modalHeight: number
275
+ offsetLeft: number
276
+ offsetTop: number
277
+ }) => {
278
+ const innerWidth = Math.max(16, modalWidth - 2)
279
+ const contentWidth = Math.max(14, innerWidth - 2)
280
+ const rowWidth = innerWidth
281
+ const maxVisible = Math.max(1, modalHeight - 7)
282
+ const selectedIndex = Math.max(0, Math.min(state.selectedIndex, themeDefinitions.length - 1))
283
+ const selectedTheme = themeDefinitions[selectedIndex]!
284
+ const scrollStart = Math.min(
285
+ Math.max(0, themeDefinitions.length - maxVisible),
286
+ Math.max(0, selectedIndex - maxVisible + 1),
287
+ )
288
+ const visibleThemes = themeDefinitions.slice(scrollStart, scrollStart + maxVisible)
289
+ const countText = `${selectedIndex + 1}/${themeDefinitions.length}`
290
+ const title = "Themes"
291
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
292
+
293
+ return (
294
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
295
+ <box height={1} paddingLeft={1} paddingRight={1}>
296
+ <TextLine>
297
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
298
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
299
+ <span fg={colors.muted}>{countText}</span>
300
+ </TextLine>
301
+ </box>
302
+ <box height={1} paddingLeft={1} paddingRight={1}>
303
+ <PlainLine text={fitCell(selectedTheme.description, contentWidth)} fg={colors.muted} />
304
+ </box>
305
+ <Divider width={innerWidth} />
306
+ <box height={maxVisible} flexDirection="column">
307
+ {visibleThemes.map((theme, index) => {
308
+ const actualIndex = scrollStart + index
309
+ const isSelected = actualIndex === selectedIndex
310
+ const isActive = theme.id === activeThemeId
311
+ const marker = isActive ? "✓" : " "
312
+ const swatchWidth = 6
313
+ const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
314
+
315
+ return (
316
+ <TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
317
+ <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
318
+ <span> </span>
319
+ <span>{fitCell(theme.name, nameWidth)}</span>
320
+ <span bg={theme.colors.background}> </span>
321
+ <span bg={theme.colors.panel}> </span>
322
+ <span bg={theme.colors.accent}> </span>
323
+ <span bg={theme.colors.status.passing}> </span>
324
+ <span bg={theme.colors.status.failing}> </span>
325
+ <span bg={theme.colors.status.review}> </span>
326
+ </TextLine>
327
+ )
328
+ })}
329
+ </box>
330
+ <Divider width={innerWidth} />
331
+ <box height={1} paddingLeft={1} paddingRight={1}>
332
+ <TextLine>
333
+ <span fg={colors.count}>↑↓</span>
334
+ <span fg={colors.muted}> preview </span>
335
+ <span fg={colors.count}>enter</span>
336
+ <span fg={colors.muted}> select </span>
337
+ <span fg={colors.count}>esc</span>
338
+ <span fg={colors.muted}> cancel</span>
339
+ </TextLine>
340
+ </box>
341
+ </ModalFrame>
342
+ )
343
+ }
@@ -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