@kitlangton/ghui 0.1.7 → 0.1.9

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.
@@ -0,0 +1,498 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { Fragment, useMemo } from "react"
3
+ import { formatRelativeDate } from "../date.js"
4
+ import type { CheckItem, PullRequestItem } from "../domain.js"
5
+ import { colors } from "./colors.js"
6
+ import { diffStatText } from "./diff.js"
7
+ import { centerCell, Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
8
+ import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
9
+
10
+ interface PreviewLine {
11
+ readonly segments: ReadonlyArray<{
12
+ readonly text: string
13
+ readonly fg: string
14
+ readonly bold?: boolean
15
+ }>
16
+ }
17
+
18
+ export interface DetailPlaceholderContent {
19
+ readonly title: string
20
+ readonly hint: string
21
+ }
22
+
23
+ export const DETAIL_BODY_LINES = 6
24
+ export const DETAIL_PLACEHOLDER_ROWS = 4
25
+
26
+ const pullRequestReferencePattern = /(#[0-9]+)/g
27
+
28
+ export const wrapText = (text: string, width: number): string[] => {
29
+ if (text.length === 0 || width <= 0) return [""]
30
+ const words = text.split(/\s+/)
31
+ const lines: string[] = []
32
+ let current = ""
33
+ for (const word of words) {
34
+ const next = current.length > 0 ? `${current} ${word}` : word
35
+ if (next.length > width && current.length > 0) {
36
+ lines.push(current)
37
+ current = word
38
+ } else {
39
+ current = next
40
+ }
41
+ }
42
+ if (current.length > 0) lines.push(current)
43
+ return lines.length > 0 ? lines : [""]
44
+ }
45
+
46
+ const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
47
+ const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
48
+ return parts.flatMap((part) => {
49
+ if (part.startsWith("`") && part.endsWith("`")) {
50
+ return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
51
+ }
52
+
53
+ return part
54
+ .split(pullRequestReferencePattern)
55
+ .filter((segment) => segment.length > 0)
56
+ .map((segment) => ({
57
+ text: segment,
58
+ fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
59
+ bold,
60
+ }))
61
+ })
62
+ }
63
+
64
+ const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
65
+ const tokens = segments.flatMap((segment) =>
66
+ segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
67
+ )
68
+
69
+ const lines: Array<PreviewLine> = []
70
+ let current: Array<PreviewLine["segments"][number]> = []
71
+ let currentLength = 0
72
+
73
+ const pushLine = () => {
74
+ lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
75
+ current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
76
+ currentLength = indent.length
77
+ }
78
+
79
+ for (const token of tokens) {
80
+ const tokenLength = token.text.length
81
+ if (currentLength > 0 && currentLength + tokenLength > width) {
82
+ pushLine()
83
+ }
84
+ current.push(token)
85
+ currentLength += tokenLength
86
+ }
87
+
88
+ if (current.length > 0) {
89
+ lines.push({ segments: current })
90
+ }
91
+
92
+ return lines
93
+ }
94
+
95
+ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
96
+ const sourceLines = body.replace(/\r/g, "").split("\n")
97
+ const preview: Array<PreviewLine> = []
98
+ let inCodeBlock = false
99
+
100
+ for (const rawLine of sourceLines) {
101
+ if (preview.length >= limit) break
102
+
103
+ const line = rawLine.trim()
104
+ if (line.startsWith("```")) {
105
+ inCodeBlock = !inCodeBlock
106
+ continue
107
+ }
108
+ if (line.length === 0) continue
109
+
110
+ let text = line
111
+ let fg: string = colors.text
112
+ let bold = false
113
+ let indent = ""
114
+
115
+ if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
116
+ if (preview.length > 0) {
117
+ preview.push({ segments: [{ text: "", fg: colors.muted }] })
118
+ if (preview.length >= limit) break
119
+ }
120
+ text = line.replace(/^#{1,6}\s+/, "")
121
+ fg = colors.count
122
+ bold = true
123
+ } else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
124
+ const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
125
+ text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
126
+ fg = checked ? colors.status.passing : colors.text
127
+ indent = " "
128
+ } else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
129
+ const checked = /^\[(x|X)\]\s+/.test(line)
130
+ text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
131
+ fg = checked ? colors.status.passing : colors.text
132
+ indent = " "
133
+ } else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
134
+ text = `• ${line.replace(/^[-*+]\s+/, "")}`
135
+ indent = " "
136
+ } else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
137
+ text = line
138
+ indent = " "
139
+ } else if (!inCodeBlock && /^>\s+/.test(line)) {
140
+ text = `> ${line.replace(/^>\s+/, "")}`
141
+ fg = colors.muted
142
+ indent = " "
143
+ } else if (inCodeBlock) {
144
+ fg = colors.muted
145
+ }
146
+
147
+ const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
148
+ for (const wrappedLine of wrapped) {
149
+ preview.push(wrappedLine)
150
+ if (preview.length >= limit) break
151
+ }
152
+ }
153
+
154
+ if (preview.length === 0) {
155
+ return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
156
+ }
157
+
158
+ return preview.slice(0, limit)
159
+ }
160
+
161
+ const BlankRow = () => <box height={1} />
162
+
163
+ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
164
+ if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
165
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
166
+ type Part = { key: string; text: string; color: string }
167
+ const rawParts: Array<Part | null> = [
168
+ pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
169
+ pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
170
+ { key: "files", text: files, color: colors.muted },
171
+ ]
172
+ const parts = rawParts.filter((part): part is Part => part !== null)
173
+
174
+ return (
175
+ <>
176
+ {parts.map((part, index) => (
177
+ <Fragment key={part.key}>
178
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
179
+ <span fg={part.color}>{part.text}</span>
180
+ </Fragment>
181
+ ))}
182
+ </>
183
+ )
184
+ }
185
+
186
+ const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
187
+ const seen = new Map<string, CheckItem>()
188
+ for (const check of checks) {
189
+ const existing = seen.get(check.name)
190
+ if (!existing || (check.status === "completed" && existing.status !== "completed")) {
191
+ seen.set(check.name, check)
192
+ }
193
+ }
194
+ return [...seen.values()]
195
+ }
196
+
197
+ const checkIcon = (check: CheckItem) => {
198
+ if (check.status === "completed") {
199
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "✓"
200
+ if (check.conclusion === "failure") return "✗"
201
+ return "·"
202
+ }
203
+ if (check.status === "in_progress") return "●"
204
+ return "○"
205
+ }
206
+
207
+ const checkColor = (check: CheckItem) => {
208
+ if (check.status === "completed") {
209
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return colors.status.passing
210
+ if (check.conclusion === "failure") return colors.status.failing
211
+ return colors.muted
212
+ }
213
+ if (check.status === "in_progress") return colors.status.pending
214
+ return colors.muted
215
+ }
216
+
217
+ const checksRowCount = (checks: readonly CheckItem[]) => {
218
+ const unique = deduplicateChecks(checks)
219
+ return Math.ceil(unique.length / 2)
220
+ }
221
+
222
+ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
223
+ const unique = deduplicateChecks(checks)
224
+ if (unique.length === 0) return null
225
+
226
+ const colWidth = Math.floor((contentWidth - 1) / 2)
227
+ const nameCol = Math.max(4, colWidth - 2)
228
+ const rows = Math.ceil(unique.length / 2)
229
+
230
+ return (
231
+ <box flexDirection="column">
232
+ <TextLine>
233
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
234
+ </TextLine>
235
+ {Array.from({ length: rows }, (_, rowIndex) => {
236
+ const left = unique[rowIndex * 2]
237
+ const right = unique[rowIndex * 2 + 1]
238
+ return (
239
+ <TextLine key={rowIndex}>
240
+ {left ? (
241
+ <>
242
+ <span fg={checkColor(left)}>{checkIcon(left)} </span>
243
+ <span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
244
+ </>
245
+ ) : null}
246
+ {right ? (
247
+ <>
248
+ <span fg={colors.muted}> </span>
249
+ <span fg={checkColor(right)}>{checkIcon(right)} </span>
250
+ <span fg={colors.text}>{right.name}</span>
251
+ </>
252
+ ) : null}
253
+ </TextLine>
254
+ )
255
+ })}
256
+ </box>
257
+ )
258
+ }
259
+
260
+ export const getDetailJunctionRows = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false): readonly number[] => {
261
+ if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
262
+ const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
263
+ const detailDividerRow = 1 + titleLines + 1
264
+ const checks = deduplicateChecks(pullRequest.checks)
265
+ const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
266
+ return showChecks && checks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
267
+ }
268
+
269
+ export const DetailHeader = ({
270
+ pullRequest,
271
+ contentWidth,
272
+ paneWidth,
273
+ showChecks = false,
274
+ }: {
275
+ pullRequest: PullRequestItem
276
+ contentWidth: number
277
+ paneWidth: number
278
+ showChecks?: boolean
279
+ }) => {
280
+ const labels = pullRequest.labels
281
+ const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
282
+ const unique = deduplicateChecks(pullRequest.checks)
283
+ const checkRows = checksRowCount(unique)
284
+ const statsText = diffStatText(pullRequest)
285
+ const labelsWidth = !pullRequest.detailLoaded
286
+ ? "loading details...".length
287
+ : labels.length > 0
288
+ ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
289
+ : "no labels".length
290
+ const showStats = contentWidth - labelsWidth - statsText.length >= 2
291
+ const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
292
+
293
+ return (
294
+ <>
295
+ <box height={1} paddingLeft={1} paddingRight={1}>
296
+ {(() => {
297
+ const opened = formatRelativeDate(pullRequest.createdAt)
298
+ const repo = shortRepoName(pullRequest.repository)
299
+ const number = String(pullRequest.number)
300
+ const review = reviewLabel(pullRequest)
301
+ const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
302
+ const statusParts = [review, checks].filter((part): part is string => Boolean(part))
303
+ const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
304
+ const leftWidth = 1 + number.length + 1 + repo.length
305
+ const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
306
+
307
+ return (
308
+ <TextLine>
309
+ <span fg={colors.count}>#{number}</span>
310
+ <span fg={colors.muted}> {repo}</span>
311
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
312
+ {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
313
+ {review && checks ? <span fg={colors.muted}> </span> : null}
314
+ {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
315
+ {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
316
+ <span fg={colors.muted}>{opened}</span>
317
+ </TextLine>
318
+ )
319
+ })()}
320
+ </box>
321
+ <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
322
+ {wrappedTitle.map((line, index) => (
323
+ <PlainLine key={index} text={line} bold />
324
+ ))}
325
+ </box>
326
+ <box height={1} paddingLeft={1} paddingRight={1}>
327
+ <TextLine>
328
+ {!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
329
+ <Fragment key={label.name}>
330
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
331
+ <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
332
+ </Fragment>
333
+ )) : <span fg={colors.muted}>no labels</span>}
334
+ {showStats ? (
335
+ <>
336
+ <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
337
+ <DiffStats pullRequest={pullRequest} />
338
+ </>
339
+ ) : null}
340
+ </TextLine>
341
+ </box>
342
+ <box height={1}><Divider width={paneWidth} /></box>
343
+ {showChecks && unique.length > 0 ? (
344
+ <>
345
+ <box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
346
+ <ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
347
+ </box>
348
+ <box height={1}><Divider width={paneWidth} /></box>
349
+ </>
350
+ ) : null}
351
+ </>
352
+ )
353
+ }
354
+
355
+ export const DetailBody = ({
356
+ pullRequest,
357
+ contentWidth,
358
+ bodyLines = DETAIL_BODY_LINES,
359
+ loadingIndicator,
360
+ }: {
361
+ pullRequest: PullRequestItem
362
+ contentWidth: number
363
+ bodyLines?: number
364
+ loadingIndicator: string
365
+ }) => {
366
+ const previewLines = useMemo(
367
+ () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
368
+ [pullRequest.body, contentWidth, bodyLines],
369
+ )
370
+
371
+ if (!pullRequest.detailLoaded) {
372
+ const topRows = Math.max(0, Math.floor((bodyLines - 1) / 2))
373
+ const bottomRows = Math.max(0, bodyLines - topRows - 1)
374
+ return (
375
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
376
+ {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
377
+ <PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
378
+ {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
379
+ </box>
380
+ )
381
+ }
382
+
383
+ return (
384
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
385
+ {previewLines.map((line, index) => (
386
+ <TextLine key={`${pullRequest.url}-${index}`}>
387
+ {line.segments.map((segment, segmentIndex) => (
388
+ ("bold" in segment && segment.bold === true) ? (
389
+ <span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
390
+ {segment.text}
391
+ </span>
392
+ ) : (
393
+ <span key={segmentIndex} fg={segment.fg}>
394
+ {segment.text}
395
+ </span>
396
+ )
397
+ ))}
398
+ </TextLine>
399
+ ))}
400
+ </box>
401
+ )
402
+ }
403
+
404
+ export const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
405
+ const innerWidth = Math.max(1, width - 2)
406
+ const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
407
+ const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
408
+ const cardInnerWidth = Math.max(1, cardWidth - 2)
409
+ const contentLine = (text: string, fg: string, bold = false) => (
410
+ <TextLine>
411
+ <span fg={colors.separator}>{offset}│</span>
412
+ {bold ? (
413
+ <span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
414
+ ) : (
415
+ <span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
416
+ )}
417
+ <span fg={colors.separator}>│</span>
418
+ </TextLine>
419
+ )
420
+
421
+ return (
422
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
423
+ <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
424
+ {contentLine(content.title, colors.count, true)}
425
+ {contentLine(content.hint, colors.muted)}
426
+ <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
427
+ </box>
428
+ )
429
+ }
430
+
431
+ export const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
432
+ <box flexDirection="column">
433
+ <StatusCard content={content} width={paneWidth} />
434
+ <box height={1}><Divider width={paneWidth} /></box>
435
+ </box>
436
+ )
437
+
438
+ export const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
439
+ const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
440
+ const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
441
+
442
+ return (
443
+ <box height={height} flexDirection="column">
444
+ {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
445
+ <StatusCard content={content} width={width} />
446
+ {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
447
+ </box>
448
+ )
449
+ }
450
+
451
+ export const DetailsPane = ({
452
+ pullRequest,
453
+ contentWidth,
454
+ bodyLines = DETAIL_BODY_LINES,
455
+ paneWidth = contentWidth + 2,
456
+ showChecks = false,
457
+ placeholderContent,
458
+ loadingIndicator,
459
+ }: {
460
+ pullRequest: PullRequestItem | null
461
+ contentWidth: number
462
+ bodyLines?: number
463
+ paneWidth?: number
464
+ showChecks?: boolean
465
+ placeholderContent: DetailPlaceholderContent
466
+ loadingIndicator: string
467
+ }) => {
468
+ const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
469
+ const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
470
+ const checkRows = checksRowCount(uniqueChecks)
471
+ const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
472
+ const previewLines = useMemo(
473
+ () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
474
+ [pullRequest?.body, contentWidth, bodyLines],
475
+ )
476
+ const bodyHeight = pullRequest && !pullRequest.detailLoaded ? bodyLines : previewLines.length
477
+ const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + bodyHeight : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
478
+
479
+ return (
480
+ <box flexDirection="column" height={contentHeight}>
481
+ {pullRequest ? (
482
+ <>
483
+ <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
484
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} />
485
+ </>
486
+ ) : (
487
+ <>
488
+ <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
489
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
490
+ {Array.from({ length: bodyLines }, (_, index) => (
491
+ <BlankRow key={index} />
492
+ ))}
493
+ </box>
494
+ </>
495
+ )}
496
+ </box>
497
+ )
498
+ }
@@ -0,0 +1,145 @@
1
+ import { colors } from "./colors.js"
2
+ import { TextLine } from "./primitives.js"
3
+
4
+ export interface RetryProgress {
5
+ readonly attempt: number
6
+ readonly max: number
7
+ }
8
+
9
+ export const FooterHints = ({
10
+ filterEditing,
11
+ showFilterClear,
12
+ detailFullView,
13
+ diffFullView,
14
+ hasSelection,
15
+ hasError,
16
+ isLoading,
17
+ loadingIndicator,
18
+ retryProgress,
19
+ }: {
20
+ filterEditing: boolean
21
+ showFilterClear: boolean
22
+ detailFullView: boolean
23
+ diffFullView: boolean
24
+ hasSelection: boolean
25
+ hasError: boolean
26
+ isLoading: boolean
27
+ loadingIndicator: string
28
+ retryProgress: RetryProgress | null
29
+ }) => {
30
+ if (filterEditing) {
31
+ return (
32
+ <TextLine>
33
+ <span fg={colors.count}>search</span>
34
+ <span fg={colors.muted}> typing </span>
35
+ <span fg={colors.count}>↑↓</span>
36
+ <span fg={colors.muted}> move </span>
37
+ <span fg={colors.count}>enter</span>
38
+ <span fg={colors.muted}> apply </span>
39
+ <span fg={colors.count}>esc</span>
40
+ <span fg={colors.muted}> cancel </span>
41
+ <span fg={colors.count}>ctrl-u</span>
42
+ <span fg={colors.muted}> clear </span>
43
+ <span fg={colors.count}>ctrl-w</span>
44
+ <span fg={colors.muted}> word</span>
45
+ </TextLine>
46
+ )
47
+ }
48
+
49
+ if (diffFullView) {
50
+ return (
51
+ <TextLine>
52
+ <span fg={colors.count}>esc</span>
53
+ <span fg={colors.muted}> back </span>
54
+ <span fg={colors.count}>v</span>
55
+ <span fg={colors.muted}> view </span>
56
+ <span fg={colors.count}>w</span>
57
+ <span fg={colors.muted}> wrap </span>
58
+ <span fg={colors.count}>[]</span>
59
+ <span fg={colors.muted}> files </span>
60
+ <span fg={colors.count}>r</span>
61
+ <span fg={colors.muted}> reload </span>
62
+ <span fg={colors.count}>o</span>
63
+ <span fg={colors.muted}> open </span>
64
+ <span fg={colors.count}>q</span>
65
+ <span fg={colors.muted}> quit</span>
66
+ </TextLine>
67
+ )
68
+ }
69
+
70
+ if (detailFullView) {
71
+ return (
72
+ <TextLine>
73
+ <span fg={colors.count}>esc</span>
74
+ <span fg={colors.muted}> back </span>
75
+ <span fg={colors.count}>o</span>
76
+ <span fg={colors.muted}> open </span>
77
+ <span fg={colors.count}>y</span>
78
+ <span fg={colors.muted}> copy </span>
79
+ <span fg={colors.count}>q</span>
80
+ <span fg={colors.muted}> quit</span>
81
+ </TextLine>
82
+ )
83
+ }
84
+
85
+ return (
86
+ <TextLine>
87
+ <span fg={colors.count}>/</span>
88
+ <span fg={colors.muted}> filter </span>
89
+ {showFilterClear ? (
90
+ <>
91
+ <span fg={colors.count}>esc</span>
92
+ <span fg={colors.muted}> clear </span>
93
+ </>
94
+ ) : null}
95
+ {retryProgress ? (
96
+ <>
97
+ <span fg={colors.status.pending}>retry</span>
98
+ <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
99
+ </>
100
+ ) : isLoading ? (
101
+ <>
102
+ <span fg={colors.status.pending}>{loadingIndicator}</span>
103
+ <span fg={colors.muted}> loading </span>
104
+ </>
105
+ ) : null}
106
+ <span fg={colors.count}>r</span>
107
+ <span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
108
+ {hasSelection ? (
109
+ <>
110
+ <span fg={colors.count}>↑↓</span>
111
+ <span fg={colors.muted}> move </span>
112
+ </>
113
+ ) : null}
114
+ {hasSelection && detailFullView ? (
115
+ <>
116
+ <span fg={colors.count}>esc</span>
117
+ <span fg={colors.muted}> back </span>
118
+ </>
119
+ ) : hasSelection ? (
120
+ <>
121
+ <span fg={colors.count}>enter</span>
122
+ <span fg={colors.muted}> expand </span>
123
+ </>
124
+ ) : null}
125
+ {hasSelection ? (
126
+ <>
127
+ <span fg={colors.count}>d</span>
128
+ <span fg={colors.muted}> draft </span>
129
+ <span fg={colors.count}>p</span>
130
+ <span fg={colors.muted}> diff </span>
131
+ <span fg={colors.count}>l</span>
132
+ <span fg={colors.muted}> labels </span>
133
+ <span fg={colors.count}>M</span>
134
+ <span fg={colors.muted}> merge </span>
135
+ <span fg={colors.count}>o</span>
136
+ <span fg={colors.muted}> open </span>
137
+ <span fg={colors.count}>y</span>
138
+ <span fg={colors.muted}> copy </span>
139
+ </>
140
+ ) : null}
141
+ <span fg={colors.count}>q</span>
142
+ <span fg={colors.muted}> quit</span>
143
+ </TextLine>
144
+ )
145
+ }