@kitlangton/ghui 0.1.13 → 0.1.15

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.13",
3
+ "version": "0.1.15",
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,7 @@ 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 { colors, filterThemeDefinitions, 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"
@@ -264,6 +264,10 @@ export const App = () => {
264
264
  const [themeId, setThemeId] = useAtom(themeIdAtom)
265
265
  const [themeModal, setThemeModal] = useAtom(themeModalAtom)
266
266
  setActiveTheme(themeId)
267
+ const themeIdRef = useRef(themeId)
268
+ const themeModalRef = useRef(themeModal)
269
+ themeIdRef.current = themeId
270
+ themeModalRef.current = themeModal
267
271
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
268
272
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
269
273
  const retryProgress = useAtomValue(retryProgressAtom)
@@ -559,12 +563,14 @@ export const App = () => {
559
563
  setMergeModal(initialMergeModalState)
560
564
  setThemeModal({
561
565
  open: true,
566
+ query: "",
567
+ filterMode: false,
562
568
  initialThemeId: themeId,
563
569
  })
564
570
  }
565
571
 
566
572
  const closeThemeModal = (confirm: boolean) => {
567
- const selectedTheme = themeDefinitions.find((theme) => theme.id === themeId)
573
+ const selectedTheme = themeDefinitions.find((theme) => theme.id === themeIdRef.current)
568
574
  if (!confirm) {
569
575
  setThemeId(themeModal.initialThemeId)
570
576
  } else if (selectedTheme) {
@@ -573,12 +579,42 @@ export const App = () => {
573
579
  setThemeModal(initialThemeModalState)
574
580
  }
575
581
 
582
+ const previewTheme = (id: ThemeId) => {
583
+ if (id === themeIdRef.current) return
584
+ themeIdRef.current = id
585
+ setThemeId(id)
586
+ }
587
+
576
588
  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))
589
+ const filteredThemes = filterThemeDefinitions(themeModalRef.current.query)
590
+ if (filteredThemes.length === 0) return
591
+ const currentIndex = Math.max(0, filteredThemes.findIndex((theme) => theme.id === themeIdRef.current))
592
+ const selectedIndex = Math.max(0, Math.min(filteredThemes.length - 1, currentIndex + delta))
579
593
  if (selectedIndex === currentIndex) return
580
- const theme = themeDefinitions[selectedIndex]
581
- if (theme && theme.id !== themeId) setThemeId(theme.id)
594
+ const theme = filteredThemes[selectedIndex]
595
+ if (theme) previewTheme(theme.id)
596
+ }
597
+
598
+ const updateThemeQuery = (query: string, options: { readonly previewFirst?: boolean; readonly filterMode?: boolean } = {}) => {
599
+ const current = themeModalRef.current
600
+ const next = {
601
+ ...current,
602
+ query,
603
+ filterMode: options.filterMode ?? current.filterMode,
604
+ }
605
+ if (next.query === current.query && next.filterMode === current.filterMode) return
606
+
607
+ themeModalRef.current = next
608
+ setThemeModal(next)
609
+
610
+ if (options.previewFirst && query.trim().length > 0) {
611
+ const firstTheme = filterThemeDefinitions(query)[0]
612
+ if (firstTheme) previewTheme(firstTheme.id)
613
+ }
614
+ }
615
+
616
+ const editThemeQuery = (transform: (query: string) => string) => {
617
+ updateThemeQuery(transform(themeModalRef.current.query), { previewFirst: true })
582
618
  }
583
619
 
584
620
  const openLabelModal = () => {
@@ -714,7 +750,7 @@ export const App = () => {
714
750
  }
715
751
 
716
752
  useKeyboard((key) => {
717
- if (key.name === "q" || (key.ctrl && key.name === "c")) {
753
+ if ((key.name === "q" && !(themeModal.open && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
718
754
  if (themeModal.open) {
719
755
  closeThemeModal(false)
720
756
  return
@@ -733,21 +769,42 @@ export const App = () => {
733
769
 
734
770
  if (themeModal.open) {
735
771
  if (key.name === "escape") {
772
+ if (themeModal.filterMode) {
773
+ updateThemeQuery("", { filterMode: false })
774
+ return
775
+ }
736
776
  closeThemeModal(false)
737
777
  return
738
778
  }
779
+ if (key.name === "/") {
780
+ updateThemeQuery("", { filterMode: true })
781
+ return
782
+ }
739
783
  if (key.name === "return" || key.name === "enter") {
784
+ if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
740
785
  closeThemeModal(true)
741
786
  return
742
787
  }
743
- if (key.name === "up" || key.name === "k") {
788
+ if (key.name === "up" || (!themeModal.filterMode && key.name === "k")) {
744
789
  moveThemeSelection(-1)
745
790
  return
746
791
  }
747
- if (key.name === "down" || key.name === "j") {
792
+ if (key.name === "down" || (!themeModal.filterMode && key.name === "j")) {
748
793
  moveThemeSelection(1)
749
794
  return
750
795
  }
796
+ if (themeModal.filterMode && key.name === "backspace") {
797
+ editThemeQuery((query) => query.slice(0, -1))
798
+ return
799
+ }
800
+ if (themeModal.filterMode && key.ctrl && key.name === "u") {
801
+ updateThemeQuery("")
802
+ return
803
+ }
804
+ if (themeModal.filterMode && !key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
805
+ editThemeQuery((query) => query + key.sequence)
806
+ return
807
+ }
751
808
  return
752
809
  }
753
810
 
@@ -1229,7 +1286,7 @@ export const App = () => {
1229
1286
 
1230
1287
  return (
1231
1288
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
1232
- <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.panel}>
1289
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.background}>
1233
1290
  <PlainLine text={headerLine} fg={colors.muted} bold />
1234
1291
  </box>
1235
1292
  {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
@@ -1263,6 +1320,7 @@ export const App = () => {
1263
1320
  showChecks
1264
1321
  placeholderContent={detailPlaceholderContent}
1265
1322
  loadingIndicator={loadingIndicator}
1323
+ themeId={themeId}
1266
1324
  />
1267
1325
  </scrollbox>
1268
1326
  </box>
@@ -1279,7 +1337,7 @@ export const App = () => {
1279
1337
  <>
1280
1338
  <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
1281
1339
  <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
1282
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} />
1340
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} themeId={themeId} />
1283
1341
  </scrollbox>
1284
1342
  </>
1285
1343
  ) : (
@@ -1297,12 +1355,13 @@ export const App = () => {
1297
1355
  paneWidth={contentWidth}
1298
1356
  placeholderContent={detailPlaceholderContent}
1299
1357
  loadingIndicator={loadingIndicator}
1358
+ themeId={themeId}
1300
1359
  />
1301
1360
  </scrollbox>
1302
1361
  </box>
1303
1362
  ) : (
1304
1363
  <box height={wideBodyHeight} flexDirection="column">
1305
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} />
1364
+ <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
1306
1365
  <Divider width={contentWidth} />
1307
1366
  <box flexGrow={1} flexDirection="column">
1308
1367
  <scrollbox flexGrow={1}>
@@ -1319,7 +1378,7 @@ export const App = () => {
1319
1378
  ) : (
1320
1379
  <Divider width={contentWidth} />
1321
1380
  )}
1322
- <box paddingLeft={1} paddingRight={1} backgroundColor={colors.footer}>
1381
+ <box paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
1323
1382
  {footerNotice ? (
1324
1383
  <PlainLine text={footerNotice} fg={colors.count} />
1325
1384
  ) : (
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
2
2
  import { Fragment, useMemo } from "react"
3
3
  import { formatRelativeDate } from "../date.js"
4
4
  import type { CheckItem, PullRequestItem } from "../domain.js"
5
- import { colors } from "./colors.js"
5
+ import { colors, type ThemeId } from "./colors.js"
6
6
  import { diffStatText } from "./diff.js"
7
7
  import { centerCell, Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
8
8
  import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
@@ -387,15 +387,17 @@ export const DetailBody = ({
387
387
  contentWidth,
388
388
  bodyLines = DETAIL_BODY_LINES,
389
389
  loadingIndicator,
390
+ themeId,
390
391
  }: {
391
392
  pullRequest: PullRequestItem
392
393
  contentWidth: number
393
394
  bodyLines?: number
394
395
  loadingIndicator: string
396
+ themeId: ThemeId
395
397
  }) => {
396
398
  const previewLines = useMemo(
397
399
  () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
398
- [pullRequest.body, contentWidth, bodyLines],
400
+ [pullRequest.body, contentWidth, bodyLines, themeId],
399
401
  )
400
402
 
401
403
  if (!pullRequest.detailLoaded) {
@@ -486,6 +488,7 @@ export const DetailsPane = ({
486
488
  showChecks = false,
487
489
  placeholderContent,
488
490
  loadingIndicator,
491
+ themeId,
489
492
  }: {
490
493
  pullRequest: PullRequestItem | null
491
494
  contentWidth: number
@@ -494,6 +497,7 @@ export const DetailsPane = ({
494
497
  showChecks?: boolean
495
498
  placeholderContent: DetailPlaceholderContent
496
499
  loadingIndicator: string
500
+ themeId: ThemeId
497
501
  }) => {
498
502
  const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines, paneWidth, showChecks })
499
503
 
@@ -502,7 +506,7 @@ export const DetailsPane = ({
502
506
  {pullRequest ? (
503
507
  <>
504
508
  <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
505
- <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} />
509
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} themeId={themeId} />
506
510
  </>
507
511
  ) : (
508
512
  <>
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,16 @@ 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 filterThemeDefinitions = (query: string) => {
592
+ const normalized = query.trim().toLowerCase()
593
+ if (normalized.length === 0) return themeDefinitions
594
+ return themeDefinitions.filter((theme) =>
595
+ theme.id.includes(normalized) ||
596
+ theme.name.toLowerCase().includes(normalized) ||
597
+ theme.description.toLowerCase().includes(normalized),
598
+ )
599
+ }
600
+
396
601
  export const setActiveTheme = (id: ThemeId) => {
397
602
  if (activeTheme.id === id) return
398
603
  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,21 +280,27 @@ 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
279
- const maxVisible = Math.max(1, modalHeight - 7)
280
- const activeIndex = themeDefinitions.findIndex((theme) => theme.id === activeThemeId)
283
+ const filteredThemes = filterThemeDefinitions(state.query)
284
+ const maxVisible = Math.max(1, modalHeight - 8)
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 queryText = state.query.length > 0 ? state.query : "type to filter themes"
297
+ const queryPrefix = "/ "
298
+ const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
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
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
303
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[3, modalHeight - 4]}>
294
304
  <box height={1} paddingLeft={1} paddingRight={1}>
295
305
  <TextLine>
296
306
  <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
@@ -301,9 +311,21 @@ export const ThemeModal = ({
301
311
  <box height={1} paddingLeft={1} paddingRight={1}>
302
312
  <PlainLine text={fitCell(selectedTheme.description, contentWidth)} fg={colors.muted} />
303
313
  </box>
314
+ <box height={1} paddingLeft={1} paddingRight={1}>
315
+ <TextLine>
316
+ <span fg={colors.count}>{queryPrefix}</span>
317
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
318
+ </TextLine>
319
+ </box>
304
320
  <Divider width={innerWidth} />
305
321
  <box height={maxVisible} flexDirection="column">
306
- {visibleThemes.map((theme, index) => {
322
+ {visibleThemes.length === 0 ? (
323
+ <>
324
+ {Array.from({ length: messageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
325
+ <PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
326
+ {Array.from({ length: messageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
327
+ </>
328
+ ) : visibleThemes.map((theme, index) => {
307
329
  const actualIndex = scrollStart + index
308
330
  const isSelected = actualIndex === selectedIndex
309
331
  const isActive = theme.id === activeThemeId
@@ -317,7 +339,7 @@ export const ThemeModal = ({
317
339
  <span> </span>
318
340
  <span>{fitCell(theme.name, nameWidth)}</span>
319
341
  <span bg={theme.colors.background}> </span>
320
- <span bg={theme.colors.panel}> </span>
342
+ <span bg={theme.colors.modalBackground}> </span>
321
343
  <span bg={theme.colors.accent}> </span>
322
344
  <span bg={theme.colors.status.passing}> </span>
323
345
  <span bg={theme.colors.status.failing}> </span>
@@ -331,6 +353,8 @@ export const ThemeModal = ({
331
353
  <TextLine>
332
354
  <span fg={colors.count}>↑↓</span>
333
355
  <span fg={colors.muted}> preview </span>
356
+ <span fg={colors.count}>/</span>
357
+ <span fg={colors.muted}> filter </span>
334
358
  <span fg={colors.count}>enter</span>
335
359
  <span fg={colors.muted}> select </span>
336
360
  <span fg={colors.count}>esc</span>