@kitlangton/ghui 0.1.22 → 0.2.1

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.
@@ -1,10 +1,10 @@
1
- import { TextAttributes } from "@opentui/core"
1
+ import { TextAttributes, type MouseEvent } from "@opentui/core"
2
2
  import { useEffect, useMemo, useState } from "react"
3
3
  import type { AppCommand } from "../commands.js"
4
4
  import { clampCommandIndex } from "../commands.js"
5
5
  import { colors } from "./colors.js"
6
6
  import { scrollTopForVisibleLine } from "./diff.js"
7
- import { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine, trimCell } from "./primitives.js"
7
+ import { centerCell, Divider, Filler, fitCell, HintRow, ModalFrame, PaddedRow, PlainLine, standardModalDims, TextLine, trimCell } from "./primitives.js"
8
8
 
9
9
  const scopeLabels = {
10
10
  Global: "App",
@@ -55,6 +55,9 @@ export const commandPaletteScrollTop = ({
55
55
  return clampScrollTop(scrollTopForVisibleLine(current, listHeight, selectedRowIndex, 0))
56
56
  }
57
57
 
58
+ export const commandPaletteClampScrollTop = (rowsLength: number, listHeight: number, value: number) =>
59
+ Math.max(0, Math.min(value, Math.max(0, rowsLength - listHeight)))
60
+
58
61
  export const CommandPalette = ({
59
62
  commands,
60
63
  query,
@@ -63,6 +66,8 @@ export const CommandPalette = ({
63
66
  modalHeight,
64
67
  offsetLeft,
65
68
  offsetTop,
69
+ onSelectCommandIndex,
70
+ onRunCommand,
66
71
  }: {
67
72
  commands: readonly AppCommand[]
68
73
  query: string
@@ -71,8 +76,11 @@ export const CommandPalette = ({
71
76
  modalHeight: number
72
77
  offsetLeft: number
73
78
  offsetTop: number
79
+ onSelectCommandIndex: (index: number) => void
80
+ onRunCommand: (command: AppCommand) => void
74
81
  }) => {
75
- const { contentWidth, bodyHeight: listHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
82
+ const { innerWidth, contentWidth, rowWidth } = standardModalDims(modalWidth, modalHeight)
83
+ const listHeight = Math.max(1, modalHeight - 6)
76
84
  const clampedIndex = clampCommandIndex(selectedIndex, commands)
77
85
  const [scrollTop, setScrollTop] = useState(0)
78
86
  const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
@@ -80,87 +88,129 @@ export const CommandPalette = ({
80
88
  const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
81
89
  const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
82
90
  const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
83
- const queryWidth = Math.max(1, contentWidth)
84
- const placeholder = "Search word"
85
- const queryText = trimCell(query, Math.max(0, queryWidth - 1))
91
+ const placeholder = "Search"
92
+ const titleText = "Commands"
93
+ const headerGap = 1
94
+ const headerDivider = "│"
95
+ const searchGap = 1
96
+ const dividerColumn = 1 + titleText.length + headerGap
97
+ const searchStart = titleText.length + headerGap + headerDivider.length + searchGap
98
+ const countGap = countText.length > 0 ? 2 : 0
99
+ const searchWidth = Math.max(1, contentWidth - searchStart - countGap - countText.length)
100
+ const queryText = trimCell(query, Math.max(0, searchWidth - 1))
101
+ const queryPadding = Math.max(0, searchWidth - queryText.length - 1)
102
+ const caretFg = colors.background === "transparent" ? colors.text : colors.background
86
103
  const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
87
104
  const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
105
+ const runCommandOnMouseDown = (command: AppCommand) => (event: MouseEvent) => {
106
+ if (event.button !== 0) return
107
+ event.preventDefault()
108
+ event.stopPropagation()
109
+ onRunCommand(command)
110
+ }
111
+ const selectCommandOnMouse = (commandIndex: number) => (event: MouseEvent) => {
112
+ onSelectCommandIndex(commandIndex)
113
+ event.stopPropagation()
114
+ }
115
+ const handleMouseScroll = (event: MouseEvent) => {
116
+ if (!event.scroll || rows.length <= listHeight) return
117
+ const delta = Math.max(1, Math.ceil(event.scroll.delta))
118
+ const direction = event.scroll.direction === "down" || event.scroll.direction === "right" ? 1 : -1
119
+ setScrollTop((current) => commandPaletteClampScrollTop(rows.length, listHeight, current + direction * delta))
120
+ event.preventDefault()
121
+ event.stopPropagation()
122
+ }
123
+ const content = rows.length === 0 ? (
124
+ <>
125
+ <Filler rows={emptyTopRows} prefix="top" />
126
+ <PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
127
+ <Filler rows={emptyBottomRows} prefix="bottom" />
128
+ </>
129
+ ) : (
130
+ <>
131
+ {visibleRows.map((row, index) => {
132
+ const rowIndex = scrollTop + index
133
+ if (row._tag === "spacer") {
134
+ return <PlainLine key={`spacer-${rowIndex}`} text="" />
135
+ }
136
+ if (row._tag === "section") {
137
+ return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
138
+ }
139
+
140
+ const { command, commandIndex } = row
141
+ const isSelected = commandIndex === clampedIndex
142
+ const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
143
+ const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
144
+ const trailingPadding = shortcut.length === 0 ? 0 : 1
145
+ // Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
146
+ const SELECTOR_WIDTH = 2
147
+ const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
148
+ const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
149
+ const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
150
+ const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
151
+ const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
152
+
153
+ return (
154
+ <box key={command.id} height={1} onMouseDown={runCommandOnMouseDown(command)} onMouseMove={selectCommandOnMouse(commandIndex)} onMouseOver={selectCommandOnMouse(commandIndex)}>
155
+ <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
156
+ <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
157
+ <span> </span>
158
+ {isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
159
+ {subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
160
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
161
+ {shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
162
+ {trailingPadding > 0 ? <span> </span> : null}
163
+ </TextLine>
164
+ </box>
165
+ )
166
+ })}
167
+ <Filler rows={bottomPaddingRows} prefix="pad" />
168
+ </>
169
+ )
88
170
  useEffect(() => {
89
171
  setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
90
172
  }, [listHeight, rows.length, selectedRowIndex])
91
-
92
173
  return (
93
- <StandardModal
174
+ <ModalFrame
94
175
  left={offsetLeft}
95
176
  top={offsetTop}
96
177
  width={modalWidth}
97
178
  height={modalHeight}
98
- title="Commands"
99
- headerRight={{ text: countText }}
100
- subtitle={
179
+ junctionRows={[1, modalHeight - 4]}
180
+ topJunctionColumns={[dividerColumn]}
181
+ >
182
+ <PaddedRow>
101
183
  <TextLine>
184
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{titleText}</span>
185
+ <span>{" ".repeat(headerGap)}</span>
186
+ <span fg={colors.separator}>{headerDivider}</span>
187
+ <span>{" ".repeat(searchGap)}</span>
102
188
  {query.length > 0 ? (
103
189
  <>
104
190
  <span fg={colors.text}>{queryText}</span>
105
- <span bg={colors.accent} fg={colors.background}> </span>
191
+ <span bg={colors.muted} fg={caretFg}> </span>
192
+ {queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
106
193
  </>
107
194
  ) : (
108
195
  <>
109
- <span bg={colors.accent} fg={colors.background}>{placeholder[0]}</span>
110
- <span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, queryWidth - 1))}</span>
196
+ <span bg={colors.muted} fg={caretFg}>{placeholder[0]}</span>
197
+ <span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, searchWidth - 1))}</span>
111
198
  </>
112
199
  )}
200
+ {countText.length > 0 && searchWidth > placeholder.length ? (
201
+ <>
202
+ <span>{" ".repeat(countGap)}</span>
203
+ <span fg={colors.muted}>{countText}</span>
204
+ </>
205
+ ) : null}
113
206
  </TextLine>
114
- }
115
- footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />}
116
- >
117
- {rows.length === 0 ? (
118
- <>
119
- <Filler rows={emptyTopRows} prefix="top" />
120
- <PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
121
- <Filler rows={emptyBottomRows} prefix="bottom" />
122
- </>
123
- ) : (
124
- <>
125
- {visibleRows.map((row, index) => {
126
- const rowIndex = scrollTop + index
127
- if (row._tag === "spacer") {
128
- return <PlainLine key={`spacer-${rowIndex}`} text="" />
129
- }
130
- if (row._tag === "section") {
131
- return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
132
- }
133
-
134
- const { command, commandIndex } = row
135
- const isSelected = commandIndex === clampedIndex
136
- const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
137
- const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
138
- const trailingPadding = shortcut.length === 0 ? 0 : 1
139
- // Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
140
- const SELECTOR_WIDTH = 2
141
- const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
142
- const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
143
- const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
144
- const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
145
- const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
146
-
147
- return (
148
- <box key={command.id} height={1}>
149
- <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
150
- <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
151
- <span> </span>
152
- {isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
153
- {subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
154
- {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
155
- {shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
156
- {trailingPadding > 0 ? <span> </span> : null}
157
- </TextLine>
158
- </box>
159
- )
160
- })}
161
- <Filler rows={bottomPaddingRows} prefix="pad" />
162
- </>
163
- )}
164
- </StandardModal>
207
+ </PaddedRow>
208
+ <Divider width={innerWidth} junctionAt={dividerColumn} junctionChar="" />
209
+ <box height={listHeight} flexDirection="column" onMouseScroll={handleMouseScroll}>{content}</box>
210
+ <Divider width={innerWidth} />
211
+ <PaddedRow>
212
+ <HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />
213
+ </PaddedRow>
214
+ </ModalFrame>
165
215
  )
166
216
  }
@@ -1,19 +1,17 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
2
  import { Fragment, useMemo } from "react"
3
3
  import { formatRelativeDate } from "../date.js"
4
- import type { CheckItem, PullRequestItem } from "../domain.js"
4
+ import type { CheckItem, PullRequestConversationItem, PullRequestItem } from "../domain.js"
5
5
  import { colors, type ThemeId } from "./colors.js"
6
- import { diffStatText } from "./diff.js"
6
+ import { commentCountText, commentDisplayRows, commentSideColor, CommentSegmentsLine, type CommentSegment } from "./comments.js"
7
+ import { diffCommentLineLabel, diffStatText } from "./diff.js"
7
8
  import { DiffStats } from "./diffStats.js"
8
9
  import { centerCell, Divider, Filler, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
9
10
  import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
10
11
 
11
12
  interface PreviewLine {
12
- readonly segments: ReadonlyArray<{
13
- readonly text: string
14
- readonly fg: string
15
- readonly bold?: boolean
16
- }>
13
+ readonly divider?: boolean
14
+ readonly segments: readonly CommentSegment[]
17
15
  }
18
16
 
19
17
  export interface DetailPlaceholderContent {
@@ -25,6 +23,8 @@ export const DETAIL_BODY_LINES = 6
25
23
  export const DETAIL_PLACEHOLDER_ROWS = 4
26
24
  export const DETAIL_BODY_SCROLL_LIMIT = 1_000
27
25
 
26
+ export type DetailConversationStatus = "idle" | "loading" | "ready"
27
+
28
28
  const pullRequestReferencePattern = /(#[0-9]+)/g
29
29
  const codeFencePattern = /^```\s*([a-zA-Z0-9_-]+)?/
30
30
  const codeTokenPattern = /(\/\/.*|`(?:\\.|[^`])*`|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\b(?:async|await|break|case|catch|class|const|continue|default|else|export|extends|finally|for|from|function|if|import|interface|let|new|return|switch|throw|try|type|var|while|yield)\b|\b(?:true|false|null|undefined)\b|\b\d+(?:\.\d+)?\b)/g
@@ -186,6 +186,85 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
186
186
  return preview.slice(0, limit)
187
187
  }
188
188
 
189
+ const previewDivider = (): PreviewLine => ({
190
+ divider: true,
191
+ segments: [],
192
+ })
193
+
194
+ const conversationItemGroups = (item: PullRequestConversationItem, width: number): readonly (readonly CommentSegment[])[] => {
195
+ if (item._tag !== "review-comment") return []
196
+ const locationWidth = Math.max(8, width - item.author.length - 20)
197
+ return [[
198
+ { text: fitCell(item.path, locationWidth), fg: colors.inlineCode },
199
+ { text: diffCommentLineLabel(item), fg: commentSideColor(item.side), bold: true },
200
+ ]]
201
+ }
202
+
203
+ const conversationPreview = ({
204
+ items,
205
+ status,
206
+ width,
207
+ limit,
208
+ }: {
209
+ readonly items: readonly PullRequestConversationItem[]
210
+ readonly status: DetailConversationStatus
211
+ readonly width: number
212
+ readonly limit: number
213
+ }): Array<PreviewLine> => {
214
+ if (status === "idle" || (status === "ready" && items.length === 0) || limit <= 0) return []
215
+ const rows: Array<PreviewLine> = []
216
+ const title = "Conversation"
217
+ const countText = status === "loading" ? "loading" : commentCountText(items.length)
218
+ const gap = Math.max(2, width - title.length - countText.length)
219
+ rows.push({
220
+ segments: [
221
+ { text: title, fg: colors.count, bold: true },
222
+ { text: " ".repeat(gap), fg: colors.muted },
223
+ { text: countText, fg: colors.muted },
224
+ ],
225
+ })
226
+ if (items.length === 0) {
227
+ rows.push({ segments: [{ text: "│ ", fg: colors.muted }, { text: "Loading comments...", fg: colors.muted }] })
228
+ return rows.slice(0, limit)
229
+ }
230
+
231
+ for (const item of items) {
232
+ if (rows.length >= limit) break
233
+ rows.push(...commentDisplayRows({ item, width, groups: conversationItemGroups(item, width) }).slice(0, limit - rows.length))
234
+ }
235
+
236
+ return rows.slice(0, limit)
237
+ }
238
+
239
+ const detailBodyPreview = ({
240
+ pullRequest,
241
+ contentWidth,
242
+ limit,
243
+ conversationItems,
244
+ conversationStatus,
245
+ }: {
246
+ readonly pullRequest: PullRequestItem
247
+ readonly contentWidth: number
248
+ readonly limit: number
249
+ readonly conversationItems: readonly PullRequestConversationItem[]
250
+ readonly conversationStatus: DetailConversationStatus
251
+ }) => {
252
+ const summaryRows = bodyPreview(pullRequest.body, contentWidth, limit)
253
+ const conversationRows = conversationPreview({
254
+ items: conversationItems,
255
+ status: conversationStatus,
256
+ width: contentWidth,
257
+ limit: Math.max(0, limit - summaryRows.length - 1),
258
+ })
259
+
260
+ if (conversationRows.length === 0) return summaryRows
261
+ return [
262
+ ...summaryRows,
263
+ previewDivider(),
264
+ ...conversationRows,
265
+ ].slice(0, limit)
266
+ }
267
+
189
268
  const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
190
269
  const seen = new Map<string, CheckItem>()
191
270
  for (const check of checks) {
@@ -264,13 +343,46 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
264
343
  )
265
344
  }
266
345
 
267
- export const getDetailJunctionRows = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false): readonly number[] => {
346
+ const conversationDividerBodyRow = (pullRequest: PullRequestItem, contentWidth: number, conversationItems: readonly PullRequestConversationItem[], conversationStatus: DetailConversationStatus) => {
347
+ if (!pullRequest.detailLoaded) return null
348
+ const dividerIndex = detailBodyPreview({ pullRequest, contentWidth, limit: DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus })
349
+ .findIndex((line) => line.divider === true)
350
+ return dividerIndex >= 0 ? dividerIndex : null
351
+ }
352
+
353
+ export const getDetailJunctionRows = ({
354
+ pullRequest,
355
+ paneWidth,
356
+ showChecks = false,
357
+ contentWidth,
358
+ conversationItems = [],
359
+ conversationStatus = "idle",
360
+ bodyScrollTop = 0,
361
+ bodyViewportHeight = Number.POSITIVE_INFINITY,
362
+ }: {
363
+ readonly pullRequest: PullRequestItem | null
364
+ readonly paneWidth: number
365
+ readonly showChecks?: boolean
366
+ readonly contentWidth?: number
367
+ readonly conversationItems?: readonly PullRequestConversationItem[]
368
+ readonly conversationStatus?: DetailConversationStatus
369
+ readonly bodyScrollTop?: number
370
+ readonly bodyViewportHeight?: number
371
+ }): readonly number[] => {
268
372
  if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
373
+ const resolvedContentWidth = contentWidth ?? Math.max(1, paneWidth - 2)
269
374
  const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
270
375
  const detailDividerRow = 1 + titleLines + 1
271
376
  const checks = deduplicateChecks(pullRequest.checks)
272
377
  const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
273
- return showChecks && checks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
378
+ const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
379
+ const conversationDivider = conversationDividerBodyRow(pullRequest, resolvedContentWidth, conversationItems, conversationStatus)
380
+ const visibleConversationDivider = conversationDivider === null ? null : conversationDivider - Math.max(0, Math.floor(bodyScrollTop))
381
+ return [
382
+ detailDividerRow,
383
+ showChecks && checks.length > 0 ? checksDividerRow : -1,
384
+ visibleConversationDivider === null || visibleConversationDivider < 0 || visibleConversationDivider >= bodyViewportHeight ? -1 : headerHeight + visibleConversationDivider,
385
+ ].filter((row) => row >= 0)
274
386
  }
275
387
 
276
388
  export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
@@ -281,14 +393,14 @@ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneW
281
393
  return titleLines + 3 + checksHeight
282
394
  }
283
395
 
284
- export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES) => {
396
+ export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
285
397
  if (!pullRequest) return bodyLines
286
398
  if (!pullRequest.detailLoaded) return bodyLines
287
- return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
399
+ return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
288
400
  }
289
401
 
290
- export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
291
- return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
402
+ export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
403
+ return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus)
292
404
  }
293
405
 
294
406
  export const getDetailsPaneHeight = ({
@@ -297,14 +409,18 @@ export const getDetailsPaneHeight = ({
297
409
  bodyLines = DETAIL_BODY_LINES,
298
410
  paneWidth = contentWidth + 2,
299
411
  showChecks = false,
412
+ conversationItems = [],
413
+ conversationStatus = "idle",
300
414
  }: {
301
415
  pullRequest: PullRequestItem | null
302
416
  contentWidth: number
303
417
  bodyLines?: number
304
418
  paneWidth?: number
305
419
  showChecks?: boolean
420
+ conversationItems?: readonly PullRequestConversationItem[]
421
+ conversationStatus?: DetailConversationStatus
306
422
  }) => pullRequest
307
- ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
423
+ ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
308
424
  : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
309
425
 
310
426
  export const DetailHeader = ({
@@ -394,21 +510,27 @@ export const DetailHeader = ({
394
510
  export const DetailBody = ({
395
511
  pullRequest,
396
512
  contentWidth,
513
+ paneWidth = contentWidth + 2,
397
514
  bodyLines = DETAIL_BODY_LINES,
398
515
  bodyLineLimit = bodyLines,
516
+ conversationItems = [],
517
+ conversationStatus = "idle",
399
518
  loadingIndicator,
400
519
  themeId,
401
520
  }: {
402
521
  pullRequest: PullRequestItem
403
522
  contentWidth: number
523
+ paneWidth?: number
404
524
  bodyLines?: number
405
525
  bodyLineLimit?: number
526
+ conversationItems?: readonly PullRequestConversationItem[]
527
+ conversationStatus?: DetailConversationStatus
406
528
  loadingIndicator: string
407
529
  themeId: ThemeId
408
530
  }) => {
409
531
  const previewLines = useMemo(
410
- () => bodyPreview(pullRequest.body, contentWidth, bodyLineLimit),
411
- [pullRequest.body, contentWidth, bodyLineLimit, themeId],
532
+ () => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
533
+ [pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
412
534
  )
413
535
 
414
536
  if (!pullRequest.detailLoaded) {
@@ -424,21 +546,15 @@ export const DetailBody = ({
424
546
  }
425
547
 
426
548
  return (
427
- <box flexDirection="column" paddingLeft={1} paddingRight={1} height={previewLines.length}>
549
+ <box flexDirection="column" height={previewLines.length}>
428
550
  {previewLines.map((line, index) => (
429
- <TextLine key={`${pullRequest.url}-${index}`}>
430
- {line.segments.map((segment, segmentIndex) => (
431
- ("bold" in segment && segment.bold === true) ? (
432
- <span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
433
- {segment.text}
434
- </span>
435
- ) : (
436
- <span key={segmentIndex} fg={segment.fg}>
437
- {segment.text}
438
- </span>
439
- )
440
- ))}
441
- </TextLine>
551
+ line.divider === true ? (
552
+ <Divider key={`${pullRequest.url}-${index}`} width={paneWidth} />
553
+ ) : (
554
+ <PaddedRow key={`${pullRequest.url}-${index}`}>
555
+ <CommentSegmentsLine segments={line.segments} />
556
+ </PaddedRow>
557
+ )
442
558
  ))}
443
559
  </box>
444
560
  )
@@ -499,6 +615,8 @@ export const DetailsPane = ({
499
615
  bodyLineLimit = bodyLines,
500
616
  paneWidth = contentWidth + 2,
501
617
  showChecks = false,
618
+ conversationItems = [],
619
+ conversationStatus = "idle",
502
620
  placeholderContent,
503
621
  loadingIndicator,
504
622
  themeId,
@@ -510,18 +628,20 @@ export const DetailsPane = ({
510
628
  bodyLineLimit?: number
511
629
  paneWidth?: number
512
630
  showChecks?: boolean
631
+ conversationItems?: readonly PullRequestConversationItem[]
632
+ conversationStatus?: DetailConversationStatus
513
633
  placeholderContent: DetailPlaceholderContent
514
634
  loadingIndicator: string
515
635
  themeId: ThemeId
516
636
  }) => {
517
- const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
637
+ const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
518
638
 
519
639
  return (
520
640
  <box flexDirection="column" height={contentHeight}>
521
641
  {pullRequest ? (
522
642
  <>
523
643
  <DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
524
- <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} loadingIndicator={loadingIndicator} themeId={themeId} />
644
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} conversationItems={conversationItems} conversationStatus={conversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
525
645
  </>
526
646
  ) : (
527
647
  <>
@@ -0,0 +1,75 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { colors, mixHex } from "./colors.js"
3
+ import type { DetailPlaceholderContent } from "./DetailsPane.js"
4
+ import { centerCell, Filler, PlainLine, TextLine } from "./primitives.js"
5
+ import { SPINNER_FRAMES } from "./spinner.js"
6
+
7
+ type LoadingLogoContent = Pick<DetailPlaceholderContent, "hint">
8
+
9
+ const GHUI_LOGO = ["█▀▀▀ █ █ █ █ ▀█▀", "█ ▀█ █▀▀█ █ █ █ ", "▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀"] as const
10
+
11
+ const LEFT_WORD_WIDTH = 9
12
+ const LOGO_WIDTH = Math.max(...GHUI_LOGO.map((line) => line.length))
13
+ const LOGO_HEIGHT = GHUI_LOGO.length
14
+ const LOGO_BLOCK_HEIGHT = LOGO_HEIGHT + 2
15
+
16
+ const logoColor = (x: number) => (x < LEFT_WORD_WIDTH ? mixHex(colors.accent, colors.text, 0.14) : mixHex(colors.accent, colors.text, 0.52))
17
+
18
+ const LOGO_COLORS = Array.from({ length: LOGO_WIDTH }, (_, index) => logoColor(index))
19
+ const LOGO_ROWS = GHUI_LOGO.map((line) => Array.from(line.padEnd(LOGO_WIDTH, " "), (char, index) => ({ char, color: LOGO_COLORS[index]! })))
20
+
21
+ const LogoRow = ({ row, left }: { row: (typeof LOGO_ROWS)[number]; left: number }) => (
22
+ <TextLine>
23
+ <span fg={colors.muted}>{" ".repeat(left)}</span>
24
+ {row.map(({ char, color }, index) =>
25
+ char === " " ? (
26
+ <span key={index}> </span>
27
+ ) : (
28
+ <span key={index} fg={color} attributes={TextAttributes.BOLD}>
29
+ {char}
30
+ </span>
31
+ ),
32
+ )}
33
+ </TextLine>
34
+ )
35
+
36
+ export const LoadingLogo = ({ content, width, frame }: { content: LoadingLogoContent; width: number; frame: number }) => {
37
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
38
+ const logoLeft = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
39
+
40
+ return (
41
+ <box flexDirection="column" width={width}>
42
+ {LOGO_ROWS.map((row, index) => (
43
+ <LogoRow key={index} row={row} left={logoLeft} />
44
+ ))}
45
+ <box height={1} />
46
+ <PlainLine text={centerCell(`${spinner} ${content.hint}`, width)} fg={colors.muted} />
47
+ </box>
48
+ )
49
+ }
50
+
51
+ export const LoadingLogoPane = ({ content, width, height, frame }: { content: LoadingLogoContent; width: number; height: number; frame: number }) => {
52
+ if (width < LOGO_WIDTH + 2 || height < LOGO_BLOCK_HEIGHT) {
53
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
54
+ const topRows = Math.max(0, Math.floor((height - 1) / 2))
55
+ const bottomRows = Math.max(0, height - topRows - 1)
56
+ return (
57
+ <box height={height} flexDirection="column">
58
+ <Filler rows={topRows} prefix="loading-logo-compact-top" />
59
+ <PlainLine text={centerCell(`${spinner} ${content.hint}`, width)} fg={colors.muted} />
60
+ <Filler rows={bottomRows} prefix="loading-logo-compact-bottom" />
61
+ </box>
62
+ )
63
+ }
64
+
65
+ const topRows = Math.max(0, Math.floor((height - LOGO_BLOCK_HEIGHT) / 2))
66
+ const bottomRows = Math.max(0, height - topRows - LOGO_BLOCK_HEIGHT)
67
+
68
+ return (
69
+ <box height={height} flexDirection="column">
70
+ <Filler rows={topRows} prefix="loading-logo-top" />
71
+ <LoadingLogo content={content} width={width} frame={frame} />
72
+ <Filler rows={bottomRows} prefix="loading-logo-bottom" />
73
+ </box>
74
+ )
75
+ }