@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.
package/src/App.tsx CHANGED
@@ -1,18 +1,19 @@
1
1
  import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
- import { useAppCommandRegistry } from "./keyboard/useAppCommandRegistry.js"
4
- import { scrollBindings, useScopedBindings, type ScopedBindingAction } from "./keyboard/useScopedBindings.js"
3
+ import { useKeymap } from "@ghui/keymap/react"
4
+ import { appKeymap, type AppCtx } from "./keymap/all.js"
5
+ import { useOpenTuiSubscribe } from "./keyboard/opentuiAdapter.js"
5
6
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
6
7
  import { Cause, Effect, Layer, Schedule } from "effect"
7
8
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
8
9
  import * as Atom from "effect/unstable/reactivity/Atom"
9
10
  import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
10
- import { useContext, useEffect, useMemo, useRef, useState } from "react"
11
+ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
11
12
  import { buildAppCommands } from "./appCommands.js"
12
13
  import type { AppCommand } from "./commands.js"
13
14
  import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
14
15
  import { config } from "./config.js"
15
- import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
16
+ import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestConversationItem, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
16
17
  import { formatShortDate, formatTimestamp } from "./date.js"
17
18
  import { errorMessage } from "./errors.js"
18
19
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
@@ -23,12 +24,13 @@ import { BrowserOpener } from "./services/BrowserOpener.js"
23
24
  import { Clipboard } from "./services/Clipboard.js"
24
25
  import { CommandRunner } from "./services/CommandRunner.js"
25
26
  import { GitHubService } from "./services/GitHubService.js"
26
- import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
27
+ import { loadStoredDiffWhitespaceMode, loadStoredThemeId, saveStoredDiffWhitespaceMode, saveStoredThemeId } from "./themeStore.js"
27
28
  import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
28
29
  import { backspace as editorBackspace, deleteForward as editorDeleteForward, deleteToLineEnd, deleteToLineStart, deleteWordBackward, deleteWordForward, insertText, moveLeft as editorMoveLeft, moveLineEnd, moveLineStart, moveRight as editorMoveRight, moveVertically, moveWordBackward, moveWordForward, type CommentEditorValue } from "./ui/commentEditor.js"
29
- import { buildStackedDiffFiles, diffCommentAnchorLabel, diffCommentLineLabel, diffCommentLocationKey, diffCommentSideLabel, getStackedDiffCommentAnchors, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffCommentKind, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
30
- import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getScrollableDetailBodyHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
30
+ import { buildStackedDiffFiles, diffAnchorOnSide, diffCommentAnchorLabel, diffCommentLineLabel, diffCommentLocationKey, diffCommentSideLabel, getStackedDiffCommentAnchors, minimizeWhitespaceDiffFiles, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileIndexAtLine, type DiffCommentAnchor, type DiffCommentKind, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type StackedDiffCommentAnchor, verticalDiffAnchor } from "./ui/diff.js"
31
+ import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getScrollableDetailBodyHeight, LoadingPane, type DetailConversationStatus, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
31
32
  import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
33
+ import { LoadingLogoPane } from "./ui/LoadingLogo.js"
32
34
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
33
35
  import { CommandPalette } from "./ui/CommandPalette.js"
34
36
  import { CloseModal, CommentModal, CommentThreadModal, filterLabels, initialCloseModalState, initialCommandPaletteState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialOpenRepositoryModalState, initialThemeModalState, LabelModal, MergeModal, Modal, OpenRepositoryModal, ThemeModal, type CloseModalState, type CommandPaletteState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type OpenRepositoryModalState, type ThemeModalState } from "./ui/modals.js"
@@ -36,6 +38,7 @@ import { groupBy, reviewLabel } from "./ui/pullRequests.js"
36
38
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
37
39
  import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
38
40
  import { editSingleLineInput, isSingleLineInputKey, printableKeyText, singleLineText } from "./ui/singleLineInput.js"
41
+ import { SPINNER_FRAMES } from "./ui/spinner.js"
39
42
 
40
43
  const parseOptionalPositiveInt = (value: string | undefined, fallback: number | null) => {
41
44
  if (value === undefined) return fallback
@@ -55,7 +58,10 @@ const githubRuntime = Atom.runtime(
55
58
  Layer.provideMerge(Observability.layer),
56
59
  ),
57
60
  )
58
- const initialThemeId = await Effect.runPromise(loadStoredThemeId)
61
+ const [initialThemeId, initialDiffWhitespaceMode] = await Promise.all([
62
+ Effect.runPromise(loadStoredThemeId),
63
+ Effect.runPromise(loadStoredDiffWhitespaceMode),
64
+ ])
59
65
 
60
66
  interface PullRequestLoad {
61
67
  readonly view: PullRequestView
@@ -112,7 +118,6 @@ const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
112
118
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
113
119
  const AUTO_REFRESH_JITTER_MS = 10_000
114
120
  const DIFF_STICKY_HEADER_LINES = 2
115
- const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
116
121
  const MAX_REPOSITORY_CACHE_ENTRIES = 8
117
122
  const LOAD_MORE_SELECTION_THRESHOLD = 8
118
123
  const LOAD_MORE_SCROLL_THRESHOLD = 3
@@ -120,24 +125,6 @@ const DETAIL_PREFETCH_BEHIND = 1
120
125
  const DETAIL_PREFETCH_AHEAD = 3
121
126
  const DETAIL_PREFETCH_CONCURRENCY = 3
122
127
  const DETAIL_PREFETCH_DELAY_MS = 120
123
- const MAX_KEYMAP_COUNT_PREFIX = 99
124
-
125
- const countSequence = (count: number, key: string) => `${String(count).split("").join(" ")} ${key}`
126
-
127
- const countedVerticalBindings = (
128
- moveUp: (count: number) => void,
129
- moveDown: (count: number) => void,
130
- ): Record<string, ScopedBindingAction> => {
131
- const bindings: Record<string, ScopedBindingAction> = {}
132
- for (let count = 1; count <= MAX_KEYMAP_COUNT_PREFIX; count++) {
133
- bindings[countSequence(count, "k")] = () => moveUp(count)
134
- bindings[countSequence(count, "up")] = () => moveUp(count)
135
- bindings[countSequence(count, "j")] = () => moveDown(count)
136
- bindings[countSequence(count, "down")] = () => moveDown(count)
137
- }
138
- return bindings
139
- }
140
-
141
128
  const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
142
129
  const seen = new Set(existing.map((pullRequest) => pullRequest.url))
143
130
  const mergedIncoming = mergeCachedDetails(incoming, existing)
@@ -209,10 +196,14 @@ const diffFileIndexAtom = Atom.make(0)
209
196
  const diffScrollTopAtom = Atom.make(0)
210
197
  const diffRenderViewAtom = Atom.make<DiffView>("split")
211
198
  const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
199
+ const diffWhitespaceModeAtom = Atom.make<DiffWhitespaceMode>(initialDiffWhitespaceMode)
212
200
  const diffCommentAnchorIndexAtom = Atom.make(0)
201
+ const diffPreferredSideAtom = Atom.make<DiffCommentSide | null>(null)
213
202
  const diffCommentRangeStartIndexAtom = Atom.make<number | null>(null)
214
203
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
215
204
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
205
+ const pullRequestConversationAtom = Atom.make<Record<string, readonly PullRequestConversationItem[]>>({}).pipe(Atom.keepAlive)
206
+ const pullRequestConversationLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
216
207
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
217
208
 
218
209
  const activeModalAtom = Atom.make<Modal>(initialModal)
@@ -346,6 +337,9 @@ const pullRequestDiffAtom = Atom.family((key: string) => {
346
337
  const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
347
338
  GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
348
339
  )
340
+ const listPullRequestConversationAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
341
+ GitHubService.use((github) => github.listPullRequestConversation(input.repository, input.number))
342
+ )
349
343
  const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
350
344
  GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
351
345
  )
@@ -548,10 +542,14 @@ export const App = () => {
548
542
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
549
543
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
550
544
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
545
+ const [diffWhitespaceMode, setDiffWhitespaceMode] = useAtom(diffWhitespaceModeAtom)
551
546
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
547
+ const [diffPreferredSide, setDiffPreferredSide] = useAtom(diffPreferredSideAtom)
552
548
  const [diffCommentRangeStartIndex, setDiffCommentRangeStartIndex] = useAtom(diffCommentRangeStartIndexAtom)
553
549
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
554
550
  const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
551
+ const setPullRequestConversation = useAtomSet(pullRequestConversationAtom)
552
+ const setPullRequestConversationLoaded = useAtomSet(pullRequestConversationLoadedAtom)
555
553
  const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
556
554
  const [activeModal, setActiveModal] = useAtom(activeModalAtom)
557
555
  const [themeId, setThemeId] = useAtom(themeIdAtom)
@@ -603,7 +601,9 @@ export const App = () => {
603
601
  const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
604
602
  const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
605
603
  const [terminalFocused, setTerminalFocused] = useState(true)
604
+ const [startupLoadComplete, setStartupLoadComplete] = useState(false)
606
605
  const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
606
+ const [detailPreviewScrollTop, setDetailPreviewScrollTop] = useState(0)
607
607
  const usernameResult = useAtomValue(usernameAtom)
608
608
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
609
609
  const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
@@ -611,6 +611,7 @@ export const App = () => {
611
611
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
612
612
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
613
613
  const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
614
+ const listPullRequestConversation = useAtomSet(listPullRequestConversationAtom, { mode: "promise" })
614
615
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
615
616
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
616
617
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
@@ -640,10 +641,11 @@ export const App = () => {
640
641
  const terminalFocusedRef = useRef(true)
641
642
  const terminalWasBlurredRef = useRef(false)
642
643
  const pullRequestStatusRef = useRef<LoadStatus>("loading")
643
- const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
644
+ const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {})
644
645
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
645
646
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
646
647
  const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
648
+ const detailPreviewScrollTopRef = useRef(0)
647
649
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
648
650
  const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
649
651
  const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
@@ -682,7 +684,7 @@ export const App = () => {
682
684
  const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
683
685
  const pullRequests = useAtomValue(displayedPullRequestsAtom)
684
686
  const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
685
- const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
687
+ const isInitialLoading = !startupLoadComplete && pullRequestStatus === "loading" && pullRequests.length === 0
686
688
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
687
689
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
688
690
  pullRequestStatusRef.current = pullRequestStatus
@@ -692,6 +694,8 @@ export const App = () => {
692
694
  const visibleGroups = useAtomValue(visibleGroupsAtom)
693
695
  const visiblePullRequests = useAtomValue(visiblePullRequestsAtom)
694
696
  const selectedPullRequest = useAtomValue(selectedPullRequestAtom)
697
+ const pullRequestConversation = useAtomValue(pullRequestConversationAtom)
698
+ const pullRequestConversationLoaded = useAtomValue(pullRequestConversationLoadedAtom)
695
699
  const selectedRepository = viewRepository(activeView)
696
700
  const activeViews = activePullRequestViews(activeView)
697
701
  const currentQueueCacheKey = viewCacheKey(activeView)
@@ -710,9 +714,22 @@ export const App = () => {
710
714
  }), [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests])
711
715
  const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null)
712
716
  const selectedDiffKey = useAtomValue(selectedDiffKeyAtom)
717
+ const selectedConversationItems = selectedDiffKey ? pullRequestConversation[selectedDiffKey] ?? [] : []
718
+ const selectedConversationStatus: DetailConversationStatus = selectedDiffKey ? pullRequestConversationLoaded[selectedDiffKey] ?? "idle" : "idle"
713
719
  const selectedDiffState = useAtomValue(selectedDiffStateAtom)
714
720
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
715
- const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
721
+ const readyDiffFiles = useMemo(
722
+ () => selectedDiffState?._tag === "Ready"
723
+ ? diffWhitespaceMode === "ignore" ? minimizeWhitespaceDiffFiles(selectedDiffState.files) : selectedDiffState.files
724
+ : [],
725
+ [selectedDiffState, diffWhitespaceMode],
726
+ )
727
+ const displayedDiffState = useMemo(
728
+ () => selectedDiffState?._tag === "Ready"
729
+ ? PullRequestDiffState.Ready({ patch: readyDiffFiles.map((file) => file.patch).join("\n"), files: readyDiffFiles })
730
+ : selectedDiffState,
731
+ [selectedDiffState, readyDiffFiles],
732
+ )
716
733
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
717
734
  const diffCommentAnchors = useMemo(
718
735
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
@@ -783,12 +800,36 @@ export const App = () => {
783
800
  if (!pullRequest) return
784
801
  setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
785
802
  }
786
- const refreshPullRequests = (message?: string) => {
803
+ const markPullRequestCompleted = (pullRequest: PullRequestItem, state: "closed" | "merged") => {
804
+ setRecentlyCompletedPullRequests((current) => ({
805
+ ...current,
806
+ [pullRequest.url]: {
807
+ ...pullRequest,
808
+ state,
809
+ autoMergeEnabled: false,
810
+ },
811
+ }))
812
+ }
813
+ const restoreOptimisticPullRequest = (pullRequest: PullRequestItem) => {
814
+ setRecentlyCompletedPullRequests((current) => {
815
+ if (!(pullRequest.url in current)) return current
816
+ const next = { ...current }
817
+ delete next[pullRequest.url]
818
+ return next
819
+ })
820
+ updatePullRequest(pullRequest.url, () => pullRequest)
821
+ }
822
+ const refreshPullRequests = (message?: string, options: { readonly resetTransientState?: boolean } = {}) => {
787
823
  refreshGenerationRef.current += 1
788
824
  detailHydrationRef.current.clear()
789
825
  if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
790
826
  setLoadingMoreKey(null)
791
827
  setPullRequestOverrides({})
828
+ if (options.resetTransientState) {
829
+ setRecentlyCompletedPullRequests({})
830
+ setPullRequestConversation({})
831
+ setPullRequestConversationLoaded({})
832
+ }
792
833
  if (message) {
793
834
  setNotice(null)
794
835
  setRefreshCompletionMessage(message)
@@ -891,6 +932,29 @@ export const App = () => {
891
932
  })
892
933
  return true
893
934
  }
935
+ const loadPullRequestConversation = (pullRequest: PullRequestItem, force = false) => {
936
+ const key = pullRequestDiffKey(pullRequest)
937
+ const previousLoadState = registry.get(pullRequestConversationLoadedAtom)[key]
938
+ if (!force && previousLoadState) return
939
+ const generation = refreshGenerationRef.current
940
+ setPullRequestConversationLoaded((current) => ({ ...current, [key]: "loading" }))
941
+ void listPullRequestConversation({ repository: pullRequest.repository, number: pullRequest.number })
942
+ .then((items) => {
943
+ if (generation !== refreshGenerationRef.current) return
944
+ setPullRequestConversation((current) => ({ ...current, [key]: items }))
945
+ setPullRequestConversationLoaded((current) => ({ ...current, [key]: "ready" }))
946
+ })
947
+ .catch((error) => {
948
+ if (generation !== refreshGenerationRef.current) return
949
+ setPullRequestConversationLoaded((current) => {
950
+ if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
951
+ const next = { ...current }
952
+ delete next[key]
953
+ return next
954
+ })
955
+ flashNotice(errorMessage(error))
956
+ })
957
+ }
894
958
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
895
959
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
896
960
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -1006,10 +1070,19 @@ export const App = () => {
1006
1070
  setDiffFileIndex(0)
1007
1071
  setDiffScrollTop(0)
1008
1072
  setDiffCommentAnchorIndex(0)
1073
+ setDiffPreferredSide(null)
1009
1074
  setDiffCommentRangeStartIndex(null)
1010
1075
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
1076
+ if (detailPreviewScrollTopRef.current !== 0) {
1077
+ detailPreviewScrollTopRef.current = 0
1078
+ setDetailPreviewScrollTop(0)
1079
+ }
1011
1080
  }, [selectedIndex])
1012
1081
 
1082
+ useEffect(() => {
1083
+ setDiffFileIndex((current) => safeDiffFileIndex(readyDiffFiles, current))
1084
+ }, [readyDiffFiles.length])
1085
+
1013
1086
  useEffect(() => {
1014
1087
  setDiffCommentAnchorIndex((current) => {
1015
1088
  if (diffCommentAnchors.length === 0) return 0
@@ -1069,24 +1142,48 @@ export const App = () => {
1069
1142
  }
1070
1143
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
1071
1144
  }, [selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, selectedDiffCommentRangeAnchors, diffLineColorContextKey, effectiveDiffRenderView, diffCommentThreadAnchors])
1145
+
1146
+ // Scroll the selected line into view when the diff view is opened. Previously
1147
+ // opentui's `focused` scrollbox did this auto-scroll on mount; with the keymap
1148
+ // migration the scrollbox is `focusable={false}` so we have to scroll explicitly.
1149
+ useEffect(() => {
1150
+ if (!diffFullView) return
1151
+ if (!selectedDiffCommentAnchor) return
1152
+ ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
1153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1154
+ }, [diffFullView])
1072
1155
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
1073
1156
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
1074
1157
  const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
1075
- const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1158
+ const loadingIndicator = SPINNER_FRAMES[loadingFrame % SPINNER_FRAMES.length]!
1076
1159
 
1077
1160
  useEffect(() => {
1078
1161
  if (!hasActiveLoadingIndicator) return
1079
1162
  const interval = globalThis.setInterval(() => {
1080
- setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1163
+ setLoadingFrame((current) => current + 1)
1081
1164
  }, 120)
1082
1165
  return () => globalThis.clearInterval(interval)
1083
1166
  }, [hasActiveLoadingIndicator])
1084
1167
 
1168
+ useEffect(() => {
1169
+ if (isInitialLoading) setLoadingFrame(0)
1170
+ }, [isInitialLoading])
1171
+
1172
+ useEffect(() => {
1173
+ if (startupLoadComplete || pullRequestStatus === "loading") return
1174
+ setStartupLoadComplete(true)
1175
+ }, [startupLoadComplete, pullRequestStatus])
1176
+
1085
1177
  useEffect(() => {
1086
1178
  if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1087
1179
  hydratePullRequestDetails(selectedPullRequest, true)
1088
1180
  }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.state, selectedPullRequest?.detailLoaded, selectedPullRequest?.repository, selectedPullRequest?.number])
1089
1181
 
1182
+ useEffect(() => {
1183
+ if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1184
+ loadPullRequestConversation(selectedPullRequest)
1185
+ }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.repository, selectedPullRequest?.number])
1186
+
1090
1187
  useEffect(() => {
1091
1188
  if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1092
1189
  if (pullRequestStatus !== "ready" || visiblePullRequests.length === 0) return
@@ -1120,8 +1217,6 @@ export const App = () => {
1120
1217
  title: `${loadingIndicator} Loading pull request details`,
1121
1218
  hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1122
1219
  } : detailPlaceholderContent
1123
- const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
1124
-
1125
1220
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1126
1221
 
1127
1222
  const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
@@ -1217,6 +1312,7 @@ export const App = () => {
1217
1312
  setDiffFileIndex(0)
1218
1313
  setDiffScrollTop(0)
1219
1314
  setDiffCommentAnchorIndex(0)
1315
+ setDiffPreferredSide(null)
1220
1316
  setDiffCommentRangeStartIndex(null)
1221
1317
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1222
1318
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
@@ -1238,12 +1334,27 @@ export const App = () => {
1238
1334
  const scrollTop = diffScrollRef.current?.scrollTop
1239
1335
  if (scrollTop === undefined || stackedDiffFiles.length === 0) return
1240
1336
  setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
1241
- const nextIndex = stackedDiffFileAtLine(stackedDiffFiles, scrollTop)?.index ?? 0
1337
+ const nextIndex = Math.max(0, stackedDiffFileIndexAtLine(stackedDiffFiles, scrollTop))
1242
1338
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1243
1339
  }
1244
1340
 
1245
- const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1246
- const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1341
+ const syncDetailPreviewScrollState = useCallback(() => {
1342
+ const scrollTop = detailPreviewScrollRef.current?.scrollTop
1343
+ if (scrollTop === undefined) return
1344
+ const nextTop = Math.max(0, Math.floor(scrollTop))
1345
+ if (detailPreviewScrollTopRef.current === nextTop) return
1346
+ detailPreviewScrollTopRef.current = nextTop
1347
+ setDetailPreviewScrollTop(nextTop)
1348
+ }, [])
1349
+
1350
+ const scrollDetailPreviewBy = (y: number) => {
1351
+ detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1352
+ syncDetailPreviewScrollState()
1353
+ }
1354
+ const scrollDetailPreviewTo = (y: number) => {
1355
+ detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1356
+ syncDetailPreviewScrollState()
1357
+ }
1247
1358
 
1248
1359
  const ensureDiffLineVisible = (line: number) => {
1249
1360
  const scroll = diffScrollRef.current
@@ -1262,12 +1373,23 @@ export const App = () => {
1262
1373
  return () => globalThis.clearInterval(interval)
1263
1374
  }, [diffFullView, stackedDiffFiles])
1264
1375
 
1376
+ useLayoutEffect(() => {
1377
+ if (!isWideLayout || detailFullView || diffFullView) return
1378
+ const scroll = detailPreviewScrollRef.current
1379
+ if (!scroll) return
1380
+ const sync = () => { syncDetailPreviewScrollState() }
1381
+ scroll.verticalScrollBar.on("change", sync)
1382
+ syncDetailPreviewScrollState()
1383
+ return () => { scroll.verticalScrollBar.off("change", sync) }
1384
+ }, [isWideLayout, detailFullView, diffFullView, syncDetailPreviewScrollState])
1385
+
1265
1386
  const jumpDiffFile = (delta: 1 | -1) => {
1266
1387
  if (readyDiffFiles.length === 0) return
1267
1388
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1268
1389
  setDiffFileIndex(nextIndex)
1269
1390
  setDiffCommentRangeStartIndex(null)
1270
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
1391
+ const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
1392
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === targetSide)
1271
1393
  ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1272
1394
  if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1273
1395
  scrollToDiffFile(nextIndex)
@@ -1280,13 +1402,8 @@ export const App = () => {
1280
1402
  const moveDiffCommentAnchor = (delta: number, options: { readonly preserveViewportRow?: boolean } = {}) => {
1281
1403
  const anchors = navigableDiffCommentAnchors()
1282
1404
  if (anchors.length === 0) return
1283
- const rows = [...new Set(anchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right)
1284
1405
  const currentAnchor = selectedDiffCommentAnchor && anchors.includes(selectedDiffCommentAnchor) ? selectedDiffCommentAnchor : anchors[0]
1285
- const currentRowIndex = Math.max(0, currentAnchor ? rows.indexOf(currentAnchor.renderLine) : 0)
1286
- const nextRow = rows[Math.max(0, Math.min(rows.length - 1, currentRowIndex + delta))]
1287
- if (nextRow === undefined) return
1288
- const nextAnchor = anchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1289
- ?? anchors.find((anchor) => anchor.renderLine === nextRow)
1406
+ const nextAnchor = verticalDiffAnchor(anchors, currentAnchor ?? null, delta, diffPreferredSide)
1290
1407
  if (!nextAnchor) return
1291
1408
  if (options.preserveViewportRow) {
1292
1409
  const scroll = diffScrollRef.current
@@ -1328,8 +1445,9 @@ export const App = () => {
1328
1445
  }
1329
1446
 
1330
1447
  const selectDiffCommentSide = (side: DiffCommentSide) => {
1448
+ setDiffPreferredSide(side)
1331
1449
  if (!selectedDiffCommentAnchor) return
1332
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1450
+ const nextAnchor = diffAnchorOnSide(diffCommentAnchors, selectedDiffCommentAnchor, side)
1333
1451
  if (!nextAnchor) return
1334
1452
  setDiffCommentRangeStartIndex(null)
1335
1453
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
@@ -1340,6 +1458,7 @@ export const App = () => {
1340
1458
  const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1341
1459
  if (!nextAnchor) return
1342
1460
  suppressNextDiffCommentScrollRef.current = true
1461
+ setDiffPreferredSide(side ?? nextAnchor.side)
1343
1462
  if (diffCommentRangeStartAnchor && !sameDiffCommentTarget(diffCommentRangeStartAnchor, nextAnchor)) {
1344
1463
  setDiffCommentRangeStartIndex(null)
1345
1464
  }
@@ -1516,16 +1635,7 @@ export const App = () => {
1516
1635
  setCloseModal((current) => ({ ...current, running: true, error: null }))
1517
1636
  void closePullRequest({ repository, number })
1518
1637
  .then(() => {
1519
- if (previousPullRequest) {
1520
- setRecentlyCompletedPullRequests((current) => ({
1521
- ...current,
1522
- [previousPullRequest.url]: {
1523
- ...previousPullRequest,
1524
- state: "closed",
1525
- autoMergeEnabled: false,
1526
- },
1527
- }))
1528
- }
1638
+ if (previousPullRequest) markPullRequestCompleted(previousPullRequest, "closed")
1529
1639
  closeActiveModal()
1530
1640
  refreshPullRequests(`Closed #${number}`)
1531
1641
  })
@@ -1560,6 +1670,12 @@ export const App = () => {
1560
1670
  setThemeId(id)
1561
1671
  }
1562
1672
 
1673
+ const toggleDiffWhitespaceMode = () => {
1674
+ const next = diffWhitespaceMode === "ignore" ? "show" : "ignore"
1675
+ setDiffWhitespaceMode(next)
1676
+ void Effect.runPromise(saveStoredDiffWhitespaceMode(next)).catch((error) => flashNotice(errorMessage(error)))
1677
+ }
1678
+
1563
1679
  const moveThemeSelection = (delta: number) => {
1564
1680
  const filteredThemes = filterThemeDefinitions(themeModalRef.current.query)
1565
1681
  if (filteredThemes.length === 0) return
@@ -1655,30 +1771,15 @@ export const App = () => {
1655
1771
  const { repository, number } = mergeModal.info
1656
1772
  const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number)
1657
1773
  const previousPullRequest = targetPullRequest ?? null
1658
- const previousMergeInfo = mergeModal.info
1659
1774
 
1660
1775
  if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
1661
1776
  updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
1662
- setMergeModal((current) => ({
1663
- ...current,
1664
- info: current.info ? { ...current.info, autoMergeEnabled: option.optimisticAutoMergeEnabled! } : current.info,
1665
- }))
1666
1777
  }
1778
+ if (targetPullRequest && option.optimisticState === "merged") markPullRequestCompleted(targetPullRequest, "merged")
1667
1779
 
1668
- setMergeModal((current) => ({ ...current, running: true, error: null }))
1780
+ closeActiveModal()
1669
1781
  void mergePullRequest({ repository, number, action: option.action })
1670
1782
  .then(() => {
1671
- if (option.refreshOnSuccess && previousPullRequest) {
1672
- setRecentlyCompletedPullRequests((current) => ({
1673
- ...current,
1674
- [previousPullRequest.url]: {
1675
- ...previousPullRequest,
1676
- state: "merged",
1677
- autoMergeEnabled: false,
1678
- },
1679
- }))
1680
- }
1681
- closeActiveModal()
1682
1783
  if (option.refreshOnSuccess) {
1683
1784
  refreshPullRequests(`${option.pastTense} #${number}`)
1684
1785
  } else {
@@ -1686,8 +1787,7 @@ export const App = () => {
1686
1787
  }
1687
1788
  })
1688
1789
  .catch((error) => {
1689
- if (previousPullRequest) updatePullRequest(previousPullRequest.url, () => previousPullRequest)
1690
- setMergeModal((current) => ({ ...current, running: false, info: previousMergeInfo, error: errorMessage(error) }))
1790
+ if (previousPullRequest) restoreOptimisticPullRequest(previousPullRequest)
1691
1791
  flashNotice(errorMessage(error))
1692
1792
  })
1693
1793
  }
@@ -1801,6 +1901,7 @@ export const App = () => {
1801
1901
  diffReady: selectedDiffState?._tag === "Ready",
1802
1902
  effectiveDiffRenderView,
1803
1903
  diffWrapMode,
1904
+ diffWhitespaceMode,
1804
1905
  readyDiffFileCount: readyDiffFiles.length,
1805
1906
  diffFileIndex,
1806
1907
  diffRangeActive: diffCommentRangeActive,
@@ -1843,6 +1944,7 @@ export const App = () => {
1843
1944
  },
1844
1945
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1845
1946
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1947
+ toggleDiffWhitespaceMode,
1846
1948
  jumpDiffFile,
1847
1949
  openSelectedDiffComment,
1848
1950
  toggleDiffCommentRange,
@@ -1874,7 +1976,6 @@ export const App = () => {
1874
1976
  }
1875
1977
  const runCommandByIdRef = useRef(runCommandById)
1876
1978
  runCommandByIdRef.current = runCommandById
1877
- useAppCommandRegistry(appCommands, runCommandByIdRef)
1878
1979
  const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1879
1980
  if (!commandPaletteActive) return []
1880
1981
  const repository = parseRepositoryInput(commandPalette.query)
@@ -1901,217 +2002,30 @@ export const App = () => {
1901
2002
  const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1902
2003
  const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1903
2004
 
1904
- const noModalActive = activeModal._tag === "None"
1905
- const globalLayerActive = noModalActive
1906
- && !diffFullView
1907
- && !detailFullView
1908
- && !filterMode
1909
- useScopedBindings({
1910
- when: true,
1911
- bindings: {
1912
- "ctrl+p": "command.open",
1913
- "meta+k": "command.open",
1914
- },
1915
- })
1916
-
1917
- useScopedBindings({
1918
- when: closeModalActive,
1919
- bindings: {
1920
- escape: closeActiveModal,
1921
- return: confirmClosePullRequest,
1922
- },
1923
- })
1924
-
2005
+ // === Helpers used by the keymap layers ===
1925
2006
  const moveMergeSelection = (delta: -1 | 1) => setMergeModal((current) => {
1926
2007
  const max = Math.max(0, availableMergeActions(mergeModal.info).length - 1)
1927
2008
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1928
2009
  })
1929
- useScopedBindings({
1930
- when: mergeModalActive,
1931
- bindings: {
1932
- escape: closeActiveModal,
1933
- return: () => {
1934
- if (availableMergeActions(mergeModal.info).length > 0) confirmMergeAction()
1935
- },
1936
- up: () => moveMergeSelection(-1),
1937
- k: () => moveMergeSelection(-1),
1938
- down: () => moveMergeSelection(1),
1939
- j: () => moveMergeSelection(1),
1940
- },
1941
- })
1942
-
1943
2010
  const scrollCommentThread = (delta: number) => setCommentThreadModal((current) => ({
1944
2011
  ...current,
1945
2012
  scrollOffset: Math.max(0, current.scrollOffset + delta),
1946
2013
  }))
1947
- useScopedBindings({
1948
- when: commentThreadModalActive,
1949
- bindings: {
1950
- escape: closeActiveModal,
1951
- return: openDiffCommentModal,
1952
- a: openDiffCommentModal,
1953
- c: openDiffCommentModal,
1954
- up: () => scrollCommentThread(-1),
1955
- k: () => scrollCommentThread(-1),
1956
- down: () => scrollCommentThread(1),
1957
- j: () => scrollCommentThread(1),
1958
- pageup: () => scrollCommentThread(-halfPage),
1959
- "ctrl+u": () => scrollCommentThread(-halfPage),
1960
- pagedown: () => scrollCommentThread(halfPage),
1961
- "ctrl+d": () => scrollCommentThread(halfPage),
1962
- "ctrl+v": () => scrollCommentThread(halfPage),
1963
- },
1964
- })
1965
-
1966
2014
  const moveLabelSelection = (delta: -1 | 1) => setLabelModal((current) => {
1967
2015
  const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
1968
2016
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1969
2017
  })
1970
- useScopedBindings({
1971
- when: labelModalActive,
1972
- bindings: {
1973
- escape: closeActiveModal,
1974
- return: toggleLabelAtIndex,
1975
- up: () => moveLabelSelection(-1),
1976
- k: () => moveLabelSelection(-1),
1977
- down: () => moveLabelSelection(1),
1978
- j: () => moveLabelSelection(1),
1979
- },
1980
- })
1981
-
1982
- useScopedBindings({
1983
- when: themeModalActive,
1984
- bindings: {
1985
- escape: () => {
1986
- if (themeModal.filterMode) updateThemeQuery("", { filterMode: false })
1987
- else closeThemeModal(false)
1988
- },
1989
- "/": () => updateThemeQuery("", { filterMode: true }),
1990
- return: () => {
1991
- if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
1992
- closeThemeModal(true)
1993
- },
1994
- up: () => moveThemeSelection(-1),
1995
- down: () => moveThemeSelection(1),
1996
- k: () => { if (!themeModal.filterMode) moveThemeSelection(-1) },
1997
- j: () => { if (!themeModal.filterMode) moveThemeSelection(1) },
1998
- },
1999
- })
2000
-
2001
- useScopedBindings({
2002
- when: openRepositoryModalActive,
2003
- bindings: {
2004
- escape: closeActiveModal,
2005
- return: openRepositoryFromInput,
2006
- },
2007
- })
2008
-
2009
- useScopedBindings({
2010
- when: commentModalActive,
2011
- bindings: {
2012
- escape: closeActiveModal,
2013
- "ctrl+s": submitDiffComment,
2014
- "ctrl+a": () => editComment(moveLineStart),
2015
- "ctrl+e": () => editComment(moveLineEnd),
2016
- "ctrl+b": () => editComment(editorMoveLeft),
2017
- "ctrl+f": () => editComment(editorMoveRight),
2018
- "ctrl+w": () => editComment(deleteWordBackward),
2019
- "ctrl+u": () => editComment(deleteToLineStart),
2020
- "ctrl+k": () => editComment(deleteToLineEnd),
2021
- "ctrl+d": () => editComment(editorDeleteForward),
2022
- "meta+b": () => editComment(moveWordBackward),
2023
- "meta+left": () => editComment(moveWordBackward),
2024
- "meta+f": () => editComment(moveWordForward),
2025
- "meta+right": () => editComment(moveWordForward),
2026
- "meta+backspace": () => editComment(deleteWordBackward),
2027
- "meta+delete": () => editComment(deleteWordForward),
2028
- backspace: () => editComment(editorBackspace),
2029
- delete: () => editComment(editorDeleteForward),
2030
- left: () => editComment(editorMoveLeft),
2031
- right: () => editComment(editorMoveRight),
2032
- up: () => editComment((state) => moveVertically(state, -1)),
2033
- down: () => editComment((state) => moveVertically(state, 1)),
2034
- home: () => editComment(moveLineStart),
2035
- end: () => editComment(moveLineEnd),
2036
- "shift+return": () => editComment((state) => insertText(state, "\n")),
2037
- return: submitDiffComment,
2038
- },
2039
- })
2040
-
2041
2018
  const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
2042
2019
  const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
2043
2020
  return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2044
2021
  })
2045
- useScopedBindings({
2046
- when: commandPaletteActive,
2047
- bindings: {
2048
- escape: closeActiveModal,
2049
- "ctrl+c": closeActiveModal,
2050
- return: () => { if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true }) },
2051
- up: () => moveCommandPaletteSelection(-1),
2052
- down: () => moveCommandPaletteSelection(1),
2053
- },
2054
- })
2055
-
2056
- useScopedBindings({
2057
- when: filterMode,
2058
- bindings: {
2059
- escape: () => { setFilterDraft(filterQuery); setFilterMode(false) },
2060
- return: () => { setFilterQuery(filterDraft); setFilterMode(false) },
2061
- },
2062
- })
2063
-
2064
- useScopedBindings({
2065
- when: diffFullView && noModalActive,
2066
- bindings: {
2067
- escape: () => {
2068
- if (diffCommentRangeActive) setDiffCommentRangeStartIndex(null)
2069
- else runCommandById("diff.close")
2070
- },
2071
- return: openSelectedDiffComment,
2072
- a: "diff.add-comment",
2073
- v: "diff.toggle-range",
2074
- "shift+v": "diff.toggle-view",
2075
- w: "diff.toggle-wrap",
2076
- r: "diff.reload",
2077
- n: "diff.next-thread",
2078
- p: "diff.previous-thread",
2079
- pageup: () => moveDiffCommentAnchor(-halfPage, { preserveViewportRow: true }),
2080
- "ctrl+u": () => moveDiffCommentAnchor(-halfPage, { preserveViewportRow: true }),
2081
- pagedown: () => moveDiffCommentAnchor(halfPage, { preserveViewportRow: true }),
2082
- "ctrl+d": () => moveDiffCommentAnchor(halfPage, { preserveViewportRow: true }),
2083
- "ctrl+v": () => moveDiffCommentAnchor(halfPage, { preserveViewportRow: true }),
2084
- "shift+up": () => moveDiffCommentAnchor(-8),
2085
- "shift+k": () => moveDiffCommentAnchor(-8),
2086
- "meta+up": () => moveDiffCommentAnchor(-8),
2087
- "meta+k": () => moveDiffCommentAnchor(-8),
2088
- "shift+down": () => moveDiffCommentAnchor(8),
2089
- "shift+j": () => moveDiffCommentAnchor(8),
2090
- "meta+down": () => moveDiffCommentAnchor(8),
2091
- "meta+j": () => moveDiffCommentAnchor(8),
2092
- ...countedVerticalBindings(
2093
- (count) => moveDiffCommentAnchor(-count),
2094
- (count) => moveDiffCommentAnchor(count),
2095
- ),
2096
- up: () => moveDiffCommentAnchor(-1),
2097
- k: () => moveDiffCommentAnchor(-1),
2098
- down: () => moveDiffCommentAnchor(1),
2099
- j: () => moveDiffCommentAnchor(1),
2100
- left: () => selectDiffCommentSide("LEFT"),
2101
- h: () => selectDiffCommentSide("LEFT"),
2102
- right: () => selectDiffCommentSide("RIGHT"),
2103
- l: () => selectDiffCommentSide("RIGHT"),
2104
- "]": "diff.next-file",
2105
- "[": "diff.previous-file",
2106
- "g g": () => moveDiffCommentToBoundary("first"),
2107
- "shift+g": () => moveDiffCommentToBoundary("last"),
2108
- "z z": () => alignSelectedDiffCommentAnchor("center"),
2109
- "z t": () => alignSelectedDiffCommentAnchor("top"),
2110
- "z b": () => alignSelectedDiffCommentAnchor("bottom"),
2111
- o: "pull.open-browser",
2112
- },
2022
+ const selectCommandPaletteIndex = (index: number) => setCommandPalette((current) => {
2023
+ const selectedIndex = clampCommandIndex(index, commandPaletteCommands)
2024
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2113
2025
  })
2114
-
2026
+ const runCommandPaletteCommand = (command: AppCommand) => {
2027
+ runCommand(command, { notifyDisabled: true, closePalette: true })
2028
+ }
2115
2029
  const scrollDetailFullViewBy = (delta: number) => {
2116
2030
  detailScrollRef.current?.scrollBy({ x: 0, y: delta })
2117
2031
  setDetailScrollOffset((current) => Math.max(0, current + delta))
@@ -2120,26 +2034,6 @@ export const App = () => {
2120
2034
  detailScrollRef.current?.scrollTo({ x: 0, y })
2121
2035
  setDetailScrollOffset(y)
2122
2036
  }
2123
- useScopedBindings({
2124
- when: detailFullView && noModalActive,
2125
- bindings: {
2126
- ...scrollBindings(scrollDetailFullViewBy, halfPage, scrollDetailFullViewTo),
2127
- escape: "detail.close",
2128
- return: "detail.close",
2129
- t: "theme.open",
2130
- d: "diff.open",
2131
- x: "pull.close",
2132
- l: "pull.labels",
2133
- m: "pull.merge",
2134
- "shift+m": "pull.merge",
2135
- s: "pull.toggle-draft",
2136
- "shift+s": "pull.toggle-draft",
2137
- r: "pull.refresh",
2138
- o: "pull.open-browser",
2139
- y: "pull.copy-metadata",
2140
- },
2141
- })
2142
-
2143
2037
  const moveSelectedToPreviousGroup = () => setSelectedIndex((current) => {
2144
2038
  if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2145
2039
  const currentGroup = getCurrentGroupIndex(current)
@@ -2178,48 +2072,168 @@ export const App = () => {
2178
2072
  if (visiblePullRequests.length === 0) return 0
2179
2073
  return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2180
2074
  })
2181
- useScopedBindings({
2182
- when: globalLayerActive,
2183
- bindings: {
2184
- "/": "filter.open",
2185
- r: "pull.refresh",
2186
- t: "theme.open",
2187
- d: "diff.open",
2188
- l: "pull.labels",
2189
- m: "pull.merge",
2190
- "shift+m": "pull.merge",
2191
- x: "pull.close",
2192
- o: "pull.open-browser",
2193
- s: "pull.toggle-draft",
2194
- "shift+s": "pull.toggle-draft",
2195
- y: "pull.copy-metadata",
2196
- return: "detail.open",
2197
- tab: () => switchQueueMode(1),
2198
- "shift+tab": () => switchQueueMode(-1),
2199
- escape: () => { if (filterQuery.length > 0) runCommandByIdRef.current("filter.clear") },
2200
- home: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewTo(0) },
2201
- end: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER) },
2202
- pageup: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewBy(-halfPage) },
2203
- pagedown: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewBy(halfPage) },
2204
- "[": moveSelectedToPreviousGroup,
2205
- "meta+up": moveSelectedToPreviousGroup,
2206
- "meta+k": moveSelectedToPreviousGroup,
2207
- "shift+k": moveSelectedToPreviousGroup,
2208
- "]": moveSelectedToNextGroup,
2209
- "meta+down": moveSelectedToNextGroup,
2210
- "meta+j": moveSelectedToNextGroup,
2211
- "shift+j": moveSelectedToNextGroup,
2212
- "ctrl+u": () => stepSelected(-halfPage),
2213
- "ctrl+d": () => stepSelected(halfPage),
2214
- ...countedVerticalBindings(stepSelectedUp, stepSelectedDown),
2215
- up: stepSelectedUpWrap,
2216
- k: stepSelectedUpWrap,
2217
- down: stepSelectedDownWithLoadMore,
2218
- j: stepSelectedDownWithLoadMore,
2219
- "g g": () => setSelectedIndex(0),
2220
- "shift+g": () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2075
+ const handleQuitOrClose = () => {
2076
+ if (themeModalActive) {
2077
+ closeThemeModal(false)
2078
+ return
2079
+ }
2080
+ if (activeModal._tag !== "None") {
2081
+ closeActiveModal()
2082
+ return
2083
+ }
2084
+ runCommandById("app.quit")
2085
+ }
2086
+
2087
+ // === Build the keymap context ===
2088
+ const appCtx: AppCtx = {
2089
+ closeModalActive,
2090
+ mergeModalActive,
2091
+ commentThreadModalActive,
2092
+ labelModalActive,
2093
+ themeModalActive,
2094
+ openRepositoryModalActive,
2095
+ commentModalActive,
2096
+ commandPaletteActive,
2097
+ filterMode,
2098
+ diffFullView,
2099
+ detailFullView,
2100
+ textInputActive: commentModalActive
2101
+ || commandPaletteActive
2102
+ || openRepositoryModalActive
2103
+ || labelModalActive
2104
+ || filterMode
2105
+ || (themeModalActive && themeModal.filterMode),
2106
+ closeModal: {
2107
+ closeModal: closeActiveModal,
2108
+ confirmClose: confirmClosePullRequest,
2221
2109
  },
2222
- })
2110
+ mergeModal: {
2111
+ availableActionCount: availableMergeActions(mergeModal.info).length,
2112
+ closeModal: closeActiveModal,
2113
+ confirmMerge: confirmMergeAction,
2114
+ moveSelection: moveMergeSelection,
2115
+ },
2116
+ commentThreadModal: {
2117
+ halfPage,
2118
+ closeModal: closeActiveModal,
2119
+ openInlineComment: openDiffCommentModal,
2120
+ scrollBy: scrollCommentThread,
2121
+ },
2122
+ labelModal: {
2123
+ closeModal: closeActiveModal,
2124
+ toggleSelected: toggleLabelAtIndex,
2125
+ moveSelection: moveLabelSelection,
2126
+ },
2127
+ themeModal: {
2128
+ filterMode: themeModal.filterMode,
2129
+ hasFilteredResults: filterThemeDefinitions(themeModal.query).length > 0,
2130
+ closeWithoutSaving: () => closeThemeModal(false),
2131
+ clearFilter: () => updateThemeQuery("", { filterMode: false }),
2132
+ enterFilterMode: () => updateThemeQuery("", { filterMode: true }),
2133
+ confirmSelection: () => closeThemeModal(true),
2134
+ moveSelection: moveThemeSelection,
2135
+ },
2136
+ openRepositoryModal: {
2137
+ closeModal: closeActiveModal,
2138
+ openFromInput: openRepositoryFromInput,
2139
+ },
2140
+ commentModal: {
2141
+ closeModal: closeActiveModal,
2142
+ submit: submitDiffComment,
2143
+ insertNewline: () => editComment((state) => insertText(state, "\n")),
2144
+ moveLeft: () => editComment(editorMoveLeft),
2145
+ moveRight: () => editComment(editorMoveRight),
2146
+ moveUp: () => editComment((state) => moveVertically(state, -1)),
2147
+ moveDown: () => editComment((state) => moveVertically(state, 1)),
2148
+ moveLineStart: () => editComment(moveLineStart),
2149
+ moveLineEnd: () => editComment(moveLineEnd),
2150
+ moveWordBackward: () => editComment(moveWordBackward),
2151
+ moveWordForward: () => editComment(moveWordForward),
2152
+ backspace: () => editComment(editorBackspace),
2153
+ deleteForward: () => editComment(editorDeleteForward),
2154
+ deleteWordBackward: () => editComment(deleteWordBackward),
2155
+ deleteWordForward: () => editComment(deleteWordForward),
2156
+ deleteToLineStart: () => editComment(deleteToLineStart),
2157
+ deleteToLineEnd: () => editComment(deleteToLineEnd),
2158
+ },
2159
+ commandPalette: {
2160
+ closeModal: closeActiveModal,
2161
+ runSelected: () => {
2162
+ if (selectedCommand) runCommandPaletteCommand(selectedCommand)
2163
+ },
2164
+ moveSelection: moveCommandPaletteSelection,
2165
+ },
2166
+ filterModeCtx: {
2167
+ cancel: () => {
2168
+ setFilterDraft(filterQuery)
2169
+ setFilterMode(false)
2170
+ },
2171
+ commit: () => {
2172
+ setFilterQuery(filterDraft)
2173
+ setFilterMode(false)
2174
+ },
2175
+ },
2176
+ diff: {
2177
+ halfPage,
2178
+ handleEscape: () => {
2179
+ if (diffCommentRangeActive) setDiffCommentRangeStartIndex(null)
2180
+ else runCommandById("diff.close")
2181
+ },
2182
+ openSelectedComment: openSelectedDiffComment,
2183
+ addComment: () => runCommandById("diff.add-comment"),
2184
+ toggleRange: () => runCommandById("diff.toggle-range"),
2185
+ toggleView: () => runCommandById("diff.toggle-view"),
2186
+ toggleWrap: () => runCommandById("diff.toggle-wrap"),
2187
+ reload: () => runCommandById("diff.reload"),
2188
+ nextThread: () => runCommandById("diff.next-thread"),
2189
+ previousThread: () => runCommandById("diff.previous-thread"),
2190
+ moveAnchor: moveDiffCommentAnchor,
2191
+ moveAnchorToBoundary: moveDiffCommentToBoundary,
2192
+ alignAnchor: alignSelectedDiffCommentAnchor,
2193
+ selectSide: selectDiffCommentSide,
2194
+ nextFile: () => runCommandById("diff.next-file"),
2195
+ previousFile: () => runCommandById("diff.previous-file"),
2196
+ openInBrowser: () => runCommandById("pull.open-browser"),
2197
+ },
2198
+ detail: {
2199
+ halfPage,
2200
+ scrollBy: scrollDetailFullViewBy,
2201
+ scrollTo: scrollDetailFullViewTo,
2202
+ closeDetail: () => runCommandById("detail.close"),
2203
+ openTheme: () => runCommandById("theme.open"),
2204
+ openDiff: () => runCommandById("diff.open"),
2205
+ closePullRequest: () => runCommandById("pull.close"),
2206
+ openLabels: () => runCommandById("pull.labels"),
2207
+ openMerge: () => runCommandById("pull.merge"),
2208
+ toggleDraft: () => runCommandById("pull.toggle-draft"),
2209
+ refresh: () => runCommandById("pull.refresh"),
2210
+ openInBrowser: () => runCommandById("pull.open-browser"),
2211
+ copyMetadata: () => runCommandById("pull.copy-metadata"),
2212
+ },
2213
+ listNav: {
2214
+ halfPage,
2215
+ visibleCount: visiblePullRequests.length,
2216
+ hasFilter: filterQuery.length > 0,
2217
+ canScrollDetailPreview: isWideLayout && selectedPullRequest !== null,
2218
+ runCommandById: (id) => { runCommandById(id) },
2219
+ switchQueueMode,
2220
+ scrollDetailPreviewBy,
2221
+ scrollDetailPreviewTo,
2222
+ clearFilter: () => { runCommandById("filter.clear") },
2223
+ stepSelected,
2224
+ stepSelectedUp,
2225
+ stepSelectedDown,
2226
+ stepSelectedUpWrap,
2227
+ stepSelectedDownWithLoadMore,
2228
+ moveSelectedToPreviousGroup,
2229
+ moveSelectedToNextGroup,
2230
+ setSelected: (index) => setSelectedIndex(index),
2231
+ },
2232
+ openCommandPalette: () => { runCommandById("command.open") },
2233
+ handleQuitOrClose,
2234
+ }
2235
+
2236
+ useKeymap(appKeymap, appCtx, useOpenTuiSubscribe())
2223
2237
 
2224
2238
  useKeyboard((key) => {
2225
2239
  if (commandPaletteActive) {
@@ -2243,18 +2257,9 @@ export const App = () => {
2243
2257
  return
2244
2258
  }
2245
2259
 
2246
- if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
2247
- if (themeModalActive) {
2248
- closeThemeModal(false)
2249
- return
2250
- }
2251
- if (activeModal._tag !== "None") {
2252
- closeActiveModal()
2253
- return
2254
- }
2255
- runCommandById("app.quit")
2256
- return
2257
- }
2260
+ // q / ctrl+c quit/close-modal logic now lives in the keymap layer
2261
+ // (handleQuitOrClose). This useKeyboard callback only handles raw text
2262
+ // input for modals that need character-by-character accumulation.
2258
2263
 
2259
2264
  if (themeModalActive) {
2260
2265
  if (themeModal.filterMode && isSingleLineInputKey(key)) {
@@ -2290,16 +2295,34 @@ export const App = () => {
2290
2295
  }
2291
2296
  })
2292
2297
 
2298
+ if (isInitialLoading) {
2299
+ return (
2300
+ <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
2301
+ <LoadingLogoPane content={detailPlaceholderContent} width={contentWidth} height={terminalHeight} frame={loadingFrame} />
2302
+ </box>
2303
+ )
2304
+ }
2305
+
2293
2306
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
2294
2307
  const fullscreenBodyLines = Math.max(8, terminalHeight - 8)
2295
2308
  const fullscreenDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout)
2296
2309
  const fullscreenDetailBodyViewportHeight = Math.max(1, wideBodyHeight - fullscreenDetailHeaderHeight)
2297
- const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth)
2310
+ const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth, selectedConversationItems, selectedConversationStatus)
2298
2311
  const fullscreenDetailBodyScrollable = fullscreenDetailBodyHeight > fullscreenDetailBodyViewportHeight
2299
2312
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2300
2313
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2301
- const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
2314
+ const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth, selectedConversationItems, selectedConversationStatus)
2302
2315
  const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2316
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows({
2317
+ pullRequest: selectedPullRequest,
2318
+ paneWidth: rightPaneWidth,
2319
+ showChecks: true,
2320
+ contentWidth: rightContentWidth,
2321
+ conversationItems: selectedConversationItems,
2322
+ conversationStatus: selectedConversationStatus,
2323
+ bodyScrollTop: detailPreviewScrollTop,
2324
+ bodyViewportHeight: wideDetailBodyViewportHeight,
2325
+ })
2303
2326
 
2304
2327
  const prListProps = {
2305
2328
  groups: visibleGroups,
@@ -2370,20 +2393,19 @@ export const App = () => {
2370
2393
  <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.background}>
2371
2394
  <PlainLine text={headerLine} fg={colors.muted} bold />
2372
2395
  </box>
2373
- {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
2396
+ {isWideLayout && !detailFullView && !diffFullView ? (
2374
2397
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
2375
2398
  ) : (
2376
2399
  <Divider width={contentWidth} />
2377
2400
  )}
2378
- {isInitialLoading ? (
2379
- <LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
2380
- ) : diffFullView ? (
2401
+ {diffFullView ? (
2381
2402
  <PullRequestDiffPane
2382
2403
  pullRequest={selectedPullRequest}
2383
- diffState={selectedDiffState}
2404
+ diffState={displayedDiffState}
2384
2405
  stackedFiles={stackedDiffFiles}
2385
2406
  scrollTop={diffScrollTop}
2386
2407
  view={effectiveDiffRenderView}
2408
+ whitespaceMode={diffWhitespaceMode}
2387
2409
  wrapMode={diffWrapMode}
2388
2410
  paneWidth={contentWidth}
2389
2411
  height={wideBodyHeight}
@@ -2406,8 +2428,8 @@ export const App = () => {
2406
2428
  {selectedPullRequest ? (
2407
2429
  <>
2408
2430
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks />
2409
- <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2410
- <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2431
+ <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2432
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2411
2433
  </scrollbox>
2412
2434
  </>
2413
2435
  ) : (
@@ -2434,7 +2456,7 @@ export const App = () => {
2434
2456
  <>
2435
2457
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2436
2458
  <scrollbox ref={detailPreviewScrollRef} flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2437
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2459
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2438
2460
  </scrollbox>
2439
2461
  </>
2440
2462
  ) : (
@@ -2447,8 +2469,8 @@ export const App = () => {
2447
2469
  {selectedPullRequest ? (
2448
2470
  <>
2449
2471
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} />
2450
- <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2451
- <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2472
+ <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2473
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2452
2474
  </scrollbox>
2453
2475
  </>
2454
2476
  ) : (
@@ -2457,7 +2479,7 @@ export const App = () => {
2457
2479
  </box>
2458
2480
  ) : (
2459
2481
  <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2460
- <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2482
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2461
2483
  <Divider width={contentWidth} />
2462
2484
  <box flexGrow={1} flexDirection="column">
2463
2485
  <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
@@ -2469,7 +2491,7 @@ export const App = () => {
2469
2491
  </box>
2470
2492
  )}
2471
2493
 
2472
- {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
2494
+ {isWideLayout && !detailFullView && !diffFullView ? (
2473
2495
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
2474
2496
  ) : (
2475
2497
  <Divider width={contentWidth} />
@@ -2573,6 +2595,8 @@ export const App = () => {
2573
2595
  modalHeight={commandPaletteHeight}
2574
2596
  offsetLeft={commandPaletteLeft}
2575
2597
  offsetTop={commandPaletteTop}
2598
+ onSelectCommandIndex={selectCommandPaletteIndex}
2599
+ onRunCommand={runCommandPaletteCommand}
2576
2600
  />
2577
2601
  ) : null}
2578
2602
  </box>