@kitlangton/ghui 0.1.21 → 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,77 +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 queryText = query.length > 0 ? query : "type a command, state, or shortcut"
84
- const queryWidth = Math.max(1, contentWidth - 2)
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
85
103
  const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
86
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
+ )
87
170
  useEffect(() => {
88
171
  setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
89
172
  }, [listHeight, rows.length, selectedRowIndex])
90
-
91
173
  return (
92
- <StandardModal
174
+ <ModalFrame
93
175
  left={offsetLeft}
94
176
  top={offsetTop}
95
177
  width={modalWidth}
96
178
  height={modalHeight}
97
- title="Command Palette"
98
- headerRight={{ text: countText }}
99
- subtitle={
179
+ junctionRows={[1, modalHeight - 4]}
180
+ topJunctionColumns={[dividerColumn]}
181
+ >
182
+ <PaddedRow>
100
183
  <TextLine>
101
- <span fg={colors.count}>› </span>
102
- <span fg={query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
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>
188
+ {query.length > 0 ? (
189
+ <>
190
+ <span fg={colors.text}>{queryText}</span>
191
+ <span bg={colors.muted} fg={caretFg}> </span>
192
+ {queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
193
+ </>
194
+ ) : (
195
+ <>
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>
198
+ </>
199
+ )}
200
+ {countText.length > 0 && searchWidth > placeholder.length ? (
201
+ <>
202
+ <span>{" ".repeat(countGap)}</span>
203
+ <span fg={colors.muted}>{countText}</span>
204
+ </>
205
+ ) : null}
103
206
  </TextLine>
104
- }
105
- footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />}
106
- >
107
- {rows.length === 0 ? (
108
- <>
109
- <Filler rows={emptyTopRows} prefix="top" />
110
- <PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
111
- <Filler rows={emptyBottomRows} prefix="bottom" />
112
- </>
113
- ) : (
114
- <>
115
- {visibleRows.map((row, index) => {
116
- const rowIndex = scrollTop + index
117
- if (row._tag === "spacer") {
118
- return <PlainLine key={`spacer-${rowIndex}`} text="" />
119
- }
120
- if (row._tag === "section") {
121
- return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
122
- }
123
-
124
- const { command, commandIndex } = row
125
- const isSelected = commandIndex === clampedIndex
126
- const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
127
- const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
128
- const trailingPadding = shortcut.length === 0 ? 0 : 1
129
- // Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
130
- const SELECTOR_WIDTH = 2
131
- const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
132
- const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
133
- const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
134
- const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
135
- const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
136
-
137
- return (
138
- <box key={command.id} height={1}>
139
- <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
140
- <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
141
- <span> </span>
142
- {isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
143
- {subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
144
- {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
145
- {shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
146
- {trailingPadding > 0 ? <span> </span> : null}
147
- </TextLine>
148
- </box>
149
- )
150
- })}
151
- <Filler rows={bottomPaddingRows} prefix="pad" />
152
- </>
153
- )}
154
- </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>
155
215
  )
156
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,7 +23,12 @@ 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
+ const codeFencePattern = /^```\s*([a-zA-Z0-9_-]+)?/
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
31
+ const codeFenceLine = (line: string) => line.trim().replace(/\\`/g, "`").match(codeFencePattern)
29
32
 
30
33
  export const wrapText = (text: string, width: number): string[] => {
31
34
  if (text.length === 0 || width <= 0) return [""]
@@ -63,6 +66,29 @@ const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLin
63
66
  })
64
67
  }
65
68
 
69
+ const parseCodeSegments = (text: string): PreviewLine["segments"] => {
70
+ const segments: Array<PreviewLine["segments"][number]> = []
71
+ let index = 0
72
+ for (const match of text.matchAll(codeTokenPattern)) {
73
+ const start = match.index ?? 0
74
+ if (start > index) segments.push({ text: text.slice(index, start), fg: colors.text })
75
+ const token = match[0]
76
+ const fg = token.startsWith("//")
77
+ ? colors.muted
78
+ : token.startsWith("`") || token.startsWith("\"") || token.startsWith("'")
79
+ ? colors.inlineCode
80
+ : /^\d/.test(token)
81
+ ? colors.status.review
82
+ : token === "true" || token === "false" || token === "null" || token === "undefined"
83
+ ? colors.status.review
84
+ : colors.accent
85
+ segments.push({ text: token, fg, bold: fg === colors.accent })
86
+ index = start + token.length
87
+ }
88
+ if (index < text.length) segments.push({ text: text.slice(index), fg: colors.text })
89
+ return segments.length > 0 ? segments : [{ text: "", fg: colors.muted }]
90
+ }
91
+
66
92
  const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
67
93
  const tokens = segments.flatMap((segment) =>
68
94
  segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
@@ -102,11 +128,13 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
102
128
  for (const rawLine of sourceLines) {
103
129
  if (preview.length >= limit) break
104
130
 
105
- const line = rawLine.trim()
106
- if (line.startsWith("```")) {
131
+ const fence = codeFenceLine(rawLine)
132
+ if (fence) {
107
133
  inCodeBlock = !inCodeBlock
108
134
  continue
109
135
  }
136
+
137
+ const line = inCodeBlock ? rawLine.replace(/\t/g, " ") : rawLine.trim()
110
138
  if (line.length === 0) continue
111
139
 
112
140
  let text = line
@@ -142,11 +170,9 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
142
170
  text = `> ${line.replace(/^>\s+/, "")}`
143
171
  fg = colors.muted
144
172
  indent = " "
145
- } else if (inCodeBlock) {
146
- fg = colors.muted
147
173
  }
148
174
 
149
- const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
175
+ const wrapped = wrapPreviewSegments(inCodeBlock ? parseCodeSegments(text) : parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
150
176
  for (const wrappedLine of wrapped) {
151
177
  preview.push(wrappedLine)
152
178
  if (preview.length >= limit) break
@@ -160,6 +186,85 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
160
186
  return preview.slice(0, limit)
161
187
  }
162
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
+
163
268
  const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
164
269
  const seen = new Map<string, CheckItem>()
165
270
  for (const check of checks) {
@@ -204,9 +309,10 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
204
309
  const unique = deduplicateChecks(checks)
205
310
  if (unique.length === 0) return null
206
311
 
207
- const colWidth = Math.floor((contentWidth - 1) / 2)
312
+ const columns = 2
313
+ const colWidth = Math.floor((contentWidth - 1) / columns)
208
314
  const nameCol = Math.max(4, colWidth - 2)
209
- const rows = Math.ceil(unique.length / 2)
315
+ const rows = Math.ceil(unique.length / columns)
210
316
 
211
317
  return (
212
318
  <box flexDirection="column">
@@ -214,23 +320,22 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
214
320
  <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
215
321
  </TextLine>
216
322
  {Array.from({ length: rows }, (_, rowIndex) => {
217
- const left = unique[rowIndex * 2]
218
- const right = unique[rowIndex * 2 + 1]
219
323
  return (
220
324
  <TextLine key={rowIndex}>
221
- {left ? (
222
- <>
223
- <span fg={checkColor(left)}>{checkIcon(left)} </span>
224
- <span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
225
- </>
226
- ) : null}
227
- {right ? (
228
- <>
229
- <span fg={colors.muted}> </span>
230
- <span fg={checkColor(right)}>{checkIcon(right)} </span>
231
- <span fg={colors.text}>{right.name}</span>
232
- </>
233
- ) : null}
325
+ {Array.from({ length: columns }, (_, columnIndex) => {
326
+ const check = unique[rowIndex * columns + columnIndex]
327
+ return (
328
+ <Fragment key={columnIndex}>
329
+ {columnIndex > 0 ? <span fg={colors.muted}> </span> : null}
330
+ {check ? (
331
+ <>
332
+ <span fg={checkColor(check)}>{checkIcon(check)} </span>
333
+ <span fg={colors.text}>{fitCell(check.name, nameCol)}</span>
334
+ </>
335
+ ) : <span>{" ".repeat(colWidth)}</span>}
336
+ </Fragment>
337
+ )
338
+ })}
234
339
  </TextLine>
235
340
  )
236
341
  })}
@@ -238,13 +343,38 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
238
343
  )
239
344
  }
240
345
 
241
- 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[] => {
242
366
  if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
243
367
  const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
244
368
  const detailDividerRow = 1 + titleLines + 1
245
369
  const checks = deduplicateChecks(pullRequest.checks)
246
370
  const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
247
- 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)
248
378
  }
249
379
 
250
380
  export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
@@ -255,14 +385,14 @@ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneW
255
385
  return titleLines + 3 + checksHeight
256
386
  }
257
387
 
258
- 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") => {
259
389
  if (!pullRequest) return bodyLines
260
390
  if (!pullRequest.detailLoaded) return bodyLines
261
- return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
391
+ return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
262
392
  }
263
393
 
264
- export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
265
- 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)
266
396
  }
267
397
 
268
398
  export const getDetailsPaneHeight = ({
@@ -271,14 +401,18 @@ export const getDetailsPaneHeight = ({
271
401
  bodyLines = DETAIL_BODY_LINES,
272
402
  paneWidth = contentWidth + 2,
273
403
  showChecks = false,
404
+ conversationItems = [],
405
+ conversationStatus = "idle",
274
406
  }: {
275
407
  pullRequest: PullRequestItem | null
276
408
  contentWidth: number
277
409
  bodyLines?: number
278
410
  paneWidth?: number
279
411
  showChecks?: boolean
412
+ conversationItems?: readonly PullRequestConversationItem[]
413
+ conversationStatus?: DetailConversationStatus
280
414
  }) => pullRequest
281
- ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
415
+ ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
282
416
  : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
283
417
 
284
418
  export const DetailHeader = ({
@@ -368,21 +502,27 @@ export const DetailHeader = ({
368
502
  export const DetailBody = ({
369
503
  pullRequest,
370
504
  contentWidth,
505
+ paneWidth = contentWidth + 2,
371
506
  bodyLines = DETAIL_BODY_LINES,
372
507
  bodyLineLimit = bodyLines,
508
+ conversationItems = [],
509
+ conversationStatus = "idle",
373
510
  loadingIndicator,
374
511
  themeId,
375
512
  }: {
376
513
  pullRequest: PullRequestItem
377
514
  contentWidth: number
515
+ paneWidth?: number
378
516
  bodyLines?: number
379
517
  bodyLineLimit?: number
518
+ conversationItems?: readonly PullRequestConversationItem[]
519
+ conversationStatus?: DetailConversationStatus
380
520
  loadingIndicator: string
381
521
  themeId: ThemeId
382
522
  }) => {
383
523
  const previewLines = useMemo(
384
- () => bodyPreview(pullRequest.body, contentWidth, bodyLineLimit),
385
- [pullRequest.body, contentWidth, bodyLineLimit, themeId],
524
+ () => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
525
+ [pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
386
526
  )
387
527
 
388
528
  if (!pullRequest.detailLoaded) {
@@ -398,21 +538,15 @@ export const DetailBody = ({
398
538
  }
399
539
 
400
540
  return (
401
- <box flexDirection="column" paddingLeft={1} paddingRight={1} height={previewLines.length}>
541
+ <box flexDirection="column" height={previewLines.length}>
402
542
  {previewLines.map((line, index) => (
403
- <TextLine key={`${pullRequest.url}-${index}`}>
404
- {line.segments.map((segment, segmentIndex) => (
405
- ("bold" in segment && segment.bold === true) ? (
406
- <span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
407
- {segment.text}
408
- </span>
409
- ) : (
410
- <span key={segmentIndex} fg={segment.fg}>
411
- {segment.text}
412
- </span>
413
- )
414
- ))}
415
- </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
+ )
416
550
  ))}
417
551
  </box>
418
552
  )
@@ -473,6 +607,8 @@ export const DetailsPane = ({
473
607
  bodyLineLimit = bodyLines,
474
608
  paneWidth = contentWidth + 2,
475
609
  showChecks = false,
610
+ conversationItems = [],
611
+ conversationStatus = "idle",
476
612
  placeholderContent,
477
613
  loadingIndicator,
478
614
  themeId,
@@ -484,18 +620,20 @@ export const DetailsPane = ({
484
620
  bodyLineLimit?: number
485
621
  paneWidth?: number
486
622
  showChecks?: boolean
623
+ conversationItems?: readonly PullRequestConversationItem[]
624
+ conversationStatus?: DetailConversationStatus
487
625
  placeholderContent: DetailPlaceholderContent
488
626
  loadingIndicator: string
489
627
  themeId: ThemeId
490
628
  }) => {
491
- const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
629
+ const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
492
630
 
493
631
  return (
494
632
  <box flexDirection="column" height={contentHeight}>
495
633
  {pullRequest ? (
496
634
  <>
497
635
  <DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
498
- <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} />
499
637
  </>
500
638
  ) : (
501
639
  <>