@kitlangton/ghui 0.1.21 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  import type { AppCommand } from "./commands.js"
2
2
  import { defineCommand } from "./commands.js"
3
3
  import type { LoadStatus, PullRequestItem } from "./domain.js"
4
- import type { DiffView, DiffWrapMode } from "./ui/diff.js"
4
+ import type { DiffView, DiffWhitespaceMode, DiffWrapMode } from "./ui/diff.js"
5
5
  import { type PullRequestView, viewEquals, viewLabel, viewMode } from "./pullRequestViews.js"
6
6
 
7
7
  interface AppCommandActions {
8
8
  readonly openCommandPalette: () => void
9
- readonly refreshPullRequests: (message?: string) => void
9
+ readonly refreshPullRequests: (message?: string, options?: { readonly resetTransientState?: boolean }) => void
10
10
  readonly openFilter: () => void
11
11
  readonly clearFilter: () => void
12
12
  readonly openThemeModal: () => void
@@ -20,8 +20,11 @@ interface AppCommandActions {
20
20
  readonly reloadDiff: () => void
21
21
  readonly toggleDiffRenderView: () => void
22
22
  readonly toggleDiffWrapMode: () => void
23
+ readonly toggleDiffWhitespaceMode: () => void
23
24
  readonly jumpDiffFile: (delta: 1 | -1) => void
24
- readonly toggleDiffCommentMode: () => void
25
+ readonly openSelectedDiffComment: () => void
26
+ readonly toggleDiffCommentRange: () => void
27
+ readonly moveDiffCommentThread: (delta: 1 | -1) => void
25
28
  readonly openDiffCommentModal: () => void
26
29
  readonly togglePullRequestDraftStatus: () => void
27
30
  readonly openLabelModal: () => void
@@ -48,10 +51,13 @@ interface BuildAppCommandsInput {
48
51
  readonly diffReady: boolean
49
52
  readonly effectiveDiffRenderView: DiffView
50
53
  readonly diffWrapMode: DiffWrapMode
54
+ readonly diffWhitespaceMode: DiffWhitespaceMode
51
55
  readonly readyDiffFileCount: number
52
56
  readonly diffFileIndex: number
53
- readonly diffCommentMode: boolean
57
+ readonly diffRangeActive: boolean
54
58
  readonly selectedDiffCommentAnchorLabel: string | null
59
+ readonly selectedDiffCommentThreadCount: number
60
+ readonly hasDiffCommentThreads: boolean
55
61
  readonly actions: AppCommandActions
56
62
  }
57
63
 
@@ -71,10 +77,13 @@ export const buildAppCommands = ({
71
77
  diffReady,
72
78
  effectiveDiffRenderView,
73
79
  diffWrapMode,
80
+ diffWhitespaceMode,
74
81
  readyDiffFileCount,
75
82
  diffFileIndex,
76
- diffCommentMode,
83
+ diffRangeActive,
77
84
  selectedDiffCommentAnchorLabel,
85
+ selectedDiffCommentThreadCount,
86
+ hasDiffCommentThreads,
78
87
  actions,
79
88
  }: BuildAppCommandsInput): readonly AppCommand[] => {
80
89
  const selectedPullRequestLabel = selectedPullRequest ? `#${selectedPullRequest.number} ${selectedPullRequest.repository}` : "No pull request selected"
@@ -84,6 +93,12 @@ export const buildAppCommands = ({
84
93
  ? diffReady ? null : "Load the diff before running this command."
85
94
  : noPullRequestReason
86
95
  const diffOpenReadyReason = diffFullView ? diffReadyReason : "Open a diff first."
96
+ const selectedDiffLineReason = diffFullView && diffReady
97
+ ? selectedDiffCommentAnchorLabel ? null : "No diff line selected."
98
+ : diffOpenReadyReason
99
+ const diffThreadReason = diffFullView && diffReady
100
+ ? hasDiffCommentThreads ? null : "No diff comments loaded."
101
+ : diffOpenReadyReason
87
102
  const loadMoreDisabledReason = isLoadingMorePullRequests
88
103
  ? "Already loading more pull requests."
89
104
  : hasMorePullRequests ? null : "No more pull requests loaded by this view."
@@ -116,7 +131,7 @@ export const buildAppCommands = ({
116
131
  subtitle: "Fetch the latest queue from GitHub",
117
132
  shortcut: "r",
118
133
  keywords: ["reload", "sync"],
119
- run: () => actions.refreshPullRequests("Refreshed"),
134
+ run: () => actions.refreshPullRequests("Refreshed", { resetTransientState: true }),
120
135
  }),
121
136
  defineCommand({
122
137
  id: "filter.open",
@@ -189,7 +204,7 @@ export const buildAppCommands = ({
189
204
  }),
190
205
  forSelected({
191
206
  id: "diff.open",
192
- title: "Open stacked diff",
207
+ title: "Open diff",
193
208
  scope: "Diff",
194
209
  shortcut: "d",
195
210
  keywords: ["files", "patch"],
@@ -219,7 +234,7 @@ export const buildAppCommands = ({
219
234
  title: "Toggle diff split/unified view",
220
235
  scope: "Diff",
221
236
  subtitle: effectiveDiffRenderView === "split" ? "Switch to unified view" : "Switch to split view",
222
- shortcut: "v",
237
+ shortcut: "shift-v",
223
238
  disabledReason: diffFullView ? null : "Open a diff first.",
224
239
  run: actions.toggleDiffRenderView,
225
240
  }),
@@ -232,6 +247,15 @@ export const buildAppCommands = ({
232
247
  disabledReason: diffFullView ? null : "Open a diff first.",
233
248
  run: actions.toggleDiffWrapMode,
234
249
  }),
250
+ defineCommand({
251
+ id: "diff.toggle-whitespace",
252
+ title: diffWhitespaceMode === "ignore" ? "Show whitespace changes" : "Ignore whitespace changes",
253
+ scope: "Diff",
254
+ subtitle: diffWhitespaceMode === "ignore" ? "Display the original GitHub patch" : "Hide whitespace-only line changes",
255
+ disabledReason: diffFullView ? null : "Open a diff first.",
256
+ keywords: ["whitespace", "spacing", "ignore", "show"],
257
+ run: actions.toggleDiffWhitespaceMode,
258
+ }),
235
259
  defineCommand({
236
260
  id: "diff.next-file",
237
261
  title: "Next diff file",
@@ -251,14 +275,44 @@ export const buildAppCommands = ({
251
275
  run: () => actions.jumpDiffFile(-1),
252
276
  }),
253
277
  defineCommand({
254
- id: "diff.comment-mode",
255
- title: diffCommentMode ? "Exit diff comment mode" : "Enter diff comment mode",
278
+ id: "diff.open-comment-target",
279
+ title: selectedDiffCommentThreadCount > 0 ? "Open selected diff thread" : "Comment on selected diff line",
280
+ scope: "Diff",
281
+ subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
282
+ shortcut: "enter",
283
+ disabledReason: selectedDiffLineReason,
284
+ keywords: ["review", "comment", "thread", "line"],
285
+ run: actions.openSelectedDiffComment,
286
+ }),
287
+ defineCommand({
288
+ id: "diff.toggle-range",
289
+ title: diffRangeActive ? "Clear diff comment range" : "Start diff comment range",
290
+ scope: "Diff",
291
+ subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
292
+ shortcut: "v",
293
+ disabledReason: selectedDiffLineReason,
294
+ keywords: ["review", "comment", "range", "visual"],
295
+ run: actions.toggleDiffCommentRange,
296
+ }),
297
+ defineCommand({
298
+ id: "diff.next-thread",
299
+ title: "Next diff thread",
300
+ scope: "Diff",
301
+ subtitle: hasDiffCommentThreads ? "Jump to the next commented line" : "No diff comments loaded",
302
+ shortcut: "n",
303
+ disabledReason: diffThreadReason,
304
+ keywords: ["review", "comment", "thread"],
305
+ run: () => actions.moveDiffCommentThread(1),
306
+ }),
307
+ defineCommand({
308
+ id: "diff.previous-thread",
309
+ title: "Previous diff thread",
256
310
  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,
311
+ subtitle: hasDiffCommentThreads ? "Jump to the previous commented line" : "No diff comments loaded",
312
+ shortcut: "p",
313
+ disabledReason: diffThreadReason,
314
+ keywords: ["review", "comment", "thread"],
315
+ run: () => actions.moveDiffCommentThread(-1),
262
316
  }),
263
317
  defineCommand({
264
318
  id: "diff.add-comment",
@@ -266,7 +320,7 @@ export const buildAppCommands = ({
266
320
  scope: "Diff",
267
321
  subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
268
322
  shortcut: "a",
269
- disabledReason: diffCommentMode && selectedDiffCommentAnchorLabel ? null : "Enter diff comment mode and select a line first.",
323
+ disabledReason: selectedDiffLineReason,
270
324
  keywords: ["review", "reply"],
271
325
  run: actions.openDiffCommentModal,
272
326
  }),
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
 
@@ -76,6 +78,17 @@ export interface PullRequestReviewComment {
76
78
  readonly url: string | null
77
79
  }
78
80
 
81
+ export type PullRequestConversationItem =
82
+ | {
83
+ readonly _tag: "comment"
84
+ readonly id: string
85
+ readonly author: string
86
+ readonly body: string
87
+ readonly createdAt: Date | null
88
+ readonly url: string | null
89
+ }
90
+ | ({ readonly _tag: "review-comment" } & PullRequestReviewComment)
91
+
79
92
  export interface PullRequestItem {
80
93
  readonly repository: string
81
94
  readonly author: string
package/src/index.tsx CHANGED
@@ -3,8 +3,6 @@
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 { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
7
- import { KeymapProvider } from "@opentui/keymap/react"
8
6
 
9
7
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
10
8
 
@@ -45,12 +43,8 @@ const renderer = await createCliRenderer({
45
43
 
46
44
  process.stdout.write(FOCUS_REPORTING_ENABLE)
47
45
 
48
- const keymap = createDefaultOpenTuiKeymap(renderer)
49
-
50
46
  createRoot(renderer).render(
51
47
  <RegistryProvider>
52
- <KeymapProvider keymap={keymap}>
53
- <App />
54
- </KeymapProvider>
48
+ <App />
55
49
  </RegistryProvider>,
56
50
  )
@@ -0,0 +1,43 @@
1
+ import type { KeyEvent } from "@opentui/core"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import type { ParsedStroke } from "@ghui/keymap"
4
+ import type { KeySubscribe } from "@ghui/keymap/react"
5
+ import { useMemo, useRef } from "react"
6
+
7
+ /**
8
+ * Map an opentui KeyEvent into @ghui/keymap's ParsedStroke.
9
+ * `option` (alt key on Linux/Windows) is folded into `meta` to keep one
10
+ * cross-platform modifier surface.
11
+ */
12
+ export const normalizeOpenTuiKey = (event: KeyEvent): ParsedStroke => ({
13
+ key: event.name.toLowerCase(),
14
+ ctrl: event.ctrl,
15
+ shift: event.shift,
16
+ meta: event.meta || event.option,
17
+ })
18
+
19
+ /**
20
+ * React hook that returns a stable `KeySubscribe` driven by opentui's keyboard.
21
+ *
22
+ * Multiple subscribers (e.g. the `@ghui/keymap` dispatcher and any text-input
23
+ * fallback) can each register their own handler. Internally we attach a single
24
+ * `useKeyboard` and fan out to every registered handler, so we don't accidentally
25
+ * stack two `useKeyboard` listeners on the same component.
26
+ */
27
+ export const useOpenTuiSubscribe = (): KeySubscribe => {
28
+ const handlersRef = useRef<Set<(stroke: ParsedStroke) => void>>(new Set())
29
+
30
+ useKeyboard((event) => {
31
+ const keyEvent = event as KeyEvent
32
+ if (keyEvent.defaultPrevented) return
33
+ const stroke = normalizeOpenTuiKey(keyEvent)
34
+ for (const handler of handlersRef.current) handler(stroke)
35
+ })
36
+
37
+ return useMemo<KeySubscribe>(() => (handler) => {
38
+ handlersRef.current.add(handler)
39
+ return () => {
40
+ handlersRef.current.delete(handler)
41
+ }
42
+ }, [])
43
+ }
@@ -0,0 +1,103 @@
1
+ import { context } from "@ghui/keymap"
2
+ import { closeModalKeymap, type CloseModalCtx } from "./closeModal.ts"
3
+ import { commandPaletteKeymap, type CommandPaletteCtx } from "./commandPalette.ts"
4
+ import { commentModalKeymap, type CommentModalCtx } from "./commentModal.ts"
5
+ import { commentThreadModalKeymap, type CommentThreadModalCtx } from "./commentThreadModal.ts"
6
+ import { detailViewKeymap, type DetailViewCtx } from "./detailView.ts"
7
+ import { diffViewKeymap, type DiffViewCtx } from "./diffView.ts"
8
+ import { filterModeKeymap, type FilterModeCtx } from "./filterMode.ts"
9
+ import { labelModalKeymap, type LabelModalCtx } from "./labelModal.ts"
10
+ import { listNavKeymap, type ListNavCtx } from "./listNav.ts"
11
+ import { mergeModalKeymap, type MergeModalCtx } from "./mergeModal.ts"
12
+ import { openRepositoryModalKeymap, type OpenRepositoryModalCtx } from "./openRepositoryModal.ts"
13
+ import { themeModalKeymap, type ThemeModalCtx } from "./themeModal.ts"
14
+
15
+ export interface AppCtx {
16
+ // Active flags
17
+ readonly closeModalActive: boolean
18
+ readonly mergeModalActive: boolean
19
+ readonly commentThreadModalActive: boolean
20
+ readonly labelModalActive: boolean
21
+ readonly themeModalActive: boolean
22
+ readonly openRepositoryModalActive: boolean
23
+ readonly commentModalActive: boolean
24
+ readonly commandPaletteActive: boolean
25
+ readonly filterMode: boolean
26
+ readonly diffFullView: boolean
27
+ readonly detailFullView: boolean
28
+
29
+ // True whenever a modal/mode swallows raw text input (so q-quit, etc. are
30
+ // disabled inside text-editing contexts).
31
+ readonly textInputActive: boolean
32
+
33
+ // Per-layer narrow contexts
34
+ readonly closeModal: CloseModalCtx
35
+ readonly mergeModal: MergeModalCtx
36
+ readonly commentThreadModal: CommentThreadModalCtx
37
+ readonly labelModal: LabelModalCtx
38
+ readonly themeModal: ThemeModalCtx
39
+ readonly openRepositoryModal: OpenRepositoryModalCtx
40
+ readonly commentModal: CommentModalCtx
41
+ readonly commandPalette: CommandPaletteCtx
42
+ readonly filterModeCtx: FilterModeCtx
43
+ readonly diff: DiffViewCtx
44
+ readonly detail: DetailViewCtx
45
+ readonly listNav: ListNavCtx
46
+
47
+ // Always-on / app-level
48
+ readonly openCommandPalette: () => void
49
+ readonly handleQuitOrClose: () => void
50
+ }
51
+
52
+ const App = context<AppCtx>()
53
+
54
+ const inListMode = (a: AppCtx): boolean =>
55
+ !a.closeModalActive
56
+ && !a.mergeModalActive
57
+ && !a.commentThreadModalActive
58
+ && !a.labelModalActive
59
+ && !a.themeModalActive
60
+ && !a.openRepositoryModalActive
61
+ && !a.commentModalActive
62
+ && !a.commandPaletteActive
63
+ && !a.filterMode
64
+ && !a.diffFullView
65
+ && !a.detailFullView
66
+
67
+ export const appKeymap = App(
68
+ // Always-on: command palette opener
69
+ { id: "command.open", title: "Open command palette", keys: ["ctrl+p", "meta+k"], run: (s) => s.openCommandPalette() },
70
+
71
+ // Quit / close-active-modal — gated to "not editing text"
72
+ {
73
+ id: "app.quit-or-close",
74
+ title: "Quit / close modal",
75
+ keys: ["ctrl+c"],
76
+ run: (s) => s.handleQuitOrClose(),
77
+ },
78
+ {
79
+ id: "app.quit-or-close-q",
80
+ title: "Quit / close modal",
81
+ keys: ["q"],
82
+ when: (s) => !s.textInputActive,
83
+ run: (s) => s.handleQuitOrClose(),
84
+ },
85
+
86
+ // Modal layers
87
+ closeModalKeymap.scope((a) => a.closeModalActive && a.closeModal),
88
+ mergeModalKeymap.scope((a) => a.mergeModalActive && a.mergeModal),
89
+ commentThreadModalKeymap.scope((a) => a.commentThreadModalActive && a.commentThreadModal),
90
+ labelModalKeymap.scope((a) => a.labelModalActive && a.labelModal),
91
+ themeModalKeymap.scope((a) => a.themeModalActive && a.themeModal),
92
+ openRepositoryModalKeymap.scope((a) => a.openRepositoryModalActive && a.openRepositoryModal),
93
+ commentModalKeymap.scope((a) => a.commentModalActive && a.commentModal),
94
+ commandPaletteKeymap.scope((a) => a.commandPaletteActive && a.commandPalette),
95
+ filterModeKeymap.scope((a) => a.filterMode && a.filterModeCtx),
96
+
97
+ // Full-view layers (only when no modal is on top)
98
+ diffViewKeymap.scope((a) => a.diffFullView && !a.commandPaletteActive && a.diff),
99
+ detailViewKeymap.scope((a) => a.detailFullView && !a.commandPaletteActive && a.detail),
100
+
101
+ // PR list nav
102
+ listNavKeymap.scope((a) => inListMode(a) && a.listNav),
103
+ )
@@ -0,0 +1,13 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface CloseModalCtx {
4
+ readonly closeModal: () => void
5
+ readonly confirmClose: () => void
6
+ }
7
+
8
+ const Close = context<CloseModalCtx>()
9
+
10
+ export const closeModalKeymap = Close(
11
+ { id: "close-modal.cancel", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
12
+ { id: "close-modal.confirm", title: "Close pull request", keys: ["return"], run: (s) => s.confirmClose() },
13
+ )
@@ -0,0 +1,16 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface CommandPaletteCtx {
4
+ readonly closeModal: () => void
5
+ readonly runSelected: () => void
6
+ readonly moveSelection: (delta: -1 | 1) => void
7
+ }
8
+
9
+ const Palette = context<CommandPaletteCtx>()
10
+
11
+ export const commandPaletteKeymap = Palette(
12
+ { id: "palette.close", title: "Close palette", keys: ["escape", "ctrl+c"], run: (s) => s.closeModal() },
13
+ { id: "palette.run", title: "Run command", keys: ["return"], run: (s) => s.runSelected() },
14
+ { id: "palette.up", title: "Up", keys: ["up"], run: (s) => s.moveSelection(-1) },
15
+ { id: "palette.down", title: "Down", keys: ["down"], run: (s) => s.moveSelection(1) },
16
+ )
@@ -0,0 +1,73 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ /**
4
+ * Single-method-per-binding interface. Each `move*` / `delete*` is a discrete
5
+ * editor command; the App constructs the context so each method applies the
6
+ * appropriate `editorState → editorState` transform via the existing
7
+ * `editComment` helper.
8
+ */
9
+ export interface CommentModalCtx {
10
+ readonly closeModal: () => void
11
+ readonly submit: () => void
12
+ readonly insertNewline: () => void
13
+ readonly moveLeft: () => void
14
+ readonly moveRight: () => void
15
+ readonly moveUp: () => void
16
+ readonly moveDown: () => void
17
+ readonly moveLineStart: () => void
18
+ readonly moveLineEnd: () => void
19
+ readonly moveWordBackward: () => void
20
+ readonly moveWordForward: () => void
21
+ readonly backspace: () => void
22
+ readonly deleteForward: () => void
23
+ readonly deleteWordBackward: () => void
24
+ readonly deleteWordForward: () => void
25
+ readonly deleteToLineStart: () => void
26
+ readonly deleteToLineEnd: () => void
27
+ }
28
+
29
+ const Comment = context<CommentModalCtx>()
30
+
31
+ export const commentModalKeymap = Comment(
32
+ { id: "comment.escape", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
33
+ { id: "comment.submit", title: "Submit", keys: ["ctrl+s", "return"], run: (s) => s.submit() },
34
+ { id: "comment.newline", title: "Insert newline", keys: ["shift+return"], run: (s) => s.insertNewline() },
35
+
36
+ // Cursor movement
37
+ { id: "comment.move-left", title: "Cursor left", keys: ["left", "ctrl+b"], run: (s) => s.moveLeft() },
38
+ { id: "comment.move-right", title: "Cursor right", keys: ["right", "ctrl+f"], run: (s) => s.moveRight() },
39
+ { id: "comment.move-up", title: "Cursor up", keys: ["up"], run: (s) => s.moveUp() },
40
+ { id: "comment.move-down", title: "Cursor down", keys: ["down"], run: (s) => s.moveDown() },
41
+ { id: "comment.line-start", title: "Line start", keys: ["home", "ctrl+a"], run: (s) => s.moveLineStart() },
42
+ { id: "comment.line-end", title: "Line end", keys: ["end", "ctrl+e"], run: (s) => s.moveLineEnd() },
43
+ {
44
+ id: "comment.word-back",
45
+ title: "Word backward",
46
+ keys: ["meta+b", "meta+left"],
47
+ run: (s) => s.moveWordBackward(),
48
+ },
49
+ {
50
+ id: "comment.word-forward",
51
+ title: "Word forward",
52
+ keys: ["meta+f", "meta+right"],
53
+ run: (s) => s.moveWordForward(),
54
+ },
55
+
56
+ // Deletion
57
+ { id: "comment.backspace", title: "Backspace", keys: ["backspace"], run: (s) => s.backspace() },
58
+ { id: "comment.delete", title: "Delete", keys: ["delete", "ctrl+d"], run: (s) => s.deleteForward() },
59
+ {
60
+ id: "comment.delete-word-back",
61
+ title: "Delete word backward",
62
+ keys: ["ctrl+w", "meta+backspace"],
63
+ run: (s) => s.deleteWordBackward(),
64
+ },
65
+ {
66
+ id: "comment.delete-word-forward",
67
+ title: "Delete word forward",
68
+ keys: ["meta+delete"],
69
+ run: (s) => s.deleteWordForward(),
70
+ },
71
+ { id: "comment.delete-to-line-start", title: "Delete to line start", keys: ["ctrl+u"], run: (s) => s.deleteToLineStart() },
72
+ { id: "comment.delete-to-line-end", title: "Delete to line end", keys: ["ctrl+k"], run: (s) => s.deleteToLineEnd() },
73
+ )
@@ -0,0 +1,24 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface CommentThreadModalCtx {
4
+ readonly halfPage: number
5
+ readonly closeModal: () => void
6
+ readonly openInlineComment: () => void
7
+ readonly scrollBy: (delta: number) => void
8
+ }
9
+
10
+ const Thread = context<CommentThreadModalCtx>()
11
+
12
+ export const commentThreadModalKeymap = Thread(
13
+ { id: "comment-thread.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
14
+ { id: "comment-thread.reply", title: "Reply", keys: ["return", "a", "c"], run: (s) => s.openInlineComment() },
15
+ { id: "comment-thread.up", title: "Up", keys: ["k", "up"], run: (s) => s.scrollBy(-1) },
16
+ { id: "comment-thread.down", title: "Down", keys: ["j", "down"], run: (s) => s.scrollBy(1) },
17
+ { id: "comment-thread.half-up", title: "Half page up", keys: ["pageup", "ctrl+u"], run: (s) => s.scrollBy(-s.halfPage) },
18
+ {
19
+ id: "comment-thread.half-down",
20
+ title: "Half page down",
21
+ keys: ["pagedown", "ctrl+d", "ctrl+v"],
22
+ run: (s) => s.scrollBy(s.halfPage),
23
+ },
24
+ )
@@ -0,0 +1,30 @@
1
+ import { context, type Scrollable, scrollCommands } from "@ghui/keymap"
2
+
3
+ export interface DetailViewCtx extends Scrollable {
4
+ readonly closeDetail: () => void
5
+ readonly openTheme: () => void
6
+ readonly openDiff: () => void
7
+ readonly closePullRequest: () => void
8
+ readonly openLabels: () => void
9
+ readonly openMerge: () => void
10
+ readonly toggleDraft: () => void
11
+ readonly refresh: () => void
12
+ readonly openInBrowser: () => void
13
+ readonly copyMetadata: () => void
14
+ }
15
+
16
+ const Detail = context<DetailViewCtx>()
17
+
18
+ export const detailViewKeymap = Detail(
19
+ scrollCommands<DetailViewCtx>(),
20
+ { id: "detail.close", title: "Close detail", keys: ["escape", "return"], run: (s) => s.closeDetail() },
21
+ { id: "detail.theme", title: "Open theme", keys: ["t"], run: (s) => s.openTheme() },
22
+ { id: "detail.diff", title: "Open diff", keys: ["d"], run: (s) => s.openDiff() },
23
+ { id: "detail.close-pr", title: "Close pull request", keys: ["x"], run: (s) => s.closePullRequest() },
24
+ { id: "detail.labels", title: "Manage labels", keys: ["l"], run: (s) => s.openLabels() },
25
+ { id: "detail.merge", title: "Merge", keys: ["m", "shift+m"], run: (s) => s.openMerge() },
26
+ { id: "detail.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.toggleDraft() },
27
+ { id: "detail.refresh", title: "Refresh", keys: ["r"], run: (s) => s.refresh() },
28
+ { id: "detail.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() },
29
+ { id: "detail.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.copyMetadata() },
30
+ )
@@ -0,0 +1,91 @@
1
+ import { context } from "@ghui/keymap"
2
+ import { countedVerticalBindings } from "./helpers.ts"
3
+
4
+ export type DiffSide = "LEFT" | "RIGHT"
5
+ export type DiffAlign = "center" | "top" | "bottom"
6
+
7
+ export interface DiffViewCtx {
8
+ readonly halfPage: number
9
+ readonly handleEscape: () => void // closes diff, or clears comment range if active
10
+ readonly openSelectedComment: () => void
11
+ readonly addComment: () => void
12
+ readonly toggleRange: () => void
13
+ readonly toggleView: () => void
14
+ readonly toggleWrap: () => void
15
+ readonly reload: () => void
16
+ readonly nextThread: () => void
17
+ readonly previousThread: () => void
18
+ readonly moveAnchor: (delta: number, opts?: { preserveViewportRow?: boolean }) => void
19
+ readonly moveAnchorToBoundary: (boundary: "first" | "last") => void
20
+ readonly alignAnchor: (align: DiffAlign) => void
21
+ readonly selectSide: (side: DiffSide) => void
22
+ readonly nextFile: () => void
23
+ readonly previousFile: () => void
24
+ readonly openInBrowser: () => void
25
+ }
26
+
27
+ const Diff = context<DiffViewCtx>()
28
+
29
+ export const diffViewKeymap = Diff(
30
+ { id: "diff.escape", title: "Close diff / clear range", keys: ["escape"], run: (s) => s.handleEscape() },
31
+ { id: "diff.open-comment", title: "Open / add comment", keys: ["return"], run: (s) => s.openSelectedComment() },
32
+ { id: "diff.add-comment", title: "Add comment on line", keys: ["a"], run: (s) => s.addComment() },
33
+ { id: "diff.toggle-range", title: "Toggle comment range", keys: ["v"], run: (s) => s.toggleRange() },
34
+ { id: "diff.toggle-view", title: "Toggle split/unified", keys: ["shift+v"], run: (s) => s.toggleView() },
35
+ { id: "diff.toggle-wrap", title: "Toggle wrap", keys: ["w"], run: (s) => s.toggleWrap() },
36
+ { id: "diff.reload", title: "Reload diff", keys: ["r"], run: (s) => s.reload() },
37
+ { id: "diff.next-thread", title: "Next thread", keys: ["n"], run: (s) => s.nextThread() },
38
+ { id: "diff.previous-thread", title: "Previous thread", keys: ["p"], run: (s) => s.previousThread() },
39
+
40
+ // Half-page anchor moves preserve viewport row (true vim semantics)
41
+ {
42
+ id: "diff.half-up",
43
+ title: "Half page up",
44
+ keys: ["pageup", "ctrl+u"],
45
+ run: (s) => s.moveAnchor(-s.halfPage, { preserveViewportRow: true }),
46
+ },
47
+ {
48
+ id: "diff.half-down",
49
+ title: "Half page down",
50
+ keys: ["pagedown", "ctrl+d", "ctrl+v"],
51
+ run: (s) => s.moveAnchor(s.halfPage, { preserveViewportRow: true }),
52
+ },
53
+
54
+ // Jump-by-8 (shift+arrow / meta+arrow / shift+letter / meta+letter)
55
+ {
56
+ id: "diff.jump-up",
57
+ title: "Jump up",
58
+ keys: ["shift+up", "shift+k", "meta+up", "meta+k"],
59
+ run: (s) => s.moveAnchor(-8),
60
+ },
61
+ {
62
+ id: "diff.jump-down",
63
+ title: "Jump down",
64
+ keys: ["shift+down", "shift+j", "meta+down", "meta+j"],
65
+ run: (s) => s.moveAnchor(8),
66
+ },
67
+
68
+ // Vim count prefixes ("2 k", "15 j", etc.)
69
+ ...countedVerticalBindings<DiffViewCtx>((s, delta) => s.moveAnchor(delta)),
70
+
71
+ // Single-step
72
+ { id: "diff.up", title: "Up", keys: ["up", "k"], run: (s) => s.moveAnchor(-1) },
73
+ { id: "diff.down", title: "Down", keys: ["down", "j"], run: (s) => s.moveAnchor(1) },
74
+
75
+ // Side selection
76
+ { id: "diff.side-left", title: "Old side", keys: ["left", "h"], run: (s) => s.selectSide("LEFT") },
77
+ { id: "diff.side-right", title: "New side", keys: ["right", "l"], run: (s) => s.selectSide("RIGHT") },
78
+
79
+ // File nav
80
+ { id: "diff.next-file", title: "Next file", keys: ["]"], run: (s) => s.nextFile() },
81
+ { id: "diff.previous-file", title: "Previous file", keys: ["["], run: (s) => s.previousFile() },
82
+
83
+ // Boundary jumps + align
84
+ { id: "diff.first", title: "First comment", keys: ["g g"], run: (s) => s.moveAnchorToBoundary("first") },
85
+ { id: "diff.last", title: "Last comment", keys: ["shift+g"], run: (s) => s.moveAnchorToBoundary("last") },
86
+ { id: "diff.align-center", title: "Align center", keys: ["z z"], run: (s) => s.alignAnchor("center") },
87
+ { id: "diff.align-top", title: "Align top", keys: ["z t"], run: (s) => s.alignAnchor("top") },
88
+ { id: "diff.align-bottom", title: "Align bottom", keys: ["z b"], run: (s) => s.alignAnchor("bottom") },
89
+
90
+ { id: "diff.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() },
91
+ )
@@ -0,0 +1,13 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface FilterModeCtx {
4
+ readonly cancel: () => void
5
+ readonly commit: () => void
6
+ }
7
+
8
+ const Filter = context<FilterModeCtx>()
9
+
10
+ export const filterModeKeymap = Filter(
11
+ { id: "filter-mode.cancel", title: "Cancel filter", keys: ["escape"], run: (s) => s.cancel() },
12
+ { id: "filter-mode.commit", title: "Apply filter", keys: ["return"], run: (s) => s.commit() },
13
+ )
@@ -0,0 +1,24 @@
1
+ import type { CommandConfig } from "@ghui/keymap"
2
+
3
+ const MAX_COUNT_PREFIX = 99
4
+
5
+ const countSequence = (count: number, key: string) =>
6
+ `${String(count).split("").join(" ")} ${key}`
7
+
8
+ /**
9
+ * Vim-style "count prefix" navigation: `2 k` moves by 2, `15 j` by 15, etc.
10
+ * Generates bindings for `count` × {k, up, j, down} for count ∈ [1, 99].
11
+ *
12
+ * Each generated binding is anonymous (no meta) — they don't belong in the
13
+ * palette. Only `moveBy` semantics matter.
14
+ */
15
+ export const countedVerticalBindings = <C>(
16
+ moveBy: (ctx: C, delta: number) => void,
17
+ ): readonly CommandConfig<C>[] => {
18
+ const out: CommandConfig<C>[] = []
19
+ for (let count = 1; count <= MAX_COUNT_PREFIX; count++) {
20
+ out.push({ keys: [countSequence(count, "k"), countSequence(count, "up")], run: (s) => moveBy(s, -count) })
21
+ out.push({ keys: [countSequence(count, "j"), countSequence(count, "down")], run: (s) => moveBy(s, count) })
22
+ }
23
+ return out
24
+ }