@kitlangton/ghui 0.1.22 → 0.2.0

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,38 @@ 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 summaryRows = bodyPreview(pullRequest.body, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
349
+ const conversationRows = conversationPreview({
350
+ items: conversationItems,
351
+ status: conversationStatus,
352
+ width: contentWidth,
353
+ limit: Math.max(0, DETAIL_BODY_SCROLL_LIMIT - summaryRows.length - 1),
354
+ })
355
+ return conversationRows.length > 0 ? summaryRows.length : null
356
+ }
357
+
358
+ export const getDetailJunctionRows = (
359
+ pullRequest: PullRequestItem | null,
360
+ paneWidth: number,
361
+ showChecks = false,
362
+ contentWidth = Math.max(1, paneWidth - 2),
363
+ conversationItems: readonly PullRequestConversationItem[] = [],
364
+ conversationStatus: DetailConversationStatus = "idle",
365
+ ): readonly number[] => {
268
366
  if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
269
367
  const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
270
368
  const detailDividerRow = 1 + titleLines + 1
271
369
  const checks = deduplicateChecks(pullRequest.checks)
272
370
  const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
273
- return showChecks && checks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
371
+ const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
372
+ const conversationDivider = conversationDividerBodyRow(pullRequest, contentWidth, conversationItems, conversationStatus)
373
+ return [
374
+ detailDividerRow,
375
+ showChecks && checks.length > 0 ? checksDividerRow : -1,
376
+ conversationDivider === null ? -1 : headerHeight + conversationDivider,
377
+ ].filter((row) => row >= 0)
274
378
  }
275
379
 
276
380
  export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
@@ -281,14 +385,14 @@ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneW
281
385
  return titleLines + 3 + checksHeight
282
386
  }
283
387
 
284
- export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES) => {
388
+ export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
285
389
  if (!pullRequest) return bodyLines
286
390
  if (!pullRequest.detailLoaded) return bodyLines
287
- return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
391
+ return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
288
392
  }
289
393
 
290
- export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
291
- return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
394
+ export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
395
+ return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus)
292
396
  }
293
397
 
294
398
  export const getDetailsPaneHeight = ({
@@ -297,14 +401,18 @@ export const getDetailsPaneHeight = ({
297
401
  bodyLines = DETAIL_BODY_LINES,
298
402
  paneWidth = contentWidth + 2,
299
403
  showChecks = false,
404
+ conversationItems = [],
405
+ conversationStatus = "idle",
300
406
  }: {
301
407
  pullRequest: PullRequestItem | null
302
408
  contentWidth: number
303
409
  bodyLines?: number
304
410
  paneWidth?: number
305
411
  showChecks?: boolean
412
+ conversationItems?: readonly PullRequestConversationItem[]
413
+ conversationStatus?: DetailConversationStatus
306
414
  }) => pullRequest
307
- ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
415
+ ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
308
416
  : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
309
417
 
310
418
  export const DetailHeader = ({
@@ -394,21 +502,27 @@ export const DetailHeader = ({
394
502
  export const DetailBody = ({
395
503
  pullRequest,
396
504
  contentWidth,
505
+ paneWidth = contentWidth + 2,
397
506
  bodyLines = DETAIL_BODY_LINES,
398
507
  bodyLineLimit = bodyLines,
508
+ conversationItems = [],
509
+ conversationStatus = "idle",
399
510
  loadingIndicator,
400
511
  themeId,
401
512
  }: {
402
513
  pullRequest: PullRequestItem
403
514
  contentWidth: number
515
+ paneWidth?: number
404
516
  bodyLines?: number
405
517
  bodyLineLimit?: number
518
+ conversationItems?: readonly PullRequestConversationItem[]
519
+ conversationStatus?: DetailConversationStatus
406
520
  loadingIndicator: string
407
521
  themeId: ThemeId
408
522
  }) => {
409
523
  const previewLines = useMemo(
410
- () => bodyPreview(pullRequest.body, contentWidth, bodyLineLimit),
411
- [pullRequest.body, contentWidth, bodyLineLimit, themeId],
524
+ () => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
525
+ [pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
412
526
  )
413
527
 
414
528
  if (!pullRequest.detailLoaded) {
@@ -424,21 +538,15 @@ export const DetailBody = ({
424
538
  }
425
539
 
426
540
  return (
427
- <box flexDirection="column" paddingLeft={1} paddingRight={1} height={previewLines.length}>
541
+ <box flexDirection="column" height={previewLines.length}>
428
542
  {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>
543
+ line.divider === true ? (
544
+ <Divider key={`${pullRequest.url}-${index}`} width={paneWidth} />
545
+ ) : (
546
+ <PaddedRow key={`${pullRequest.url}-${index}`}>
547
+ <CommentSegmentsLine segments={line.segments} />
548
+ </PaddedRow>
549
+ )
442
550
  ))}
443
551
  </box>
444
552
  )
@@ -499,6 +607,8 @@ export const DetailsPane = ({
499
607
  bodyLineLimit = bodyLines,
500
608
  paneWidth = contentWidth + 2,
501
609
  showChecks = false,
610
+ conversationItems = [],
611
+ conversationStatus = "idle",
502
612
  placeholderContent,
503
613
  loadingIndicator,
504
614
  themeId,
@@ -510,18 +620,20 @@ export const DetailsPane = ({
510
620
  bodyLineLimit?: number
511
621
  paneWidth?: number
512
622
  showChecks?: boolean
623
+ conversationItems?: readonly PullRequestConversationItem[]
624
+ conversationStatus?: DetailConversationStatus
513
625
  placeholderContent: DetailPlaceholderContent
514
626
  loadingIndicator: string
515
627
  themeId: ThemeId
516
628
  }) => {
517
- const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
629
+ const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
518
630
 
519
631
  return (
520
632
  <box flexDirection="column" height={contentHeight}>
521
633
  {pullRequest ? (
522
634
  <>
523
635
  <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} />
636
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} conversationItems={conversationItems} conversationStatus={conversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
525
637
  </>
526
638
  ) : (
527
639
  <>
@@ -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
+ }