@kitlangton/ghui 0.1.14 → 0.1.16

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.14",
3
+ "version": "0.1.16",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -11,7 +11,8 @@ 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, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
14
+ import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
15
+ import { colors, filterThemeDefinitions, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
15
16
  import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
16
17
  import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
17
18
  import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
@@ -22,6 +23,7 @@ import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
22
23
  import { PullRequestList } from "./ui/PullRequestList.js"
23
24
 
24
25
  const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
26
+ const initialThemeId = await Effect.runPromise(loadStoredThemeId)
25
27
 
26
28
  type LoadStatus = "loading" | "ready" | "error"
27
29
 
@@ -81,7 +83,7 @@ const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>
81
83
 
82
84
  const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
83
85
  const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
84
- const themeIdAtom = Atom.make<ThemeId>("ghui").pipe(Atom.keepAlive)
86
+ const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
85
87
  const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
86
88
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
87
89
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
@@ -264,6 +266,10 @@ export const App = () => {
264
266
  const [themeId, setThemeId] = useAtom(themeIdAtom)
265
267
  const [themeModal, setThemeModal] = useAtom(themeModalAtom)
266
268
  setActiveTheme(themeId)
269
+ const themeIdRef = useRef(themeId)
270
+ const themeModalRef = useRef(themeModal)
271
+ themeIdRef.current = themeId
272
+ themeModalRef.current = themeModal
267
273
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
268
274
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
269
275
  const retryProgress = useAtomValue(retryProgressAtom)
@@ -559,26 +565,59 @@ export const App = () => {
559
565
  setMergeModal(initialMergeModalState)
560
566
  setThemeModal({
561
567
  open: true,
568
+ query: "",
569
+ filterMode: false,
562
570
  initialThemeId: themeId,
563
571
  })
564
572
  }
565
573
 
566
574
  const closeThemeModal = (confirm: boolean) => {
567
- const selectedTheme = themeDefinitions.find((theme) => theme.id === themeId)
575
+ const selectedTheme = themeDefinitions.find((theme) => theme.id === themeIdRef.current)
568
576
  if (!confirm) {
569
577
  setThemeId(themeModal.initialThemeId)
570
578
  } else if (selectedTheme) {
579
+ void Effect.runPromise(saveStoredThemeId(selectedTheme.id)).catch((error) => flashNotice(errorMessage(error)))
571
580
  flashNotice(`Theme: ${selectedTheme.name}`)
572
581
  }
573
582
  setThemeModal(initialThemeModalState)
574
583
  }
575
584
 
585
+ const previewTheme = (id: ThemeId) => {
586
+ if (id === themeIdRef.current) return
587
+ themeIdRef.current = id
588
+ setThemeId(id)
589
+ }
590
+
576
591
  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))
592
+ const filteredThemes = filterThemeDefinitions(themeModalRef.current.query)
593
+ if (filteredThemes.length === 0) return
594
+ const currentIndex = Math.max(0, filteredThemes.findIndex((theme) => theme.id === themeIdRef.current))
595
+ const selectedIndex = Math.max(0, Math.min(filteredThemes.length - 1, currentIndex + delta))
579
596
  if (selectedIndex === currentIndex) return
580
- const theme = themeDefinitions[selectedIndex]
581
- if (theme && theme.id !== themeId) setThemeId(theme.id)
597
+ const theme = filteredThemes[selectedIndex]
598
+ if (theme) previewTheme(theme.id)
599
+ }
600
+
601
+ const updateThemeQuery = (query: string, options: { readonly previewFirst?: boolean; readonly filterMode?: boolean } = {}) => {
602
+ const current = themeModalRef.current
603
+ const next = {
604
+ ...current,
605
+ query,
606
+ filterMode: options.filterMode ?? current.filterMode,
607
+ }
608
+ if (next.query === current.query && next.filterMode === current.filterMode) return
609
+
610
+ themeModalRef.current = next
611
+ setThemeModal(next)
612
+
613
+ if (options.previewFirst && query.trim().length > 0) {
614
+ const firstTheme = filterThemeDefinitions(query)[0]
615
+ if (firstTheme) previewTheme(firstTheme.id)
616
+ }
617
+ }
618
+
619
+ const editThemeQuery = (transform: (query: string) => string) => {
620
+ updateThemeQuery(transform(themeModalRef.current.query), { previewFirst: true })
582
621
  }
583
622
 
584
623
  const openLabelModal = () => {
@@ -714,7 +753,7 @@ export const App = () => {
714
753
  }
715
754
 
716
755
  useKeyboard((key) => {
717
- if (key.name === "q" || (key.ctrl && key.name === "c")) {
756
+ if ((key.name === "q" && !(themeModal.open && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
718
757
  if (themeModal.open) {
719
758
  closeThemeModal(false)
720
759
  return
@@ -733,21 +772,42 @@ export const App = () => {
733
772
 
734
773
  if (themeModal.open) {
735
774
  if (key.name === "escape") {
775
+ if (themeModal.filterMode) {
776
+ updateThemeQuery("", { filterMode: false })
777
+ return
778
+ }
736
779
  closeThemeModal(false)
737
780
  return
738
781
  }
782
+ if (key.name === "/") {
783
+ updateThemeQuery("", { filterMode: true })
784
+ return
785
+ }
739
786
  if (key.name === "return" || key.name === "enter") {
787
+ if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
740
788
  closeThemeModal(true)
741
789
  return
742
790
  }
743
- if (key.name === "up" || key.name === "k") {
791
+ if (key.name === "up" || (!themeModal.filterMode && key.name === "k")) {
744
792
  moveThemeSelection(-1)
745
793
  return
746
794
  }
747
- if (key.name === "down" || key.name === "j") {
795
+ if (key.name === "down" || (!themeModal.filterMode && key.name === "j")) {
748
796
  moveThemeSelection(1)
749
797
  return
750
798
  }
799
+ if (themeModal.filterMode && key.name === "backspace") {
800
+ editThemeQuery((query) => query.slice(0, -1))
801
+ return
802
+ }
803
+ if (themeModal.filterMode && key.ctrl && key.name === "u") {
804
+ updateThemeQuery("")
805
+ return
806
+ }
807
+ if (themeModal.filterMode && !key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
808
+ editThemeQuery((query) => query + key.sequence)
809
+ return
810
+ }
751
811
  return
752
812
  }
753
813
 
@@ -1229,7 +1289,7 @@ export const App = () => {
1229
1289
 
1230
1290
  return (
1231
1291
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
1232
- <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.panel}>
1292
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.background}>
1233
1293
  <PlainLine text={headerLine} fg={colors.muted} bold />
1234
1294
  </box>
1235
1295
  {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
@@ -1321,7 +1381,7 @@ export const App = () => {
1321
1381
  ) : (
1322
1382
  <Divider width={contentWidth} />
1323
1383
  )}
1324
- <box paddingLeft={1} paddingRight={1} backgroundColor={colors.footer}>
1384
+ <box paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
1325
1385
  {footerNotice ? (
1326
1386
  <PlainLine text={footerNotice} fg={colors.count} />
1327
1387
  ) : (
@@ -0,0 +1,43 @@
1
+ import { mkdir } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { dirname, join } from "node:path"
4
+ import { Effect } from "effect"
5
+ import { isThemeId, type ThemeId } from "./ui/colors.js"
6
+
7
+ interface StoredConfig {
8
+ readonly theme?: unknown
9
+ }
10
+
11
+ const configDirectory = () => {
12
+ if (process.env.GHUI_CONFIG_DIR) return process.env.GHUI_CONFIG_DIR
13
+ if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "ghui")
14
+ if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "ghui")
15
+ return join(homedir(), ".config", "ghui")
16
+ }
17
+
18
+ export const configPath = () => join(configDirectory(), "config.json")
19
+
20
+ const parseConfig = (text: string): StoredConfig => {
21
+ const value = JSON.parse(text) as unknown
22
+ return value && typeof value === "object" ? value : {}
23
+ }
24
+
25
+ export const loadStoredThemeId: Effect.Effect<ThemeId> = Effect.catchCause(Effect.tryPromise(async () => {
26
+ const file = Bun.file(configPath())
27
+ if (!(await file.exists())) return "ghui" satisfies ThemeId
28
+
29
+ const config = parseConfig(await file.text())
30
+ return isThemeId(config.theme) ? config.theme : "ghui"
31
+ }), () => Effect.succeed("ghui" satisfies ThemeId))
32
+
33
+ export const saveStoredThemeId = (theme: ThemeId): Effect.Effect<void> => Effect.tryPromise(async () => {
34
+ const path = configPath()
35
+ const file = Bun.file(path)
36
+ const config = await file.exists()
37
+ ? parseConfig(await file.text())
38
+ : {}
39
+ if (config.theme === theme) return
40
+
41
+ await mkdir(dirname(path), { recursive: true })
42
+ await Bun.write(path, `${JSON.stringify({ ...config, theme }, null, "\t")}\n`)
43
+ })
package/src/ui/colors.ts CHANGED
@@ -1,9 +1,20 @@
1
- export type ThemeId = "ghui" | "tokyo-night" | "catppuccin" | "rose-pine" | "gruvbox" | "nord" | "dracula" | "opencode"
1
+ export type ThemeId =
2
+ | "ghui"
3
+ | "tokyo-night"
4
+ | "catppuccin"
5
+ | "rose-pine"
6
+ | "gruvbox"
7
+ | "nord"
8
+ | "dracula"
9
+ | "kanagawa"
10
+ | "one-dark"
11
+ | "monokai"
12
+ | "solarized-dark"
13
+ | "everforest"
14
+ | "opencode"
2
15
 
3
16
  export interface ColorPalette {
4
17
  readonly background: string
5
- readonly panel: string
6
- readonly footer: string
7
18
  readonly modalBackground: string
8
19
  readonly text: string
9
20
  readonly muted: string
@@ -50,8 +61,6 @@ export interface ThemeDefinition {
50
61
 
51
62
  const ghuiColors: ColorPalette = {
52
63
  background: "#111018",
53
- panel: "#161923",
54
- footer: "#1d2430",
55
64
  modalBackground: "#1a1a2e",
56
65
  text: "#ede7da",
57
66
  muted: "#9f9788",
@@ -91,8 +100,6 @@ const ghuiColors: ColorPalette = {
91
100
 
92
101
  const tokyoNightColors: ColorPalette = {
93
102
  background: "#1a1b26",
94
- panel: "#16161e",
95
- footer: "#24283b",
96
103
  modalBackground: "#24283b",
97
104
  text: "#c0caf5",
98
105
  muted: "#787c99",
@@ -132,8 +139,6 @@ const tokyoNightColors: ColorPalette = {
132
139
 
133
140
  const opencodeColors: ColorPalette = {
134
141
  background: "#0a0a0a",
135
- panel: "#141414",
136
- footer: "#1e1e1e",
137
142
  modalBackground: "#1e1e1e",
138
143
  text: "#eeeeee",
139
144
  muted: "#808080",
@@ -173,8 +178,6 @@ const opencodeColors: ColorPalette = {
173
178
 
174
179
  const catppuccinColors: ColorPalette = {
175
180
  background: "#1e1e2e",
176
- panel: "#181825",
177
- footer: "#313244",
178
181
  modalBackground: "#313244",
179
182
  text: "#cdd6f4",
180
183
  muted: "#7f849c",
@@ -214,8 +217,6 @@ const catppuccinColors: ColorPalette = {
214
217
 
215
218
  const rosePineColors: ColorPalette = {
216
219
  background: "#191724",
217
- panel: "#1f1d2e",
218
- footer: "#26233a",
219
220
  modalBackground: "#26233a",
220
221
  text: "#e0def4",
221
222
  muted: "#908caa",
@@ -255,8 +256,6 @@ const rosePineColors: ColorPalette = {
255
256
 
256
257
  const gruvboxColors: ColorPalette = {
257
258
  background: "#282828",
258
- panel: "#1d2021",
259
- footer: "#3c3836",
260
259
  modalBackground: "#3c3836",
261
260
  text: "#ebdbb2",
262
261
  muted: "#928374",
@@ -296,8 +295,6 @@ const gruvboxColors: ColorPalette = {
296
295
 
297
296
  const nordColors: ColorPalette = {
298
297
  background: "#2e3440",
299
- panel: "#242933",
300
- footer: "#3b4252",
301
298
  modalBackground: "#3b4252",
302
299
  text: "#eceff4",
303
300
  muted: "#8892a7",
@@ -337,8 +334,6 @@ const nordColors: ColorPalette = {
337
334
 
338
335
  const draculaColors: ColorPalette = {
339
336
  background: "#282a36",
340
- panel: "#21222c",
341
- footer: "#343746",
342
337
  modalBackground: "#343746",
343
338
  text: "#f8f8f2",
344
339
  muted: "#8f94b8",
@@ -376,6 +371,201 @@ const draculaColors: ColorPalette = {
376
371
  },
377
372
  }
378
373
 
374
+ const kanagawaColors: ColorPalette = {
375
+ background: "#1f1f28",
376
+ modalBackground: "#2a2a37",
377
+ text: "#dcd7ba",
378
+ muted: "#727169",
379
+ separator: "#54546d",
380
+ accent: "#7e9cd8",
381
+ inlineCode: "#d27e99",
382
+ error: "#c34043",
383
+ selectedBg: "#363646",
384
+ selectedText: "#fff7d6",
385
+ count: "#ffa066",
386
+ status: {
387
+ draft: "#c0a36e",
388
+ approved: "#76946a",
389
+ changes: "#c34043",
390
+ review: "#7e9cd8",
391
+ none: "#727169",
392
+ passing: "#76946a",
393
+ pending: "#c0a36e",
394
+ failing: "#c34043",
395
+ },
396
+ repos: {
397
+ opencode: "#7e9cd8",
398
+ "effect-smol": "#98bb6c",
399
+ "opencode-console": "#957fb8",
400
+ opencontrol: "#ffa066",
401
+ default: "#7fb4ca",
402
+ },
403
+ diff: {
404
+ addedBg: "#253326",
405
+ removedBg: "#3a2528",
406
+ contextBg: "transparent",
407
+ lineNumberBg: "#16161d",
408
+ addedLineNumberBg: "#223025",
409
+ removedLineNumberBg: "#352326",
410
+ },
411
+ }
412
+
413
+ const oneDarkColors: ColorPalette = {
414
+ background: "#282c34",
415
+ modalBackground: "#2c313c",
416
+ text: "#abb2bf",
417
+ muted: "#7f848e",
418
+ separator: "#4b5263",
419
+ accent: "#61afef",
420
+ inlineCode: "#c678dd",
421
+ error: "#e06c75",
422
+ selectedBg: "#3e4451",
423
+ selectedText: "#ffffff",
424
+ count: "#d19a66",
425
+ status: {
426
+ draft: "#e5c07b",
427
+ approved: "#98c379",
428
+ changes: "#e06c75",
429
+ review: "#61afef",
430
+ none: "#7f848e",
431
+ passing: "#98c379",
432
+ pending: "#e5c07b",
433
+ failing: "#e06c75",
434
+ },
435
+ repos: {
436
+ opencode: "#61afef",
437
+ "effect-smol": "#98c379",
438
+ "opencode-console": "#c678dd",
439
+ opencontrol: "#d19a66",
440
+ default: "#56b6c2",
441
+ },
442
+ diff: {
443
+ addedBg: "#27362b",
444
+ removedBg: "#3a282c",
445
+ contextBg: "transparent",
446
+ lineNumberBg: "#21252b",
447
+ addedLineNumberBg: "#243228",
448
+ removedLineNumberBg: "#35262a",
449
+ },
450
+ }
451
+
452
+ const monokaiColors: ColorPalette = {
453
+ background: "#272822",
454
+ modalBackground: "#383830",
455
+ text: "#f8f8f2",
456
+ muted: "#90908a",
457
+ separator: "#5b5b50",
458
+ accent: "#66d9ef",
459
+ inlineCode: "#ae81ff",
460
+ error: "#f92672",
461
+ selectedBg: "#49483e",
462
+ selectedText: "#ffffff",
463
+ count: "#fd971f",
464
+ status: {
465
+ draft: "#e6db74",
466
+ approved: "#a6e22e",
467
+ changes: "#f92672",
468
+ review: "#66d9ef",
469
+ none: "#90908a",
470
+ passing: "#a6e22e",
471
+ pending: "#e6db74",
472
+ failing: "#f92672",
473
+ },
474
+ repos: {
475
+ opencode: "#66d9ef",
476
+ "effect-smol": "#a6e22e",
477
+ "opencode-console": "#ae81ff",
478
+ opencontrol: "#fd971f",
479
+ default: "#a1efe4",
480
+ },
481
+ diff: {
482
+ addedBg: "#2f3a22",
483
+ removedBg: "#3d2430",
484
+ contextBg: "transparent",
485
+ lineNumberBg: "#1f201b",
486
+ addedLineNumberBg: "#2b3620",
487
+ removedLineNumberBg: "#38222d",
488
+ },
489
+ }
490
+
491
+ const solarizedDarkColors: ColorPalette = {
492
+ background: "#002b36",
493
+ modalBackground: "#123d48",
494
+ text: "#eee8d5",
495
+ muted: "#839496",
496
+ separator: "#586e75",
497
+ accent: "#268bd2",
498
+ inlineCode: "#2aa198",
499
+ error: "#dc322f",
500
+ selectedBg: "#174652",
501
+ selectedText: "#fdf6e3",
502
+ count: "#cb4b16",
503
+ status: {
504
+ draft: "#b58900",
505
+ approved: "#859900",
506
+ changes: "#dc322f",
507
+ review: "#268bd2",
508
+ none: "#839496",
509
+ passing: "#859900",
510
+ pending: "#b58900",
511
+ failing: "#dc322f",
512
+ },
513
+ repos: {
514
+ opencode: "#268bd2",
515
+ "effect-smol": "#859900",
516
+ "opencode-console": "#d33682",
517
+ opencontrol: "#cb4b16",
518
+ default: "#2aa198",
519
+ },
520
+ diff: {
521
+ addedBg: "#123c2e",
522
+ removedBg: "#3c262a",
523
+ contextBg: "transparent",
524
+ lineNumberBg: "#073642",
525
+ addedLineNumberBg: "#10372b",
526
+ removedLineNumberBg: "#362429",
527
+ },
528
+ }
529
+
530
+ const everforestColors: ColorPalette = {
531
+ background: "#2d353b",
532
+ modalBackground: "#343f44",
533
+ text: "#d3c6aa",
534
+ muted: "#859289",
535
+ separator: "#56635f",
536
+ accent: "#7fbbb3",
537
+ inlineCode: "#d699b6",
538
+ error: "#e67e80",
539
+ selectedBg: "#465258",
540
+ selectedText: "#fff4d6",
541
+ count: "#e69875",
542
+ status: {
543
+ draft: "#dbbc7f",
544
+ approved: "#a7c080",
545
+ changes: "#e67e80",
546
+ review: "#7fbbb3",
547
+ none: "#859289",
548
+ passing: "#a7c080",
549
+ pending: "#dbbc7f",
550
+ failing: "#e67e80",
551
+ },
552
+ repos: {
553
+ opencode: "#7fbbb3",
554
+ "effect-smol": "#a7c080",
555
+ "opencode-console": "#d699b6",
556
+ opencontrol: "#e69875",
557
+ default: "#83c092",
558
+ },
559
+ diff: {
560
+ addedBg: "#33422f",
561
+ removedBg: "#463333",
562
+ contextBg: "transparent",
563
+ lineNumberBg: "#232a2e",
564
+ addedLineNumberBg: "#303d2d",
565
+ removedLineNumberBg: "#403030",
566
+ },
567
+ }
568
+
379
569
  export const themeDefinitions: readonly ThemeDefinition[] = [
380
570
  { id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
381
571
  { id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
@@ -384,6 +574,11 @@ export const themeDefinitions: readonly ThemeDefinition[] = [
384
574
  { id: "gruvbox", name: "Gruvbox", description: "Retro warm earth tones with punchy semantic accents", colors: gruvboxColors },
385
575
  { id: "nord", name: "Nord", description: "Arctic blue-gray surfaces with frosty accents", colors: nordColors },
386
576
  { id: "dracula", name: "Dracula", description: "High-contrast purple, pink, cyan, and green", colors: draculaColors },
577
+ { id: "kanagawa", name: "Kanagawa", description: "Ink-wash indigo, wave blues, and autumn accents", colors: kanagawaColors },
578
+ { id: "one-dark", name: "One Dark", description: "Atom-style charcoal with clean blue and green accents", colors: oneDarkColors },
579
+ { id: "monokai", name: "Monokai", description: "Classic dark olive with electric syntax colors", colors: monokaiColors },
580
+ { id: "solarized-dark", name: "Solarized Dark", description: "Low-contrast blue-green base with calibrated accents", colors: solarizedDarkColors },
581
+ { id: "everforest", name: "Everforest", description: "Soft green-gray forest tones with warm highlights", colors: everforestColors },
387
582
  { id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
388
583
  ] as const
389
584
 
@@ -393,6 +588,18 @@ export const colors: ColorPalette = { ...ghuiColors }
393
588
 
394
589
  export const getThemeDefinition = (id: ThemeId) => themeDefinitions.find((theme) => theme.id === id) ?? themeDefinitions[0]!
395
590
 
591
+ export const isThemeId = (value: unknown): value is ThemeId => typeof value === "string" && themeDefinitions.some((theme) => theme.id === value)
592
+
593
+ export const filterThemeDefinitions = (query: string) => {
594
+ const normalized = query.trim().toLowerCase()
595
+ if (normalized.length === 0) return themeDefinitions
596
+ return themeDefinitions.filter((theme) =>
597
+ theme.id.includes(normalized) ||
598
+ theme.name.toLowerCase().includes(normalized) ||
599
+ theme.description.toLowerCase().includes(normalized),
600
+ )
601
+ }
602
+
396
603
  export const setActiveTheme = (id: ThemeId) => {
397
604
  if (activeTheme.id === id) return
398
605
  activeTheme = getThemeDefinition(id)
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, themeDefinitions, type ThemeId } from "./colors.js"
4
+ import { colors, filterThemeDefinitions, 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
 
@@ -27,6 +27,8 @@ export interface MergeModalState {
27
27
 
28
28
  export interface ThemeModalState {
29
29
  readonly open: boolean
30
+ readonly query: string
31
+ readonly filterMode: boolean
30
32
  readonly initialThemeId: ThemeId
31
33
  }
32
34
 
@@ -52,6 +54,8 @@ export const initialMergeModalState: MergeModalState = {
52
54
 
53
55
  export const initialThemeModalState: ThemeModalState = {
54
56
  open: false,
57
+ query: "",
58
+ filterMode: false,
55
59
  initialThemeId: "ghui",
56
60
  }
57
61
 
@@ -276,18 +280,24 @@ export const ThemeModal = ({
276
280
  const innerWidth = Math.max(16, modalWidth - 2)
277
281
  const contentWidth = Math.max(14, innerWidth - 2)
278
282
  const rowWidth = innerWidth
283
+ const filteredThemes = filterThemeDefinitions(state.query)
279
284
  const maxVisible = Math.max(1, modalHeight - 7)
280
- const activeIndex = themeDefinitions.findIndex((theme) => theme.id === activeThemeId)
285
+ const activeIndex = filteredThemes.findIndex((theme) => theme.id === activeThemeId)
281
286
  const selectedIndex = Math.max(0, activeIndex)
282
- const selectedTheme = themeDefinitions[selectedIndex]!
287
+ const selectedTheme = filteredThemes[selectedIndex] ?? themeDefinitions.find((theme) => theme.id === activeThemeId) ?? themeDefinitions[0]!
283
288
  const scrollStart = Math.min(
284
- Math.max(0, themeDefinitions.length - maxVisible),
289
+ Math.max(0, filteredThemes.length - maxVisible),
285
290
  Math.max(0, selectedIndex - maxVisible + 1),
286
291
  )
287
- const visibleThemes = themeDefinitions.slice(scrollStart, scrollStart + maxVisible)
288
- const countText = `${selectedIndex + 1}/${themeDefinitions.length}`
292
+ const visibleThemes = filteredThemes.slice(scrollStart, scrollStart + maxVisible)
293
+ const countText = `${filteredThemes.length === 0 ? 0 : selectedIndex + 1}/${filteredThemes.length}`
289
294
  const title = "Themes"
290
295
  const headerGap = Math.max(1, contentWidth - title.length - countText.length)
296
+ const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
297
+ const queryPrefix = "/ "
298
+ const subtitleWidth = Math.max(1, contentWidth - (state.filterMode ? queryPrefix.length : 0))
299
+ const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
300
+ const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
291
301
 
292
302
  return (
293
303
  <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
@@ -299,11 +309,24 @@ export const ThemeModal = ({
299
309
  </TextLine>
300
310
  </box>
301
311
  <box height={1} paddingLeft={1} paddingRight={1}>
302
- <PlainLine text={fitCell(selectedTheme.description, contentWidth)} fg={colors.muted} />
312
+ {state.filterMode ? (
313
+ <TextLine>
314
+ <span fg={colors.count}>{queryPrefix}</span>
315
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
316
+ </TextLine>
317
+ ) : (
318
+ <PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
319
+ )}
303
320
  </box>
304
321
  <Divider width={innerWidth} />
305
322
  <box height={maxVisible} flexDirection="column">
306
- {visibleThemes.map((theme, index) => {
323
+ {visibleThemes.length === 0 ? (
324
+ <>
325
+ {Array.from({ length: messageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
326
+ <PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
327
+ {Array.from({ length: messageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
328
+ </>
329
+ ) : visibleThemes.map((theme, index) => {
307
330
  const actualIndex = scrollStart + index
308
331
  const isSelected = actualIndex === selectedIndex
309
332
  const isActive = theme.id === activeThemeId
@@ -317,7 +340,7 @@ export const ThemeModal = ({
317
340
  <span> </span>
318
341
  <span>{fitCell(theme.name, nameWidth)}</span>
319
342
  <span bg={theme.colors.background}> </span>
320
- <span bg={theme.colors.panel}> </span>
343
+ <span bg={theme.colors.modalBackground}> </span>
321
344
  <span bg={theme.colors.accent}> </span>
322
345
  <span bg={theme.colors.status.passing}> </span>
323
346
  <span bg={theme.colors.status.failing}> </span>
@@ -331,6 +354,8 @@ export const ThemeModal = ({
331
354
  <TextLine>
332
355
  <span fg={colors.count}>↑↓</span>
333
356
  <span fg={colors.muted}> preview </span>
357
+ <span fg={colors.count}>/</span>
358
+ <span fg={colors.muted}> filter </span>
334
359
  <span fg={colors.count}>enter</span>
335
360
  <span fg={colors.muted}> select </span>
336
361
  <span fg={colors.count}>esc</span>