@kitlangton/ghui 0.1.20 → 0.1.22

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.
@@ -21,7 +21,9 @@ interface AppCommandActions {
21
21
  readonly toggleDiffRenderView: () => void
22
22
  readonly toggleDiffWrapMode: () => void
23
23
  readonly jumpDiffFile: (delta: 1 | -1) => void
24
- readonly toggleDiffCommentMode: () => void
24
+ readonly openSelectedDiffComment: () => void
25
+ readonly toggleDiffCommentRange: () => void
26
+ readonly moveDiffCommentThread: (delta: 1 | -1) => void
25
27
  readonly openDiffCommentModal: () => void
26
28
  readonly togglePullRequestDraftStatus: () => void
27
29
  readonly openLabelModal: () => void
@@ -50,8 +52,10 @@ interface BuildAppCommandsInput {
50
52
  readonly diffWrapMode: DiffWrapMode
51
53
  readonly readyDiffFileCount: number
52
54
  readonly diffFileIndex: number
53
- readonly diffCommentMode: boolean
55
+ readonly diffRangeActive: boolean
54
56
  readonly selectedDiffCommentAnchorLabel: string | null
57
+ readonly selectedDiffCommentThreadCount: number
58
+ readonly hasDiffCommentThreads: boolean
55
59
  readonly actions: AppCommandActions
56
60
  }
57
61
 
@@ -73,8 +77,10 @@ export const buildAppCommands = ({
73
77
  diffWrapMode,
74
78
  readyDiffFileCount,
75
79
  diffFileIndex,
76
- diffCommentMode,
80
+ diffRangeActive,
77
81
  selectedDiffCommentAnchorLabel,
82
+ selectedDiffCommentThreadCount,
83
+ hasDiffCommentThreads,
78
84
  actions,
79
85
  }: BuildAppCommandsInput): readonly AppCommand[] => {
80
86
  const selectedPullRequestLabel = selectedPullRequest ? `#${selectedPullRequest.number} ${selectedPullRequest.repository}` : "No pull request selected"
@@ -84,6 +90,12 @@ export const buildAppCommands = ({
84
90
  ? diffReady ? null : "Load the diff before running this command."
85
91
  : noPullRequestReason
86
92
  const diffOpenReadyReason = diffFullView ? diffReadyReason : "Open a diff first."
93
+ const selectedDiffLineReason = diffFullView && diffReady
94
+ ? selectedDiffCommentAnchorLabel ? null : "No diff line selected."
95
+ : diffOpenReadyReason
96
+ const diffThreadReason = diffFullView && diffReady
97
+ ? hasDiffCommentThreads ? null : "No diff comments loaded."
98
+ : diffOpenReadyReason
87
99
  const loadMoreDisabledReason = isLoadingMorePullRequests
88
100
  ? "Already loading more pull requests."
89
101
  : hasMorePullRequests ? null : "No more pull requests loaded by this view."
@@ -219,7 +231,7 @@ export const buildAppCommands = ({
219
231
  title: "Toggle diff split/unified view",
220
232
  scope: "Diff",
221
233
  subtitle: effectiveDiffRenderView === "split" ? "Switch to unified view" : "Switch to split view",
222
- shortcut: "v",
234
+ shortcut: "shift-v",
223
235
  disabledReason: diffFullView ? null : "Open a diff first.",
224
236
  run: actions.toggleDiffRenderView,
225
237
  }),
@@ -251,14 +263,44 @@ export const buildAppCommands = ({
251
263
  run: () => actions.jumpDiffFile(-1),
252
264
  }),
253
265
  defineCommand({
254
- id: "diff.comment-mode",
255
- title: diffCommentMode ? "Exit diff comment mode" : "Enter diff comment mode",
266
+ id: "diff.open-comment-target",
267
+ title: selectedDiffCommentThreadCount > 0 ? "Open selected diff thread" : "Comment on selected diff line",
268
+ scope: "Diff",
269
+ subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
270
+ shortcut: "enter",
271
+ disabledReason: selectedDiffLineReason,
272
+ keywords: ["review", "comment", "thread", "line"],
273
+ run: actions.openSelectedDiffComment,
274
+ }),
275
+ defineCommand({
276
+ id: "diff.toggle-range",
277
+ title: diffRangeActive ? "Clear diff comment range" : "Start diff comment range",
278
+ scope: "Diff",
279
+ subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
280
+ shortcut: "v",
281
+ disabledReason: selectedDiffLineReason,
282
+ keywords: ["review", "comment", "range", "visual"],
283
+ run: actions.toggleDiffCommentRange,
284
+ }),
285
+ defineCommand({
286
+ id: "diff.next-thread",
287
+ title: "Next diff thread",
288
+ scope: "Diff",
289
+ subtitle: hasDiffCommentThreads ? "Jump to the next commented line" : "No diff comments loaded",
290
+ shortcut: "n",
291
+ disabledReason: diffThreadReason,
292
+ keywords: ["review", "comment", "thread"],
293
+ run: () => actions.moveDiffCommentThread(1),
294
+ }),
295
+ defineCommand({
296
+ id: "diff.previous-thread",
297
+ title: "Previous diff thread",
256
298
  scope: "Diff",
257
- subtitle: diffCommentMode ? "Return to diff scrolling" : "Choose a line to comment on",
258
- shortcut: "c",
259
- disabledReason: diffFullView && diffReady ? null : diffOpenReadyReason,
260
- keywords: ["review", "comment", "line"],
261
- run: actions.toggleDiffCommentMode,
299
+ subtitle: hasDiffCommentThreads ? "Jump to the previous commented line" : "No diff comments loaded",
300
+ shortcut: "p",
301
+ disabledReason: diffThreadReason,
302
+ keywords: ["review", "comment", "thread"],
303
+ run: () => actions.moveDiffCommentThread(-1),
262
304
  }),
263
305
  defineCommand({
264
306
  id: "diff.add-comment",
@@ -266,7 +308,7 @@ export const buildAppCommands = ({
266
308
  scope: "Diff",
267
309
  subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
268
310
  shortcut: "a",
269
- disabledReason: diffCommentMode && selectedDiffCommentAnchorLabel ? null : "Enter diff comment mode and select a line first.",
311
+ disabledReason: selectedDiffLineReason,
270
312
  keywords: ["review", "reply"],
271
313
  run: actions.openDiffCommentModal,
272
314
  }),
package/src/commands.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Navigation" | "System"
2
2
 
3
+ const SCOPE_ORDER: readonly CommandScope[] = ["Global", "View", "Pull request", "Diff", "Navigation", "System"]
4
+
5
+ export const sortCommandsByScope = (commands: readonly AppCommand[]) =>
6
+ [...commands].sort((left, right) => SCOPE_ORDER.indexOf(left.scope) - SCOPE_ORDER.indexOf(right.scope))
7
+
3
8
  export interface AppCommand {
4
9
  readonly id: string
5
10
  readonly title: string
package/src/domain.ts CHANGED
@@ -62,6 +62,8 @@ export interface CreatePullRequestCommentInput {
62
62
  readonly path: string
63
63
  readonly line: number
64
64
  readonly side: DiffCommentSide
65
+ readonly startLine?: number
66
+ readonly startSide?: DiffCommentSide
65
67
  readonly body: string
66
68
  }
67
69
 
package/src/index.tsx CHANGED
@@ -3,6 +3,8 @@
3
3
  import { addDefaultParsers, createCliRenderer, createTerminalPalette } from "@opentui/core"
4
4
  import { RegistryProvider } from "@effect/atom-react"
5
5
  import { createRoot } from "@opentui/react"
6
+ import { KeymapProvider } from "@opentui/keymap/react"
7
+ import { createKeymap } from "./keyboard/createKeymap.js"
6
8
 
7
9
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
8
10
 
@@ -43,8 +45,12 @@ const renderer = await createCliRenderer({
43
45
 
44
46
  process.stdout.write(FOCUS_REPORTING_ENABLE)
45
47
 
48
+ const keymap = createKeymap(renderer)
49
+
46
50
  createRoot(renderer).render(
47
51
  <RegistryProvider>
48
- <App />
52
+ <KeymapProvider keymap={keymap}>
53
+ <App />
54
+ </KeymapProvider>
49
55
  </RegistryProvider>,
50
56
  )
@@ -0,0 +1,25 @@
1
+ import type { CliRenderer } from "@opentui/core"
2
+ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
3
+
4
+ /**
5
+ * Creates the OpenTUI keymap with ghui's customizations.
6
+ *
7
+ * The default emacs-style parser only treats whitespace-separated input as a
8
+ * sequence when at least one stroke has a "+" modifier. Prepend a parser that
9
+ * handles plain plain-key sequences ("g g") so vim-style multi-stroke bindings
10
+ * can be authored directly.
11
+ */
12
+ export const createKeymap = (renderer: CliRenderer) => {
13
+ const keymap = createDefaultOpenTuiKeymap(renderer)
14
+ keymap.prependBindingParser(({ input, index, parseObjectKey }) => {
15
+ if (index !== 0) return undefined
16
+ const strokes = input.trim().split(/\s+/).filter(Boolean)
17
+ if (strokes.length <= 1) return undefined
18
+ if (strokes.some((stroke) => stroke.includes("+"))) return undefined
19
+ return {
20
+ parts: strokes.map((stroke) => parseObjectKey({ name: stroke.toLowerCase() })),
21
+ nextIndex: input.length,
22
+ }
23
+ })
24
+ return keymap
25
+ }
@@ -0,0 +1,30 @@
1
+ import { useBindings } from "@opentui/keymap/react"
2
+ import type { RefObject } from "react"
3
+ import { useRef } from "react"
4
+ import type { AppCommand } from "../commands.js"
5
+
6
+ /**
7
+ * Registers each AppCommand under its ID as a named keymap command, so
8
+ * bindings can reference them by ID (`cmd: "pull.refresh"`) and the keymap's
9
+ * introspection (queryCommands, useActiveKeys) sees our commands.
10
+ *
11
+ * The set of IDs is captured at first render — adding to the static list
12
+ * later in the session would not be picked up.
13
+ */
14
+ export const useAppCommandRegistry = (
15
+ appCommands: readonly AppCommand[],
16
+ runCommandByIdRef: RefObject<(id: string, options?: { readonly notifyDisabled?: boolean }) => boolean>,
17
+ ) => {
18
+ const idsRef = useRef(appCommands.map((command) => command.id))
19
+
20
+ useBindings(() => ({
21
+ commands: idsRef.current.map((id) => ({
22
+ name: id,
23
+ run: () => {
24
+ runCommandByIdRef.current(id)
25
+ return true
26
+ },
27
+ })),
28
+ bindings: [],
29
+ }), [])
30
+ }
@@ -0,0 +1,66 @@
1
+ import { useBindings } from "@opentui/keymap/react"
2
+ import { useRef } from "react"
3
+
4
+ export type ScopedBindingAction = (() => void) | string
5
+
6
+ export interface ScopedBindingsOptions {
7
+ readonly when: boolean
8
+ readonly bindings: Readonly<Record<string, ScopedBindingAction>>
9
+ }
10
+
11
+ /**
12
+ * Wraps `@opentui/keymap/react`'s `useBindings` so callers don't have to write
13
+ * the ref dance themselves. The layer registers exactly once (deps=[]); the
14
+ * `when` flag and the action callbacks read latest values via refs, so the
15
+ * binding-shape is captured at first render but closures stay fresh.
16
+ *
17
+ * Don't change which keys you bind across renders — only what they do.
18
+ */
19
+ export const useScopedBindings = ({ when, bindings }: ScopedBindingsOptions): void => {
20
+ const activeRef = useRef(false)
21
+ activeRef.current = when
22
+
23
+ const actionsRef = useRef(bindings)
24
+ actionsRef.current = bindings
25
+
26
+ useBindings(() => ({
27
+ enabled: () => activeRef.current,
28
+ bindings: Object.entries(bindings).map(([key, action]) => ({
29
+ key,
30
+ cmd: typeof action === "string" ? action : () => {
31
+ const current = actionsRef.current[key]
32
+ if (typeof current === "function") current()
33
+ },
34
+ })),
35
+ }), [])
36
+ }
37
+
38
+ /**
39
+ * Vim-style scroll bindings: j/k/up/down for line scroll, ctrl-u/d/v + pageup/down
40
+ * for half-page scroll. When `scrollTo` is given, also adds home/end and gg/G to
41
+ * jump to start/end.
42
+ */
43
+ export const scrollBindings = (
44
+ scrollBy: (delta: number) => void,
45
+ halfPage: number,
46
+ scrollTo?: (y: number) => void,
47
+ ): Record<string, ScopedBindingAction> => {
48
+ const bindings: Record<string, ScopedBindingAction> = {
49
+ up: () => scrollBy(-1),
50
+ k: () => scrollBy(-1),
51
+ down: () => scrollBy(1),
52
+ j: () => scrollBy(1),
53
+ pageup: () => scrollBy(-halfPage),
54
+ pagedown: () => scrollBy(halfPage),
55
+ "ctrl+u": () => scrollBy(-halfPage),
56
+ "ctrl+d": () => scrollBy(halfPage),
57
+ "ctrl+v": () => scrollBy(halfPage),
58
+ }
59
+ if (scrollTo) {
60
+ bindings.home = () => scrollTo(0)
61
+ bindings.end = () => scrollTo(Number.MAX_SAFE_INTEGER)
62
+ bindings["g g"] = () => scrollTo(0)
63
+ bindings["shift+g"] = () => scrollTo(Number.MAX_SAFE_INTEGER)
64
+ }
65
+ return bindings
66
+ }
@@ -663,6 +663,7 @@ export class GitHubService extends Context.Service<GitHubService, {
663
663
  "-f", `path=${input.path}`,
664
664
  "-F", `line=${input.line}`,
665
665
  "-f", `side=${input.side}`,
666
+ ...(input.startLine === undefined ? [] : ["-F", `start_line=${input.startLine}`, "-f", `start_side=${input.startSide ?? input.side}`]),
666
667
  ])
667
668
  return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
668
669
  })
@@ -17,6 +17,7 @@ const scopeLabels = {
17
17
 
18
18
  export type CommandPaletteRow =
19
19
  | { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
20
+ | { readonly _tag: "spacer" }
20
21
  | { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
21
22
 
22
23
  export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
@@ -25,6 +26,7 @@ export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readon
25
26
  for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
26
27
  const command = commands[commandIndex]!
27
28
  if (command.scope !== previousScope) {
29
+ if (previousScope !== null) rows.push({ _tag: "spacer" })
28
30
  rows.push({ _tag: "section", scope: command.scope })
29
31
  previousScope = command.scope
30
32
  }
@@ -78,8 +80,9 @@ export const CommandPalette = ({
78
80
  const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
79
81
  const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
80
82
  const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
81
- const queryText = query.length > 0 ? query : "type a command, state, or shortcut"
82
- const queryWidth = Math.max(1, contentWidth - 2)
83
+ const queryWidth = Math.max(1, contentWidth)
84
+ const placeholder = "Search word"
85
+ const queryText = trimCell(query, Math.max(0, queryWidth - 1))
83
86
  const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
84
87
  const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
85
88
  useEffect(() => {
@@ -92,12 +95,21 @@ export const CommandPalette = ({
92
95
  top={offsetTop}
93
96
  width={modalWidth}
94
97
  height={modalHeight}
95
- title="Command Palette"
98
+ title="Commands"
96
99
  headerRight={{ text: countText }}
97
100
  subtitle={
98
101
  <TextLine>
99
- <span fg={colors.count}>› </span>
100
- <span fg={query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
102
+ {query.length > 0 ? (
103
+ <>
104
+ <span fg={colors.text}>{queryText}</span>
105
+ <span bg={colors.accent} fg={colors.background}> </span>
106
+ </>
107
+ ) : (
108
+ <>
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>
111
+ </>
112
+ )}
101
113
  </TextLine>
102
114
  }
103
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" }]} />}
@@ -112,24 +124,35 @@ export const CommandPalette = ({
112
124
  <>
113
125
  {visibleRows.map((row, index) => {
114
126
  const rowIndex = scrollTop + index
127
+ if (row._tag === "spacer") {
128
+ return <PlainLine key={`spacer-${rowIndex}`} text="" />
129
+ }
115
130
  if (row._tag === "section") {
116
- return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope]}`, rowWidth)} fg={colors.accent} bold />
131
+ return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
117
132
  }
118
133
 
119
134
  const { command, commandIndex } = row
120
135
  const isSelected = commandIndex === clampedIndex
121
136
  const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
122
- const rightWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(8, shortcut.length + 1))
137
+ const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
123
138
  const trailingPadding = shortcut.length === 0 ? 0 : 1
124
- const titleWidth = Math.max(8, rowWidth - rightWidth - trailingPadding - 2)
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))
125
146
 
126
147
  return (
127
148
  <box key={command.id} height={1}>
128
149
  <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
129
150
  <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
130
151
  <span> </span>
131
- {isSelected ? <span attributes={TextAttributes.BOLD}>{fitCell(command.title, titleWidth)}</span> : <span>{fitCell(command.title, titleWidth)}</span>}
132
- {rightWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, rightWidth, "right")}</span> : null}
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}
133
156
  {trailingPadding > 0 ? <span> </span> : null}
134
157
  </TextLine>
135
158
  </box>
@@ -26,6 +26,9 @@ export const DETAIL_PLACEHOLDER_ROWS = 4
26
26
  export const DETAIL_BODY_SCROLL_LIMIT = 1_000
27
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
@@ -204,9 +230,10 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
204
230
  const unique = deduplicateChecks(checks)
205
231
  if (unique.length === 0) return null
206
232
 
207
- const colWidth = Math.floor((contentWidth - 1) / 2)
233
+ const columns = 2
234
+ const colWidth = Math.floor((contentWidth - 1) / columns)
208
235
  const nameCol = Math.max(4, colWidth - 2)
209
- const rows = Math.ceil(unique.length / 2)
236
+ const rows = Math.ceil(unique.length / columns)
210
237
 
211
238
  return (
212
239
  <box flexDirection="column">
@@ -214,23 +241,22 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
214
241
  <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
215
242
  </TextLine>
216
243
  {Array.from({ length: rows }, (_, rowIndex) => {
217
- const left = unique[rowIndex * 2]
218
- const right = unique[rowIndex * 2 + 1]
219
244
  return (
220
245
  <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}
246
+ {Array.from({ length: columns }, (_, columnIndex) => {
247
+ const check = unique[rowIndex * columns + columnIndex]
248
+ return (
249
+ <Fragment key={columnIndex}>
250
+ {columnIndex > 0 ? <span fg={colors.muted}> </span> : null}
251
+ {check ? (
252
+ <>
253
+ <span fg={checkColor(check)}>{checkIcon(check)} </span>
254
+ <span fg={colors.text}>{fitCell(check.name, nameCol)}</span>
255
+ </>
256
+ ) : <span>{" ".repeat(colWidth)}</span>}
257
+ </Fragment>
258
+ )
259
+ })}
234
260
  </TextLine>
235
261
  )
236
262
  })}
@@ -15,7 +15,7 @@ interface HintsContext {
15
15
  readonly showFilterClear: boolean
16
16
  readonly detailFullView: boolean
17
17
  readonly diffFullView: boolean
18
- readonly diffCommentMode: boolean
18
+ readonly diffRangeActive: boolean
19
19
  readonly hasSelection: boolean
20
20
  readonly canCloseSelection: boolean
21
21
  readonly hasError: boolean
@@ -33,26 +33,17 @@ const filterEditingHints: readonly HintItem[] = [
33
33
  { key: "ctrl-w", label: "word" },
34
34
  ]
35
35
 
36
- const diffCommentModeHints: readonly HintItem[] = [
37
- { key: "↑↓", label: "line" },
38
- { key: "pgup/pgdn", label: "jump" },
39
- { key: "←→", label: "side" },
40
- { key: "enter", label: "open" },
41
- { key: "a", label: "comment" },
42
- { key: "c", label: "done" },
43
- { key: "[]", label: "files" },
44
- { key: "esc", label: "back" },
45
- ]
46
-
47
- const diffViewHints: readonly HintItem[] = [
36
+ const diffViewHints = (ctx: HintsContext): readonly HintItem[] => [
48
37
  { key: "esc", label: "back" },
49
- { key: "v", label: "view" },
50
- { key: "w", label: "wrap" },
51
- { key: "c", label: "comment" },
38
+ { key: "↑↓", label: ctx.diffRangeActive ? "range" : "line" },
39
+ { key: "enter", label: ctx.diffRangeActive ? "comment" : "open" },
40
+ { key: "v", label: ctx.diffRangeActive ? "clear" : "range" },
41
+ { key: "n/p", label: "threads" },
52
42
  { key: "[]", label: "files" },
43
+ { key: "V", label: "view" },
44
+ { key: "w", label: "wrap" },
53
45
  { key: "r", label: "reload" },
54
46
  { key: "o", label: "open" },
55
- { key: "q", label: "quit" },
56
47
  ]
57
48
 
58
49
  const detailFullViewHints = (ctx: HintsContext): readonly HintItem[] => [
@@ -91,7 +82,7 @@ const defaultHints = (ctx: HintsContext): readonly HintItem[] => {
91
82
 
92
83
  const footerHints = (ctx: HintsContext): readonly HintItem[] => {
93
84
  if (ctx.filterEditing) return filterEditingHints
94
- if (ctx.diffFullView) return ctx.diffCommentMode ? diffCommentModeHints : diffViewHints
85
+ if (ctx.diffFullView) return diffViewHints(ctx)
95
86
  if (ctx.detailFullView) return detailFullViewHints(ctx)
96
87
  return defaultHints(ctx)
97
88
  }
@@ -2,7 +2,7 @@ import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/c
2
2
  import { useMemo, type Ref } from "react"
3
3
  import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
4
  import { colors, type ThemeId } from "./colors.js"
5
- import { createDiffSyntaxStyle, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
5
+ import { createDiffSyntaxStyle, diffCommentAnchorLabel, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
6
6
  import { LoadingPane, StatusCard } from "./DetailsPane.js"
7
7
  import { DiffStats } from "./diffStats.js"
8
8
  import { Divider, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
@@ -65,6 +65,11 @@ const FileHeader = ({
65
65
  )
66
66
  }
67
67
 
68
+ const firstCommentBodyLine = (body: string) => {
69
+ const newlineIndex = body.indexOf("\n")
70
+ return (newlineIndex >= 0 ? body.slice(0, newlineIndex) : body).trim() || "(empty comment)"
71
+ }
72
+
68
73
  export const PullRequestDiffPane = ({
69
74
  pullRequest,
70
75
  diffState,
@@ -77,8 +82,8 @@ export const PullRequestDiffPane = ({
77
82
  loadingIndicator,
78
83
  scrollRef,
79
84
  setDiffRef,
80
- commentMode,
81
85
  selectedCommentAnchor,
86
+ selectedCommentLabel,
82
87
  selectedCommentThread,
83
88
  onSelectCommentLine,
84
89
  themeId,
@@ -94,8 +99,8 @@ export const PullRequestDiffPane = ({
94
99
  loadingIndicator: string
95
100
  scrollRef: Ref<ScrollBoxRenderable>
96
101
  setDiffRef: (index: number, diff: DiffRenderable | null) => void
97
- commentMode: boolean
98
102
  selectedCommentAnchor: StackedDiffCommentAnchor | null
103
+ selectedCommentLabel: string | null
99
104
  selectedCommentThread: readonly PullRequestReviewComment[]
100
105
  onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
101
106
  themeId: ThemeId
@@ -134,13 +139,14 @@ export const PullRequestDiffPane = ({
134
139
  }
135
140
 
136
141
  const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
137
- const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
142
+ const hasSelectedCommentAnchor = selectedCommentAnchor !== null
143
+ const commentPeek = hasSelectedCommentAnchor && selectedCommentThread.length > 0
138
144
  ? selectedCommentThread[selectedCommentThread.length - 1]!
139
145
  : null
140
146
  const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
141
- const commentPeekBody = commentPeek?.body.split("\n")[0]?.trim() || "(empty comment)"
147
+ const commentPeekBody = commentPeek ? firstCommentBodyLine(commentPeek.body) : "(empty comment)"
142
148
  const commentPeekMeta = commentPeek && selectedCommentAnchor
143
- ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
149
+ ? `${selectedCommentLabel ?? selectedSideLabel ?? "line"} ${commentPeek.author} ${commentPeekCount} enter thread`
144
150
  : ""
145
151
  const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
146
152
  const stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
@@ -149,10 +155,9 @@ export const PullRequestDiffPane = ({
149
155
  const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
150
156
  const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
151
157
  const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
152
- if (!commentMode) return ""
153
- if (!selectedCommentAnchor) return " c no lines"
158
+ if (!selectedCommentAnchor) return " no lines"
154
159
  if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
155
- return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
160
+ return ` ${selectedCommentLabel ?? diffCommentAnchorLabel(selectedCommentAnchor)}`
156
161
  }
157
162
  const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
158
163
  const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
@@ -172,7 +177,7 @@ export const PullRequestDiffPane = ({
172
177
  <box height={height} flexDirection="column">
173
178
  <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
174
179
  <Divider width={paneWidth} />
175
- <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
180
+ <scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
176
181
  {stackedFiles.map((stackedFile) => (
177
182
  <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
178
183
  {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}