@kitlangton/ghui 0.1.16 → 0.1.18

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.
@@ -298,11 +298,13 @@ export const getDetailsPaneHeight = ({
298
298
 
299
299
  export const DetailHeader = ({
300
300
  pullRequest,
301
+ viewerUsername,
301
302
  contentWidth,
302
303
  paneWidth,
303
304
  showChecks = false,
304
305
  }: {
305
306
  pullRequest: PullRequestItem
307
+ viewerUsername: string | null
306
308
  contentWidth: number
307
309
  paneWidth: number
308
310
  showChecks?: boolean
@@ -319,34 +321,31 @@ export const DetailHeader = ({
319
321
  : "no labels".length
320
322
  const showStats = contentWidth - labelsWidth - statsText.length >= 2
321
323
  const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
324
+ const opened = formatRelativeDate(pullRequest.createdAt)
325
+ const repo = shortRepoName(pullRequest.repository)
326
+ const author = viewerUsername && pullRequest.author !== viewerUsername ? ` by ${pullRequest.author}` : ""
327
+ const number = String(pullRequest.number)
328
+ const review = reviewLabel(pullRequest)
329
+ const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
330
+ const statusParts = [review, checks].filter((part): part is string => Boolean(part))
331
+ const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
332
+ const leftWidth = 1 + number.length + 1 + repo.length + author.length
333
+ const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
322
334
 
323
335
  return (
324
336
  <>
325
337
  <box height={1} paddingLeft={1} paddingRight={1}>
326
- {(() => {
327
- const opened = formatRelativeDate(pullRequest.createdAt)
328
- const repo = shortRepoName(pullRequest.repository)
329
- const number = String(pullRequest.number)
330
- const review = reviewLabel(pullRequest)
331
- const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
332
- const statusParts = [review, checks].filter((part): part is string => Boolean(part))
333
- const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
334
- const leftWidth = 1 + number.length + 1 + repo.length
335
- const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
336
-
337
- return (
338
- <TextLine>
339
- <span fg={colors.count}>#{number}</span>
340
- <span fg={colors.muted}> {repo}</span>
341
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
342
- {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
343
- {review && checks ? <span fg={colors.muted}> </span> : null}
344
- {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
345
- {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
346
- <span fg={colors.muted}>{opened}</span>
347
- </TextLine>
348
- )
349
- })()}
338
+ <TextLine>
339
+ <span fg={colors.count}>#{number}</span>
340
+ <span fg={colors.muted}> {repo}</span>
341
+ {author ? <span fg={colors.muted}>{author}</span> : null}
342
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
343
+ {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
344
+ {review && checks ? <span fg={colors.muted}> </span> : null}
345
+ {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
346
+ {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
347
+ <span fg={colors.muted}>{opened}</span>
348
+ </TextLine>
350
349
  </box>
351
350
  <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
352
351
  {wrappedTitle.map((line, index) => (
@@ -482,6 +481,7 @@ export const LoadingPane = ({ content, width, height }: { content: DetailPlaceho
482
481
 
483
482
  export const DetailsPane = ({
484
483
  pullRequest,
484
+ viewerUsername,
485
485
  contentWidth,
486
486
  bodyLines = DETAIL_BODY_LINES,
487
487
  paneWidth = contentWidth + 2,
@@ -491,6 +491,7 @@ export const DetailsPane = ({
491
491
  themeId,
492
492
  }: {
493
493
  pullRequest: PullRequestItem | null
494
+ viewerUsername: string | null
494
495
  contentWidth: number
495
496
  bodyLines?: number
496
497
  paneWidth?: number
@@ -505,7 +506,7 @@ export const DetailsPane = ({
505
506
  <box flexDirection="column" height={contentHeight}>
506
507
  {pullRequest ? (
507
508
  <>
508
- <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
509
+ <DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
509
510
  <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} themeId={themeId} />
510
511
  </>
511
512
  ) : (
@@ -11,7 +11,9 @@ export const FooterHints = ({
11
11
  showFilterClear,
12
12
  detailFullView,
13
13
  diffFullView,
14
+ diffCommentMode,
14
15
  hasSelection,
16
+ canCloseSelection,
15
17
  hasError,
16
18
  isLoading,
17
19
  loadingIndicator,
@@ -21,7 +23,9 @@ export const FooterHints = ({
21
23
  showFilterClear: boolean
22
24
  detailFullView: boolean
23
25
  diffFullView: boolean
26
+ diffCommentMode: boolean
24
27
  hasSelection: boolean
28
+ canCloseSelection: boolean
25
29
  hasError: boolean
26
30
  isLoading: boolean
27
31
  loadingIndicator: string
@@ -47,6 +51,28 @@ export const FooterHints = ({
47
51
  }
48
52
 
49
53
  if (diffFullView) {
54
+ if (diffCommentMode) {
55
+ return (
56
+ <TextLine>
57
+ <span fg={colors.count}>↑↓</span>
58
+ <span fg={colors.muted}> line </span>
59
+ <span fg={colors.count}>pgup/pgdn</span>
60
+ <span fg={colors.muted}> jump </span>
61
+ <span fg={colors.count}>←→</span>
62
+ <span fg={colors.muted}> side </span>
63
+ <span fg={colors.count}>enter</span>
64
+ <span fg={colors.muted}> open </span>
65
+ <span fg={colors.count}>a</span>
66
+ <span fg={colors.muted}> comment </span>
67
+ <span fg={colors.count}>c</span>
68
+ <span fg={colors.muted}> done </span>
69
+ <span fg={colors.count}>[]</span>
70
+ <span fg={colors.muted}> files </span>
71
+ <span fg={colors.count}>esc</span>
72
+ <span fg={colors.muted}> back</span>
73
+ </TextLine>
74
+ )
75
+ }
50
76
  return (
51
77
  <TextLine>
52
78
  <span fg={colors.count}>esc</span>
@@ -55,6 +81,8 @@ export const FooterHints = ({
55
81
  <span fg={colors.muted}> view </span>
56
82
  <span fg={colors.count}>w</span>
57
83
  <span fg={colors.muted}> wrap </span>
84
+ <span fg={colors.count}>c</span>
85
+ <span fg={colors.muted}> comment </span>
58
86
  <span fg={colors.count}>[]</span>
59
87
  <span fg={colors.muted}> files </span>
60
88
  <span fg={colors.count}>r</span>
@@ -72,6 +100,30 @@ export const FooterHints = ({
72
100
  <TextLine>
73
101
  <span fg={colors.count}>esc</span>
74
102
  <span fg={colors.muted}> back </span>
103
+ <span fg={colors.count}>↑↓</span>
104
+ <span fg={colors.muted}> scroll </span>
105
+ <span fg={colors.count}>r</span>
106
+ <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
107
+ <span fg={colors.count}>t</span>
108
+ <span fg={colors.muted}> theme </span>
109
+ {hasSelection ? (
110
+ <>
111
+ <span fg={colors.count}>s</span>
112
+ <span fg={colors.muted}> state </span>
113
+ <span fg={colors.count}>d</span>
114
+ <span fg={colors.muted}> diff </span>
115
+ <span fg={colors.count}>l</span>
116
+ <span fg={colors.muted}> labels </span>
117
+ <span fg={colors.count}>m</span>
118
+ <span fg={colors.muted}> merge </span>
119
+ {canCloseSelection ? (
120
+ <>
121
+ <span fg={colors.count}>x</span>
122
+ <span fg={colors.muted}> close </span>
123
+ </>
124
+ ) : null}
125
+ </>
126
+ ) : null}
75
127
  <span fg={colors.count}>o</span>
76
128
  <span fg={colors.muted}> open </span>
77
129
  <span fg={colors.count}>y</span>
@@ -84,6 +136,8 @@ export const FooterHints = ({
84
136
 
85
137
  return (
86
138
  <TextLine>
139
+ <span fg={colors.count}>tab</span>
140
+ <span fg={colors.muted}> queue </span>
87
141
  <span fg={colors.count}>/</span>
88
142
  <span fg={colors.muted}> filter </span>
89
143
  <span fg={colors.count}>t</span>
@@ -117,6 +171,12 @@ export const FooterHints = ({
117
171
  <span fg={colors.muted}> labels </span>
118
172
  <span fg={colors.count}>m</span>
119
173
  <span fg={colors.muted}> merge </span>
174
+ {canCloseSelection ? (
175
+ <>
176
+ <span fg={colors.count}>x</span>
177
+ <span fg={colors.muted}> close </span>
178
+ </>
179
+ ) : null}
120
180
  <span fg={colors.count}>o</span>
121
181
  <span fg={colors.muted}> open </span>
122
182
  <span fg={colors.count}>y</span>
@@ -1,8 +1,8 @@
1
- import type { ScrollBoxRenderable } from "@opentui/core"
1
+ import type { DiffRenderable, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useMemo, type Ref } from "react"
3
- import type { PullRequestItem } from "../domain.js"
3
+ import type { PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
4
  import { colors, type ThemeId } from "./colors.js"
5
- import { createDiffSyntaxStyle, diffStatText, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
5
+ import { createDiffSyntaxStyle, diffFileStats, diffFileStatText, diffStatText, safeDiffFileIndex, type DiffCommentAnchor, type PullRequestDiffState, type StackedDiffFilePatch } 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"
@@ -27,9 +27,35 @@ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
27
27
  )
28
28
  }
29
29
 
30
+ const FileStats = ({ file }: { file: StackedDiffFilePatch["file"] }) => {
31
+ const stats = diffFileStats(file)
32
+ return (
33
+ <>
34
+ {stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
35
+ {stats.additions > 0 && stats.deletions > 0 ? <span fg={colors.muted}> </span> : null}
36
+ {stats.deletions > 0 ? <span fg={colors.status.failing}>{`-${stats.deletions}`}</span> : null}
37
+ </>
38
+ )
39
+ }
40
+
41
+ const FileHeader = ({ file, index, count, width }: { file: StackedDiffFilePatch["file"]; index: number; count: number; width: number }) => {
42
+ const counter = `${index + 1}/${count}`
43
+ const statsText = diffFileStatText(file)
44
+ const nameWidth = Math.max(1, width - counter.length - statsText.length - 5)
45
+ return (
46
+ <TextLine>
47
+ <span fg={colors.muted}>{counter} </span>
48
+ <span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
49
+ {statsText ? <span fg={colors.muted}> </span> : null}
50
+ <FileStats file={file} />
51
+ </TextLine>
52
+ )
53
+ }
54
+
30
55
  export const PullRequestDiffPane = ({
31
56
  pullRequest,
32
57
  diffState,
58
+ stackedFiles,
33
59
  fileIndex,
34
60
  view,
35
61
  wrapMode,
@@ -37,10 +63,16 @@ export const PullRequestDiffPane = ({
37
63
  height,
38
64
  loadingIndicator,
39
65
  scrollRef,
66
+ setDiffRef,
67
+ commentMode,
68
+ selectedCommentAnchor,
69
+ selectedCommentThread,
70
+ commentCount,
40
71
  themeId,
41
72
  }: {
42
73
  pullRequest: PullRequestItem | null
43
74
  diffState: PullRequestDiffState | undefined
75
+ stackedFiles: readonly StackedDiffFilePatch[]
44
76
  fileIndex: number
45
77
  view: "unified" | "split"
46
78
  wrapMode: "none" | "word"
@@ -48,15 +80,16 @@ export const PullRequestDiffPane = ({
48
80
  height: number
49
81
  loadingIndicator: string
50
82
  scrollRef: Ref<ScrollBoxRenderable>
83
+ setDiffRef: (index: number, diff: DiffRenderable | null) => void
84
+ commentMode: boolean
85
+ selectedCommentAnchor: DiffCommentAnchor | null
86
+ selectedCommentThread: readonly PullRequestReviewComment[]
87
+ commentCount: number
51
88
  themeId: ThemeId
52
89
  }) => {
53
90
  const readyFiles = diffState?.status === "ready" ? diffState.files : []
54
- const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
91
+ const safeIndex = safeDiffFileIndex(readyFiles, fileIndex)
55
92
  const file = readyFiles[safeIndex] ?? null
56
- const diffHeight = useMemo(
57
- () => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
58
- [file?.patch, view, wrapMode, paneWidth],
59
- )
60
93
  const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
61
94
 
62
95
  if (!pullRequest) {
@@ -102,7 +135,23 @@ export const PullRequestDiffPane = ({
102
135
  }
103
136
 
104
137
  const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
105
- const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
138
+ const currentFileStatsText = file ? diffFileStatText(file) : ""
139
+ const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
140
+ const commentLabel = commentMode && selectedCommentAnchor
141
+ ? ` ${selectedSideLabel} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
142
+ : commentMode ? " c no lines" : commentCount > 0 ? ` ● ${commentCount}` : ""
143
+ const commentLabelColor = commentMode && selectedCommentAnchor?.side === "LEFT"
144
+ ? colors.status.failing
145
+ : commentMode ? colors.status.passing : colors.status.passing
146
+ const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - currentFileStatsText.length - commentLabel.length - 4)
147
+ const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
148
+ ? selectedCommentThread[selectedCommentThread.length - 1]!
149
+ : null
150
+ const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
151
+ const commentPeekBody = commentPeek?.body.split("\n")[0]?.trim() || "(empty comment)"
152
+ const commentPeekMeta = commentPeek && selectedCommentAnchor
153
+ ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
154
+ : ""
106
155
 
107
156
  return (
108
157
  <box height={height} flexDirection="column">
@@ -118,34 +167,57 @@ export const PullRequestDiffPane = ({
118
167
  <TextLine>
119
168
  <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
120
169
  <span fg={colors.muted}> {fileCounter}</span>
170
+ {currentFileStatsText ? <span fg={colors.muted}> </span> : null}
171
+ <FileStats file={file} />
172
+ {commentLabel ? <span fg={commentLabelColor}>{commentLabel}</span> : null}
121
173
  </TextLine>
122
174
  </box>
123
175
  <Divider width={paneWidth} />
124
- <scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
125
- <diff
126
- key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
127
- diff={file.patch}
128
- view={view}
129
- syncScroll
130
- filetype={file.filetype ?? "text"}
131
- syntaxStyle={syntaxStyle}
132
- showLineNumbers
133
- wrapMode={wrapMode}
134
- addedBg={colors.diff.addedBg}
135
- removedBg={colors.diff.removedBg}
136
- contextBg={colors.diff.contextBg}
137
- addedSignColor={colors.status.passing}
138
- removedSignColor={colors.status.failing}
139
- lineNumberFg={colors.muted}
140
- lineNumberBg={colors.diff.lineNumberBg}
141
- addedLineNumberBg={colors.diff.addedLineNumberBg}
142
- removedLineNumberBg={colors.diff.removedLineNumberBg}
143
- selectionBg={colors.selectedBg}
144
- selectionFg={colors.selectedText}
145
- height={diffHeight}
146
- style={{ flexShrink: 0 }}
147
- />
176
+ <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false}>
177
+ {stackedFiles.map((stackedFile) => (
178
+ <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
179
+ {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
180
+ <box height={1} paddingLeft={1} paddingRight={1}>
181
+ <FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
182
+ </box>
183
+ <Divider width={paneWidth} />
184
+ <diff
185
+ ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
186
+ diff={stackedFile.file.patch}
187
+ view={view}
188
+ syncScroll
189
+ filetype={stackedFile.file.filetype ?? "text"}
190
+ syntaxStyle={syntaxStyle}
191
+ showLineNumbers
192
+ wrapMode={wrapMode}
193
+ addedBg={colors.diff.addedBg}
194
+ removedBg={colors.diff.removedBg}
195
+ contextBg={colors.diff.contextBg}
196
+ addedSignColor={colors.status.passing}
197
+ removedSignColor={colors.status.failing}
198
+ lineNumberFg={colors.muted}
199
+ lineNumberBg={colors.diff.lineNumberBg}
200
+ addedLineNumberBg={colors.diff.addedLineNumberBg}
201
+ removedLineNumberBg={colors.diff.removedLineNumberBg}
202
+ selectionBg={colors.selectedBg}
203
+ selectionFg={colors.selectedText}
204
+ height={stackedFile.diffHeight}
205
+ style={{ flexShrink: 0 }}
206
+ />
207
+ </box>
208
+ ))}
148
209
  </scrollbox>
210
+ {commentPeek ? (
211
+ <>
212
+ <Divider width={paneWidth} />
213
+ <box height={1} paddingLeft={1} paddingRight={1}>
214
+ <PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
215
+ </box>
216
+ <box height={1} paddingLeft={1} paddingRight={1}>
217
+ <PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
218
+ </box>
219
+ </>
220
+ ) : null}
149
221
  </box>
150
222
  )
151
223
  }
@@ -25,10 +25,26 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
25
25
  return maxLen + 1
26
26
  }
27
27
 
28
- const GroupTitle = ({ label, color }: { label: string; color: string }) => (
28
+ const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
29
+ const fitted = fitCell(text, width, align)
30
+ const needle = query.trim().toLowerCase()
31
+ const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
32
+ if (index < 0) return <span>{fitted}</span>
33
+
34
+ const end = Math.min(fitted.length, index + needle.length)
35
+ return (
36
+ <>
37
+ {index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
38
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
39
+ {end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
40
+ </>
41
+ )
42
+ }
43
+
44
+ const GroupTitle = ({ label, color, filterText }: { label: string; color: string; filterText: string }) => (
29
45
  <TextLine>
30
46
  <span fg={color}>{GROUP_ICON} </span>
31
- <span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
47
+ <span fg={color} attributes={TextAttributes.BOLD}><MatchedCell text={label} width={label.length} query={filterText} /></span>
32
48
  </TextLine>
33
49
  )
34
50
 
@@ -37,30 +53,38 @@ const PullRequestRow = ({
37
53
  selected,
38
54
  contentWidth,
39
55
  numWidth,
56
+ filterText,
40
57
  onSelect,
41
58
  }: {
42
59
  pullRequest: PullRequestItem
43
60
  selected: boolean
44
61
  contentWidth: number
45
62
  numWidth: number
63
+ filterText: string
46
64
  onSelect: () => void
47
65
  }) => {
48
- const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
66
+ const isClosed = pullRequest.state === "closed"
67
+ const isMerged = pullRequest.state === "merged"
68
+ const isFinal = isClosed || isMerged
69
+ const checkText = isMerged ? "merged" : isClosed ? "closed" : checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
49
70
  const ageText = `${daysOpen(pullRequest.createdAt)}d`
50
71
  const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
51
72
  const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
52
73
  const fillerWidth = Math.max(0, contentWidth - rowWidth)
53
- const indicatorColor = pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
74
+ const indicatorColor = isMerged ? colors.status.passing : isClosed ? colors.muted : pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
75
+ const rowTextColor = selected ? colors.selectedText : isFinal ? colors.muted : colors.text
76
+ const numberColor = selected ? colors.accent : isFinal ? colors.muted : colors.count
77
+ const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
54
78
 
55
79
  return (
56
80
  <box height={1} onMouseDown={onSelect}>
57
- <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
81
+ <TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
58
82
  <span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
59
83
  <span> </span>
60
- <span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
84
+ <span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
61
85
  <span> </span>
62
- <span>{fitCell(pullRequest.title, titleWidth)}</span>
63
- <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
86
+ <span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
87
+ <span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
64
88
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
65
89
  {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
66
90
  </TextLine>
@@ -109,15 +133,16 @@ export const PullRequestList = ({
109
133
  const numWidth = groupNumberWidth(pullRequests)
110
134
  return (
111
135
  <box key={repo} flexDirection="column">
112
- <GroupTitle label={repo} color={repoColor(repo)} />
136
+ <GroupTitle label={repo} color={repoColor(repo)} filterText={filterText} />
113
137
  {pullRequests.map((pullRequest) => (
114
138
  <PullRequestRow
115
139
  key={pullRequest.url}
116
140
  pullRequest={pullRequest}
117
- selected={pullRequest.url === selectedUrl}
118
- contentWidth={contentWidth}
119
- numWidth={numWidth}
120
- onSelect={() => onSelectPullRequest(pullRequest.url)}
141
+ selected={pullRequest.url === selectedUrl}
142
+ contentWidth={contentWidth}
143
+ numWidth={numWidth}
144
+ filterText={filterText}
145
+ onSelect={() => onSelectPullRequest(pullRequest.url)}
121
146
  />
122
147
  ))}
123
148
  </box>
package/src/ui/colors.ts CHANGED
@@ -11,6 +11,7 @@ export type ThemeId =
11
11
  | "monokai"
12
12
  | "solarized-dark"
13
13
  | "everforest"
14
+ | "vesper"
14
15
  | "opencode"
15
16
 
16
17
  export interface ColorPalette {
@@ -566,6 +567,45 @@ const everforestColors: ColorPalette = {
566
567
  },
567
568
  }
568
569
 
570
+ const vesperColors: ColorPalette = {
571
+ background: "#101010",
572
+ modalBackground: "#1A1A1A",
573
+ text: "#FFFFFF",
574
+ muted: "#A0A0A0",
575
+ separator: "#282828",
576
+ accent: "#FFC799",
577
+ inlineCode: "#99FFE4",
578
+ error: "#FF8080",
579
+ selectedBg: "#232323",
580
+ selectedText: "#FFFFFF",
581
+ count: "#FFC799",
582
+ status: {
583
+ draft: "#FFC799",
584
+ approved: "#99FFE4",
585
+ changes: "#FF8080",
586
+ review: "#B0B0B0",
587
+ none: "#7E7E7E",
588
+ passing: "#99FFE4",
589
+ pending: "#FFC799",
590
+ failing: "#FF8080",
591
+ },
592
+ repos: {
593
+ opencode: "#FFC799",
594
+ "effect-smol": "#99FFE4",
595
+ "opencode-console": "#FFD1A8",
596
+ opencontrol: "#FFC799",
597
+ default: "#B0B0B0",
598
+ },
599
+ diff: {
600
+ addedBg: "#17312d",
601
+ removedBg: "#351f1f",
602
+ contextBg: "transparent",
603
+ lineNumberBg: "#101010",
604
+ addedLineNumberBg: "#142b28",
605
+ removedLineNumberBg: "#2f1c1c",
606
+ },
607
+ }
608
+
569
609
  export const themeDefinitions: readonly ThemeDefinition[] = [
570
610
  { id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
571
611
  { id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
@@ -579,6 +619,7 @@ export const themeDefinitions: readonly ThemeDefinition[] = [
579
619
  { id: "monokai", name: "Monokai", description: "Classic dark olive with electric syntax colors", colors: monokaiColors },
580
620
  { id: "solarized-dark", name: "Solarized Dark", description: "Low-contrast blue-green base with calibrated accents", colors: solarizedDarkColors },
581
621
  { id: "everforest", name: "Everforest", description: "Soft green-gray forest tones with warm highlights", colors: everforestColors },
622
+ { id: "vesper", name: "Vesper", description: "Minimal black surfaces with peach and aqua accents", colors: vesperColors },
582
623
  { id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
583
624
  ] as const
584
625