@kitlangton/ghui 0.1.17 → 0.1.19

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
  ) : (
@@ -1,16 +1,21 @@
1
+ import { Data } from "effect"
1
2
  import { colors } from "./colors.js"
2
3
  import { TextLine } from "./primitives.js"
3
4
 
4
- export interface RetryProgress {
5
- readonly attempt: number
6
- readonly max: number
7
- }
5
+ export type RetryProgress = Data.TaggedEnum<{
6
+ Idle: {}
7
+ Retrying: { readonly attempt: number; readonly max: number }
8
+ }>
9
+
10
+ export const RetryProgress = Data.taggedEnum<RetryProgress>()
11
+ export const initialRetryProgress: RetryProgress = RetryProgress.Idle()
8
12
 
9
13
  export const FooterHints = ({
10
14
  filterEditing,
11
15
  showFilterClear,
12
16
  detailFullView,
13
17
  diffFullView,
18
+ diffCommentMode,
14
19
  hasSelection,
15
20
  canCloseSelection,
16
21
  hasError,
@@ -22,12 +27,13 @@ export const FooterHints = ({
22
27
  showFilterClear: boolean
23
28
  detailFullView: boolean
24
29
  diffFullView: boolean
30
+ diffCommentMode: boolean
25
31
  hasSelection: boolean
26
32
  canCloseSelection: boolean
27
33
  hasError: boolean
28
34
  isLoading: boolean
29
35
  loadingIndicator: string
30
- retryProgress: RetryProgress | null
36
+ retryProgress: RetryProgress
31
37
  }) => {
32
38
  if (filterEditing) {
33
39
  return (
@@ -49,6 +55,28 @@ export const FooterHints = ({
49
55
  }
50
56
 
51
57
  if (diffFullView) {
58
+ if (diffCommentMode) {
59
+ return (
60
+ <TextLine>
61
+ <span fg={colors.count}>↑↓</span>
62
+ <span fg={colors.muted}> line </span>
63
+ <span fg={colors.count}>pgup/pgdn</span>
64
+ <span fg={colors.muted}> jump </span>
65
+ <span fg={colors.count}>←→</span>
66
+ <span fg={colors.muted}> side </span>
67
+ <span fg={colors.count}>enter</span>
68
+ <span fg={colors.muted}> open </span>
69
+ <span fg={colors.count}>a</span>
70
+ <span fg={colors.muted}> comment </span>
71
+ <span fg={colors.count}>c</span>
72
+ <span fg={colors.muted}> done </span>
73
+ <span fg={colors.count}>[]</span>
74
+ <span fg={colors.muted}> files </span>
75
+ <span fg={colors.count}>esc</span>
76
+ <span fg={colors.muted}> back</span>
77
+ </TextLine>
78
+ )
79
+ }
52
80
  return (
53
81
  <TextLine>
54
82
  <span fg={colors.count}>esc</span>
@@ -57,6 +85,8 @@ export const FooterHints = ({
57
85
  <span fg={colors.muted}> view </span>
58
86
  <span fg={colors.count}>w</span>
59
87
  <span fg={colors.muted}> wrap </span>
88
+ <span fg={colors.count}>c</span>
89
+ <span fg={colors.muted}> comment </span>
60
90
  <span fg={colors.count}>[]</span>
61
91
  <span fg={colors.muted}> files </span>
62
92
  <span fg={colors.count}>r</span>
@@ -74,6 +104,30 @@ export const FooterHints = ({
74
104
  <TextLine>
75
105
  <span fg={colors.count}>esc</span>
76
106
  <span fg={colors.muted}> back </span>
107
+ <span fg={colors.count}>↑↓</span>
108
+ <span fg={colors.muted}> scroll </span>
109
+ <span fg={colors.count}>r</span>
110
+ <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
111
+ <span fg={colors.count}>t</span>
112
+ <span fg={colors.muted}> theme </span>
113
+ {hasSelection ? (
114
+ <>
115
+ <span fg={colors.count}>s</span>
116
+ <span fg={colors.muted}> state </span>
117
+ <span fg={colors.count}>d</span>
118
+ <span fg={colors.muted}> diff </span>
119
+ <span fg={colors.count}>l</span>
120
+ <span fg={colors.muted}> labels </span>
121
+ <span fg={colors.count}>m</span>
122
+ <span fg={colors.muted}> merge </span>
123
+ {canCloseSelection ? (
124
+ <>
125
+ <span fg={colors.count}>x</span>
126
+ <span fg={colors.muted}> close </span>
127
+ </>
128
+ ) : null}
129
+ </>
130
+ ) : null}
77
131
  <span fg={colors.count}>o</span>
78
132
  <span fg={colors.muted}> open </span>
79
133
  <span fg={colors.count}>y</span>
@@ -86,6 +140,8 @@ export const FooterHints = ({
86
140
 
87
141
  return (
88
142
  <TextLine>
143
+ <span fg={colors.count}>tab</span>
144
+ <span fg={colors.muted}> queue </span>
89
145
  <span fg={colors.count}>/</span>
90
146
  <span fg={colors.muted}> filter </span>
91
147
  <span fg={colors.count}>t</span>
@@ -96,7 +152,7 @@ export const FooterHints = ({
96
152
  <span fg={colors.muted}> clear </span>
97
153
  </>
98
154
  ) : null}
99
- {retryProgress ? (
155
+ {retryProgress._tag === "Retrying" ? (
100
156
  <>
101
157
  <span fg={colors.status.pending}>retry</span>
102
158
  <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
@@ -1,8 +1,8 @@
1
- import type { ScrollBoxRenderable } from "@opentui/core"
1
+ import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useMemo, type Ref } from "react"
3
- import type { PullRequestItem } from "../domain.js"
3
+ import type { DiffCommentSide, 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, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, 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,36 +27,82 @@ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
27
27
  )
28
28
  }
29
29
 
30
+ const FileStats = ({ stats }: { stats: DiffFileStats }) => {
31
+ return (
32
+ <>
33
+ {stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
34
+ {stats.additions > 0 && stats.deletions > 0 ? <span fg={colors.muted}> </span> : null}
35
+ {stats.deletions > 0 ? <span fg={colors.status.failing}>{`-${stats.deletions}`}</span> : null}
36
+ </>
37
+ )
38
+ }
39
+
40
+ const FileHeader = ({
41
+ file,
42
+ index,
43
+ count,
44
+ width,
45
+ suffix = "",
46
+ suffixColor = colors.muted,
47
+ }: {
48
+ file: StackedDiffFilePatch["file"]
49
+ index: number
50
+ count: number
51
+ width: number
52
+ suffix?: string
53
+ suffixColor?: string
54
+ }) => {
55
+ const counter = `${index + 1}/${count}`
56
+ const stats = diffFileStats(file)
57
+ const statsText = diffFileStatsText(stats)
58
+ const nameWidth = Math.max(1, width - counter.length - statsText.length - suffix.length - 5)
59
+ return (
60
+ <TextLine>
61
+ <span fg={colors.muted}>{counter} </span>
62
+ <span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
63
+ {statsText ? <span fg={colors.muted}> </span> : null}
64
+ <FileStats stats={stats} />
65
+ {suffix ? <span fg={suffixColor}>{suffix}</span> : null}
66
+ </TextLine>
67
+ )
68
+ }
69
+
30
70
  export const PullRequestDiffPane = ({
31
71
  pullRequest,
32
72
  diffState,
33
- fileIndex,
73
+ stackedFiles,
74
+ scrollTop,
34
75
  view,
35
76
  wrapMode,
36
77
  paneWidth,
37
78
  height,
38
79
  loadingIndicator,
39
80
  scrollRef,
81
+ setDiffRef,
82
+ commentMode,
83
+ selectedCommentAnchor,
84
+ selectedCommentThread,
85
+ onSelectCommentLine,
40
86
  themeId,
41
87
  }: {
42
88
  pullRequest: PullRequestItem | null
43
89
  diffState: PullRequestDiffState | undefined
44
- fileIndex: number
45
- view: "unified" | "split"
46
- wrapMode: "none" | "word"
90
+ stackedFiles: readonly StackedDiffFilePatch[]
91
+ scrollTop: number
92
+ view: DiffView
93
+ wrapMode: DiffWrapMode
47
94
  paneWidth: number
48
95
  height: number
49
96
  loadingIndicator: string
50
97
  scrollRef: Ref<ScrollBoxRenderable>
98
+ setDiffRef: (index: number, diff: DiffRenderable | null) => void
99
+ commentMode: boolean
100
+ selectedCommentAnchor: StackedDiffCommentAnchor | null
101
+ selectedCommentThread: readonly PullRequestReviewComment[]
102
+ onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
51
103
  themeId: ThemeId
52
104
  }) => {
53
- const readyFiles = diffState?.status === "ready" ? diffState.files : []
54
- const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
55
- 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
- )
105
+ const readyFiles = diffState?._tag === "Ready" ? diffState.files : []
60
106
  const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
61
107
 
62
108
  if (!pullRequest) {
@@ -68,7 +114,7 @@ export const PullRequestDiffPane = ({
68
114
  const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
69
115
  const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
70
116
 
71
- if (!diffState || diffState.status === "loading") {
117
+ if (!diffState || diffState._tag === "Loading") {
72
118
  return (
73
119
  <box height={height} flexDirection="column">
74
120
  <box height={1} paddingLeft={1} paddingRight={1}>
@@ -85,7 +131,7 @@ export const PullRequestDiffPane = ({
85
131
  )
86
132
  }
87
133
 
88
- if (diffState.status === "error") {
134
+ if (diffState._tag === "Error") {
89
135
  return (
90
136
  <box height={height} flexDirection="column">
91
137
  <box height={1} paddingLeft={1} paddingRight={1}>
@@ -97,12 +143,44 @@ export const PullRequestDiffPane = ({
97
143
  )
98
144
  }
99
145
 
100
- if (readyFiles.length === 0 || !file) {
146
+ if (readyFiles.length === 0 || stackedFiles.length === 0) {
101
147
  return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
102
148
  }
103
149
 
104
- const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
105
- const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
150
+ const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
151
+ const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
152
+ ? selectedCommentThread[selectedCommentThread.length - 1]!
153
+ : null
154
+ const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
155
+ const commentPeekBody = commentPeek?.body.split("\n")[0]?.trim() || "(empty comment)"
156
+ const commentPeekMeta = commentPeek && selectedCommentAnchor
157
+ ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
158
+ : ""
159
+ const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
160
+ const stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
161
+ const stickyArrayIndex = stickyFile ? stackedFiles.indexOf(stickyFile) : -1
162
+ const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
163
+ const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
164
+ const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
165
+ const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
166
+ if (!commentMode) return ""
167
+ if (!selectedCommentAnchor) return " c no lines"
168
+ if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
169
+ return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
170
+ }
171
+ const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
172
+ const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
173
+ if (event.button !== 0) return
174
+ const localY = event.y - this.viewport.y
175
+ if (localY < 0 || localY >= this.viewport.height) return
176
+ const localX = event.x - this.viewport.x
177
+ const side = view === "split"
178
+ ? localX < Math.floor(paneWidth / 2) ? "LEFT" : "RIGHT"
179
+ : null
180
+ onSelectCommentLine(Math.max(0, Math.floor(this.scrollTop + localY)), side)
181
+ event.preventDefault()
182
+ event.stopPropagation()
183
+ }
106
184
 
107
185
  return (
108
186
  <box height={height} flexDirection="column">
@@ -114,38 +192,71 @@ export const PullRequestDiffPane = ({
114
192
  <DiffStats pullRequest={pullRequest} />
115
193
  </TextLine>
116
194
  </box>
117
- <box height={1} paddingLeft={1} paddingRight={1}>
118
- <TextLine>
119
- <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
120
- <span fg={colors.muted}> {fileCounter}</span>
121
- </TextLine>
122
- </box>
123
195
  <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
- />
196
+ <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
197
+ {stackedFiles.map((stackedFile) => (
198
+ <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
199
+ {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
200
+ <box height={1} paddingLeft={1} paddingRight={1}>
201
+ <FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
202
+ </box>
203
+ <Divider width={paneWidth} />
204
+ <diff
205
+ ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
206
+ diff={stackedFile.file.patch}
207
+ view={view}
208
+ syncScroll
209
+ filetype={stackedFile.file.filetype ?? "text"}
210
+ syntaxStyle={syntaxStyle}
211
+ showLineNumbers
212
+ wrapMode={wrapMode}
213
+ addedBg={colors.diff.addedBg}
214
+ removedBg={colors.diff.removedBg}
215
+ contextBg={colors.diff.contextBg}
216
+ addedSignColor={colors.status.passing}
217
+ removedSignColor={colors.status.failing}
218
+ lineNumberFg={colors.muted}
219
+ lineNumberBg={colors.diff.lineNumberBg}
220
+ addedLineNumberBg={colors.diff.addedLineNumberBg}
221
+ removedLineNumberBg={colors.diff.removedLineNumberBg}
222
+ selectionBg={colors.selectedBg}
223
+ selectionFg={colors.selectedText}
224
+ height={stackedFile.diffHeight}
225
+ style={{ flexShrink: 0 }}
226
+ />
227
+ </box>
228
+ ))}
148
229
  </scrollbox>
230
+ {stickyFile ? (
231
+ <box position="absolute" top={2} left={0} width={paneWidth} height={2} zIndex={10} flexDirection="column" backgroundColor={colors.background}>
232
+ {incomingFile ? (
233
+ <>
234
+ <Divider width={paneWidth} />
235
+ <box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
236
+ <FileHeader file={incomingFile.file} index={incomingFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(incomingFile)} suffixColor={stickyCommentColor} />
237
+ </box>
238
+ </>
239
+ ) : (
240
+ <>
241
+ <box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
242
+ <FileHeader file={stickyFile.file} index={stickyFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(stickyFile)} suffixColor={stickyCommentColor} />
243
+ </box>
244
+ <Divider width={paneWidth} />
245
+ </>
246
+ )}
247
+ </box>
248
+ ) : null}
249
+ {commentPeek ? (
250
+ <>
251
+ <Divider width={paneWidth} />
252
+ <box height={1} paddingLeft={1} paddingRight={1}>
253
+ <PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
254
+ </box>
255
+ <box height={1} paddingLeft={1} paddingRight={1}>
256
+ <PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
257
+ </box>
258
+ </>
259
+ ) : null}
149
260
  </box>
150
261
  )
151
262
  }
@@ -1,11 +1,10 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import type { PullRequestItem } from "../domain.js"
2
+ import type { LoadStatus, PullRequestItem } from "../domain.js"
3
3
  import { daysOpen } from "../date.js"
4
4
  import { colors } from "./colors.js"
5
5
  import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
6
  import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
7
7
 
8
- export type LoadStatus = "loading" | "ready" | "error"
9
8
  export type PullRequestGroups = Array<[string, PullRequestItem[]]>
10
9
 
11
10
  const GROUP_ICON = "◆"
@@ -25,10 +24,26 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
25
24
  return maxLen + 1
26
25
  }
27
26
 
28
- const GroupTitle = ({ label, color }: { label: string; color: string }) => (
27
+ const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
28
+ const fitted = fitCell(text, width, align)
29
+ const needle = query.trim().toLowerCase()
30
+ const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
31
+ if (index < 0) return <span>{fitted}</span>
32
+
33
+ const end = Math.min(fitted.length, index + needle.length)
34
+ return (
35
+ <>
36
+ {index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
37
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
38
+ {end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
39
+ </>
40
+ )
41
+ }
42
+
43
+ const GroupTitle = ({ label, color, filterText }: { label: string; color: string; filterText: string }) => (
29
44
  <TextLine>
30
45
  <span fg={color}>{GROUP_ICON} </span>
31
- <span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
46
+ <span fg={color} attributes={TextAttributes.BOLD}><MatchedCell text={label} width={label.length} query={filterText} /></span>
32
47
  </TextLine>
33
48
  )
34
49
 
@@ -37,12 +52,14 @@ const PullRequestRow = ({
37
52
  selected,
38
53
  contentWidth,
39
54
  numWidth,
55
+ filterText,
40
56
  onSelect,
41
57
  }: {
42
58
  pullRequest: PullRequestItem
43
59
  selected: boolean
44
60
  contentWidth: number
45
61
  numWidth: number
62
+ filterText: string
46
63
  onSelect: () => void
47
64
  }) => {
48
65
  const isClosed = pullRequest.state === "closed"
@@ -59,13 +76,13 @@ const PullRequestRow = ({
59
76
  const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
60
77
 
61
78
  return (
62
- <box height={1} onMouseDown={onSelect}>
63
- <TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
79
+ <box width={contentWidth} height={1} onMouseDown={onSelect}>
80
+ <TextLine width={contentWidth} fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
64
81
  <span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
65
82
  <span> </span>
66
- <span fg={numberColor}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
83
+ <span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
67
84
  <span> </span>
68
- <span>{fitCell(pullRequest.title, titleWidth)}</span>
85
+ <span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
69
86
  <span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
70
87
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
71
88
  {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
@@ -99,7 +116,7 @@ export const PullRequestList = ({
99
116
  const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
100
117
 
101
118
  return (
102
- <box flexDirection="column">
119
+ <box width={contentWidth} flexDirection="column">
103
120
  <SectionTitle title="PULL REQUESTS" />
104
121
  {showFilterBar ? (
105
122
  <TextLine>
@@ -115,7 +132,7 @@ export const PullRequestList = ({
115
132
  const numWidth = groupNumberWidth(pullRequests)
116
133
  return (
117
134
  <box key={repo} flexDirection="column">
118
- <GroupTitle label={repo} color={repoColor(repo)} />
135
+ <GroupTitle label={repo} color={repoColor(repo)} filterText={filterText} />
119
136
  {pullRequests.map((pullRequest) => (
120
137
  <PullRequestRow
121
138
  key={pullRequest.url}
@@ -123,6 +140,7 @@ export const PullRequestList = ({
123
140
  selected={pullRequest.url === selectedUrl}
124
141
  contentWidth={contentWidth}
125
142
  numWidth={numWidth}
143
+ filterText={filterText}
126
144
  onSelect={() => onSelectPullRequest(pullRequest.url)}
127
145
  />
128
146
  ))}
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