@kitlangton/ghui 0.1.17 → 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.
package/src/ui/diff.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseColor, SyntaxStyle } from "@opentui/core"
2
- import type { PullRequestItem } from "../domain.js"
2
+ import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
3
3
  import { colors } from "./colors.js"
4
4
 
5
5
  export interface DiffFilePatch {
@@ -8,6 +8,33 @@ export interface DiffFilePatch {
8
8
  readonly patch: string
9
9
  }
10
10
 
11
+ export interface DiffFileStats {
12
+ readonly additions: number
13
+ readonly deletions: number
14
+ }
15
+
16
+ export interface StackedDiffFilePatch {
17
+ readonly file: DiffFilePatch
18
+ readonly index: number
19
+ readonly headerLine: number
20
+ readonly diffStartLine: number
21
+ readonly diffHeight: number
22
+ }
23
+
24
+ export interface DiffCommentAnchor {
25
+ readonly path: string
26
+ readonly line: number
27
+ readonly side: DiffCommentSide
28
+ readonly kind: "addition" | "deletion" | "context"
29
+ readonly renderLine: number
30
+ readonly text: string
31
+ }
32
+
33
+ export type StackedDiffCommentAnchor = DiffCommentAnchor & {
34
+ readonly fileIndex: number
35
+ readonly localRenderLine: number
36
+ }
37
+
11
38
  export type PullRequestDiffState =
12
39
  | { readonly status: "loading" }
13
40
  | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
@@ -144,6 +171,32 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
144
171
 
145
172
  export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
146
173
 
174
+ export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
175
+ files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
176
+
177
+ export const buildStackedDiffFiles = (
178
+ files: readonly DiffFilePatch[],
179
+ view: "unified" | "split",
180
+ wrapMode: "none" | "word",
181
+ width: number,
182
+ ): readonly StackedDiffFilePatch[] => {
183
+ let offset = 0
184
+ return files.map((file, index) => {
185
+ const diffHeight = patchRenderableLineCount(file.patch, view, wrapMode, width)
186
+ const separatorBefore = index === 0 ? 0 : 1
187
+ const headerLine = offset + separatorBefore
188
+ const stackedFile = {
189
+ file,
190
+ index,
191
+ headerLine,
192
+ diffStartLine: headerLine + 2,
193
+ diffHeight,
194
+ } satisfies StackedDiffFilePatch
195
+ offset += separatorBefore + 2 + diffHeight
196
+ return stackedFile
197
+ })
198
+ }
199
+
147
200
  export const diffStatText = (pullRequest: PullRequestItem) => {
148
201
  if (!pullRequest.detailLoaded) return "loading details"
149
202
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
@@ -154,6 +207,153 @@ export const diffStatText = (pullRequest: PullRequestItem) => {
154
207
  ].filter((part): part is string => part !== null).join(" ")
155
208
  }
156
209
 
210
+ export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
211
+
212
+ export const diffCommentAnchorKey = diffCommentLocationKey
213
+
214
+ type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
215
+
216
+ const diffContentWidth = (lines: readonly string[], view: "unified" | "split", width: number) => {
217
+ const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
218
+ return view === "split"
219
+ ? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
220
+ : Math.max(1, width - lineNumberGutterWidth)
221
+ }
222
+
223
+ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
224
+ let additions = 0
225
+ let deletions = 0
226
+ let inHunk = false
227
+
228
+ for (const line of file.patch.split("\n")) {
229
+ const hunk = line.match(hunkHeaderPattern)
230
+ if (hunk) {
231
+ inHunk = true
232
+ continue
233
+ }
234
+
235
+ if (!inHunk) continue
236
+ const firstChar = line[0]
237
+ if (firstChar === "+") additions++
238
+ else if (firstChar === "-") deletions++
239
+ }
240
+
241
+ return { additions, deletions }
242
+ }
243
+
244
+ export const diffFileStatText = (file: DiffFilePatch) => {
245
+ const stats = diffFileStats(file)
246
+ return [
247
+ stats.additions > 0 ? `+${stats.additions}` : null,
248
+ stats.deletions > 0 ? `-${stats.deletions}` : null,
249
+ ].filter((part): part is string => part !== null).join(" ")
250
+ }
251
+
252
+ export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "split" = "unified", wrapMode: "none" | "word" = "none", width = 120): readonly DiffCommentAnchor[] => {
253
+ const anchors: DiffCommentAnchor[] = []
254
+ const lines = file.patch.split("\n")
255
+ const contentWidth = diffContentWidth(lines, view, width)
256
+ let oldLine = 0
257
+ let newLine = 0
258
+ let renderLine = 0
259
+ let inHunk = false
260
+ let deletions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
261
+ let additions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
262
+
263
+ const flushChangeBlock = () => {
264
+ if (deletions.length === 0 && additions.length === 0) return
265
+ if (view === "split") {
266
+ const rowCount = Math.max(deletions.length, additions.length)
267
+ for (let index = 0; index < rowCount; index++) {
268
+ const deletion = deletions[index]
269
+ const addition = additions[index]
270
+ if (deletion) anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
271
+ if (addition) anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
272
+ renderLine += Math.max(deletion?.height ?? 1, addition?.height ?? 1)
273
+ }
274
+ } else {
275
+ for (const deletion of deletions) {
276
+ anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
277
+ renderLine += deletion.height
278
+ }
279
+ for (const addition of additions) {
280
+ anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
281
+ renderLine += addition.height
282
+ }
283
+ }
284
+ deletions = []
285
+ additions = []
286
+ }
287
+
288
+ for (const line of lines) {
289
+ const hunk = line.match(hunkHeaderPattern)
290
+ if (hunk) {
291
+ flushChangeBlock()
292
+ oldLine = Number(hunk[1])
293
+ newLine = Number(hunk[3])
294
+ inHunk = true
295
+ continue
296
+ }
297
+
298
+ if (!inHunk) continue
299
+
300
+ const firstChar = line[0]
301
+ if (firstChar === "\\") continue
302
+
303
+ if (firstChar === "+") {
304
+ const text = line.slice(1)
305
+ additions.push({ path: file.name, line: newLine, side: "RIGHT", kind: "addition", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
306
+ newLine++
307
+ continue
308
+ }
309
+
310
+ if (firstChar === "-") {
311
+ const text = line.slice(1)
312
+ deletions.push({ path: file.name, line: oldLine, side: "LEFT", kind: "deletion", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
313
+ oldLine++
314
+ continue
315
+ }
316
+
317
+ if (firstChar === " ") {
318
+ flushChangeBlock()
319
+ const text = line.slice(1)
320
+ anchors.push({ path: file.name, line: newLine, side: "RIGHT", kind: "context", renderLine, text })
321
+ oldLine++
322
+ newLine++
323
+ renderLine += estimatedWrappedLineCount(text, contentWidth, wrapMode)
324
+ }
325
+ }
326
+
327
+ flushChangeBlock()
328
+ return anchors
329
+ }
330
+
331
+ export const getStackedDiffCommentAnchors = (
332
+ stackedFiles: readonly StackedDiffFilePatch[],
333
+ view: "unified" | "split" = "unified",
334
+ wrapMode: "none" | "word" = "none",
335
+ width = 120,
336
+ ): readonly StackedDiffCommentAnchor[] =>
337
+ stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
338
+ ...anchor,
339
+ fileIndex: stackedFile.index,
340
+ localRenderLine: anchor.renderLine,
341
+ renderLine: stackedFile.diffStartLine + anchor.renderLine,
342
+ })))
343
+
344
+ export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
345
+ if (anchors.length === 0) return 0
346
+ const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
347
+ return nextIndex >= 0 ? nextIndex : anchors.length - 1
348
+ }
349
+
350
+ export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: number, line: number, margin = 1) => {
351
+ const safeViewportHeight = Math.max(1, viewportHeight)
352
+ if (line < currentTop + margin) return Math.max(0, line - margin)
353
+ if (line >= currentTop + safeViewportHeight - margin) return Math.max(0, line - safeViewportHeight + margin + 1)
354
+ return currentTop
355
+ }
356
+
157
357
  const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
158
358
  if (wrapMode === "none") return 1
159
359
  return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
@@ -166,10 +366,10 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
166
366
  let newLine = 0
167
367
 
168
368
  for (const line of lines) {
169
- const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
369
+ const hunk = line.match(hunkHeaderPattern)
170
370
  if (hunk) {
171
371
  oldLine = Number(hunk[1])
172
- newLine = Number(hunk[2])
372
+ newLine = Number(hunk[3])
173
373
  maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
174
374
  continue
175
375
  }
@@ -196,10 +396,7 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
196
396
 
197
397
  export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
198
398
  const lines = patch.split("\n")
199
- const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
200
- const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
201
- const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
202
- const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
399
+ const contentWidth = diffContentWidth(lines, view, width)
203
400
  let count = 0
204
401
  let inHunk = false
205
402
  let deletions: number[] = []
package/src/ui/modals.tsx CHANGED
@@ -1,12 +1,14 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
2
+ import { Data } from "effect"
3
+ import { formatShortDate, formatTimestamp } from "../date.js"
4
+ import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
3
5
  import { availableMergeActions } from "../mergeActions.js"
6
+ import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
4
7
  import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
5
8
  import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
6
9
  import { labelColor, shortRepoName } from "./pullRequests.js"
7
10
 
8
11
  export interface LabelModalState {
9
- readonly open: boolean
10
12
  readonly repository: string | null
11
13
  readonly query: string
12
14
  readonly selectedIndex: number
@@ -15,7 +17,6 @@ export interface LabelModalState {
15
17
  }
16
18
 
17
19
  export interface MergeModalState {
18
- readonly open: boolean
19
20
  readonly repository: string | null
20
21
  readonly number: number | null
21
22
  readonly selectedIndex: number
@@ -26,7 +27,6 @@ export interface MergeModalState {
26
27
  }
27
28
 
28
29
  export interface CloseModalState {
29
- readonly open: boolean
30
30
  readonly repository: string | null
31
31
  readonly number: number | null
32
32
  readonly title: string
@@ -35,15 +35,23 @@ export interface CloseModalState {
35
35
  readonly error: string | null
36
36
  }
37
37
 
38
+ export interface CommentModalState {
39
+ readonly body: string
40
+ readonly cursor: number
41
+ readonly error: string | null
42
+ }
43
+
44
+ export interface CommentThreadModalState {
45
+ readonly scrollOffset: number
46
+ }
47
+
38
48
  export interface ThemeModalState {
39
- readonly open: boolean
40
49
  readonly query: string
41
50
  readonly filterMode: boolean
42
51
  readonly initialThemeId: ThemeId
43
52
  }
44
53
 
45
54
  export const initialLabelModalState: LabelModalState = {
46
- open: false,
47
55
  repository: null,
48
56
  query: "",
49
57
  selectedIndex: 0,
@@ -52,7 +60,6 @@ export const initialLabelModalState: LabelModalState = {
52
60
  }
53
61
 
54
62
  export const initialMergeModalState: MergeModalState = {
55
- open: false,
56
63
  repository: null,
57
64
  number: null,
58
65
  selectedIndex: 0,
@@ -63,7 +70,6 @@ export const initialMergeModalState: MergeModalState = {
63
70
  }
64
71
 
65
72
  export const initialCloseModalState: CloseModalState = {
66
- open: false,
67
73
  repository: null,
68
74
  number: null,
69
75
  title: "",
@@ -72,13 +78,47 @@ export const initialCloseModalState: CloseModalState = {
72
78
  error: null,
73
79
  }
74
80
 
81
+ export const initialCommentModalState: CommentModalState = {
82
+ body: "",
83
+ cursor: 0,
84
+ error: null,
85
+ }
86
+
87
+ export const initialCommentThreadModalState: CommentThreadModalState = {
88
+ scrollOffset: 0,
89
+ }
90
+
75
91
  export const initialThemeModalState: ThemeModalState = {
76
- open: false,
77
92
  query: "",
78
93
  filterMode: false,
79
94
  initialThemeId: "ghui",
80
95
  }
81
96
 
97
+ export type Modal = Data.TaggedEnum<{
98
+ None: {}
99
+ Label: { readonly state: LabelModalState }
100
+ Close: { readonly state: CloseModalState }
101
+ Merge: { readonly state: MergeModalState }
102
+ Comment: { readonly state: CommentModalState }
103
+ CommentThread: { readonly state: CommentThreadModalState }
104
+ Theme: { readonly state: ThemeModalState }
105
+ }>
106
+
107
+ export const Modal = Data.taggedEnum<Modal>()
108
+ export const initialModal: Modal = Modal.None()
109
+
110
+ export type ModalTag = Modal["_tag"]
111
+ export type ModalState<Tag extends Exclude<ModalTag, "None">> = Extract<Modal, { _tag: Tag }>["state"]
112
+
113
+ export const modalInitialStates = {
114
+ Label: initialLabelModalState,
115
+ Close: initialCloseModalState,
116
+ Merge: initialMergeModalState,
117
+ Comment: initialCommentModalState,
118
+ CommentThread: initialCommentThreadModalState,
119
+ Theme: initialThemeModalState,
120
+ } as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
121
+
82
122
  const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
83
123
  if (!info) return "Loading merge status from GitHub."
84
124
  if (info.state !== "open") return "This pull request is not open."
@@ -349,6 +389,193 @@ export const CloseModal = ({
349
389
  )
350
390
  }
351
391
 
392
+ export const CommentModal = ({
393
+ state,
394
+ anchorLabel,
395
+ modalWidth,
396
+ modalHeight,
397
+ offsetLeft,
398
+ offsetTop,
399
+ }: {
400
+ state: CommentModalState
401
+ anchorLabel: string
402
+ modalWidth: number
403
+ modalHeight: number
404
+ offsetLeft: number
405
+ offsetTop: number
406
+ }) => {
407
+ const innerWidth = Math.max(16, modalWidth - 2)
408
+ const contentWidth = Math.max(14, innerWidth - 2)
409
+ const title = "Comment"
410
+ const rightText = "enter save"
411
+ const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
412
+ const bodyHeight = Math.max(1, modalHeight - 7)
413
+ const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
414
+ const lineRanges = commentEditorLines(state.body)
415
+ const cursor = clampCursor(state.body, state.cursor)
416
+ const cursorLineIndex = cursorLineIndexForLines(lineRanges, cursor)
417
+ const visibleStart = Math.min(
418
+ Math.max(0, lineRanges.length - editorHeight),
419
+ Math.max(0, cursorLineIndex - editorHeight + 1),
420
+ )
421
+ const visibleLines = lineRanges.slice(visibleStart, visibleStart + editorHeight)
422
+ const renderEditorLine = (line: { readonly text: string; readonly start: number; readonly end: number }, index: number) => {
423
+ const lineIndex = visibleStart + index
424
+ const isCursorLine = lineIndex === cursorLineIndex
425
+ const cursorColumn = Math.max(0, Math.min(cursor - line.start, line.text.length))
426
+ const viewStart = isCursorLine ? Math.max(0, cursorColumn - contentWidth + 1) : 0
427
+ const visibleText = line.text.slice(viewStart, viewStart + contentWidth)
428
+
429
+ if (!isCursorLine) {
430
+ return <PlainLine key={lineIndex} text={fitCell(visibleText, contentWidth)} fg={state.body.length > 0 ? colors.text : colors.muted} />
431
+ }
432
+
433
+ const cursorInView = cursorColumn - viewStart
434
+ const before = visibleText.slice(0, cursorInView)
435
+ const placeholder = state.body.length === 0 ? "Write a comment..." : ""
436
+ const cursorChar = placeholder ? placeholder[0] ?? " " : visibleText[cursorInView] ?? " "
437
+ const after = placeholder ? placeholder.slice(1) : visibleText.slice(cursorInView + 1)
438
+
439
+ return (
440
+ <TextLine key={lineIndex}>
441
+ {before ? <span fg={colors.text}>{before}</span> : null}
442
+ <span bg={colors.accent} fg={colors.background}>{cursorChar}</span>
443
+ {after ? <span fg={placeholder ? colors.muted : colors.text}>{after}</span> : null}
444
+ </TextLine>
445
+ )
446
+ }
447
+
448
+ return (
449
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
450
+ <box height={1} paddingLeft={1} paddingRight={1}>
451
+ <TextLine>
452
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
453
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
454
+ <span fg={colors.muted}>{rightText}</span>
455
+ </TextLine>
456
+ </box>
457
+ <box height={1} paddingLeft={1} paddingRight={1}>
458
+ <PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
459
+ </box>
460
+ <Divider width={innerWidth} />
461
+ <box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
462
+ {state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
463
+ {visibleLines.map(renderEditorLine)}
464
+ </box>
465
+ <Divider width={innerWidth} />
466
+ <box height={1} paddingLeft={1} paddingRight={1}>
467
+ <TextLine>
468
+ <span fg={colors.count}>enter</span>
469
+ <span fg={colors.muted}> save </span>
470
+ <span fg={colors.count}>shift-enter</span>
471
+ <span fg={colors.muted}> newline </span>
472
+ <span fg={colors.count}>esc</span>
473
+ <span fg={colors.muted}> cancel</span>
474
+ </TextLine>
475
+ </box>
476
+ </ModalFrame>
477
+ )
478
+ }
479
+
480
+ type CommentThreadRow = {
481
+ readonly key: string
482
+ readonly text: string
483
+ readonly fg: string
484
+ readonly bold?: boolean
485
+ }
486
+
487
+ const wrapCommentBody = (body: string, width: number) => {
488
+ const lines = body.length === 0 ? [""] : body.split("\n")
489
+ return lines.flatMap((line) => {
490
+ if (line.length === 0) return [""]
491
+ const wrapped: string[] = []
492
+ for (let index = 0; index < line.length; index += width) {
493
+ wrapped.push(line.slice(index, index + width))
494
+ }
495
+ return wrapped
496
+ })
497
+ }
498
+
499
+ const formatCommentDate = (date: Date | null) => date ? `${formatShortDate(date)} ${formatTimestamp(date)}` : ""
500
+
501
+ const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentThreadRow[] =>
502
+ comments.flatMap((comment, commentIndex) => {
503
+ const timestamp = formatCommentDate(comment.createdAt)
504
+ const header = timestamp ? `${comment.author} ${timestamp}` : comment.author
505
+ return [
506
+ { key: `${comment.id}:header`, text: header, fg: colors.count, bold: true },
507
+ ...wrapCommentBody(comment.body, width).map((line, lineIndex) => ({
508
+ key: `${comment.id}:body:${lineIndex}`,
509
+ text: line,
510
+ fg: colors.text,
511
+ })),
512
+ ...(commentIndex < comments.length - 1 ? [{ key: `${comment.id}:gap`, text: "", fg: colors.muted }] : []),
513
+ ]
514
+ })
515
+
516
+ export const CommentThreadModal = ({
517
+ state,
518
+ anchorLabel,
519
+ comments,
520
+ modalWidth,
521
+ modalHeight,
522
+ offsetLeft,
523
+ offsetTop,
524
+ }: {
525
+ state: CommentThreadModalState
526
+ anchorLabel: string
527
+ comments: readonly PullRequestReviewComment[]
528
+ modalWidth: number
529
+ modalHeight: number
530
+ offsetLeft: number
531
+ offsetTop: number
532
+ }) => {
533
+ const innerWidth = Math.max(16, modalWidth - 2)
534
+ const contentWidth = Math.max(14, innerWidth - 2)
535
+ const title = "Thread"
536
+ const countText = comments.length === 1 ? "1 comment" : `${comments.length} comments`
537
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
538
+ const bodyHeight = Math.max(1, modalHeight - 7)
539
+ const rows = commentThreadRows(comments, contentWidth)
540
+ const maxScroll = Math.max(0, rows.length - bodyHeight)
541
+ const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
542
+ const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
543
+
544
+ return (
545
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
546
+ <box height={1} paddingLeft={1} paddingRight={1}>
547
+ <TextLine>
548
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
549
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
550
+ <span fg={colors.muted}>{countText}</span>
551
+ </TextLine>
552
+ </box>
553
+ <box height={1} paddingLeft={1} paddingRight={1}>
554
+ <PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
555
+ </box>
556
+ <Divider width={innerWidth} />
557
+ <box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
558
+ {visibleRows.length === 0 ? (
559
+ <PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
560
+ ) : visibleRows.map((row) => (
561
+ <PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
562
+ ))}
563
+ </box>
564
+ <Divider width={innerWidth} />
565
+ <box height={1} paddingLeft={1} paddingRight={1}>
566
+ <TextLine>
567
+ <span fg={colors.count}>↑↓</span>
568
+ <span fg={colors.muted}> scroll </span>
569
+ <span fg={colors.count}>a</span>
570
+ <span fg={colors.muted}> comment </span>
571
+ <span fg={colors.count}>esc</span>
572
+ <span fg={colors.muted}> close</span>
573
+ </TextLine>
574
+ </box>
575
+ </ModalFrame>
576
+ )
577
+ }
578
+
352
579
  export const ThemeModal = ({
353
580
  state,
354
581
  activeThemeId,