@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.
@@ -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,6 +20,7 @@ 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
25
  readonly openSelectedDiffComment: () => void
25
26
  readonly toggleDiffCommentRange: () => void
@@ -50,6 +51,7 @@ interface BuildAppCommandsInput {
50
51
  readonly diffReady: boolean
51
52
  readonly effectiveDiffRenderView: DiffView
52
53
  readonly diffWrapMode: DiffWrapMode
54
+ readonly diffWhitespaceMode: DiffWhitespaceMode
53
55
  readonly readyDiffFileCount: number
54
56
  readonly diffFileIndex: number
55
57
  readonly diffRangeActive: boolean
@@ -75,6 +77,7 @@ export const buildAppCommands = ({
75
77
  diffReady,
76
78
  effectiveDiffRenderView,
77
79
  diffWrapMode,
80
+ diffWhitespaceMode,
78
81
  readyDiffFileCount,
79
82
  diffFileIndex,
80
83
  diffRangeActive,
@@ -128,7 +131,7 @@ export const buildAppCommands = ({
128
131
  subtitle: "Fetch the latest queue from GitHub",
129
132
  shortcut: "r",
130
133
  keywords: ["reload", "sync"],
131
- run: () => actions.refreshPullRequests("Refreshed"),
134
+ run: () => actions.refreshPullRequests("Refreshed", { resetTransientState: true }),
132
135
  }),
133
136
  defineCommand({
134
137
  id: "filter.open",
@@ -201,7 +204,7 @@ export const buildAppCommands = ({
201
204
  }),
202
205
  forSelected({
203
206
  id: "diff.open",
204
- title: "Open stacked diff",
207
+ title: "Open diff",
205
208
  scope: "Diff",
206
209
  shortcut: "d",
207
210
  keywords: ["files", "patch"],
@@ -244,6 +247,15 @@ export const buildAppCommands = ({
244
247
  disabledReason: diffFullView ? null : "Open a diff first.",
245
248
  run: actions.toggleDiffWrapMode,
246
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
+ }),
247
259
  defineCommand({
248
260
  id: "diff.next-file",
249
261
  title: "Next diff file",
package/src/domain.ts CHANGED
@@ -78,6 +78,17 @@ export interface PullRequestReviewComment {
78
78
  readonly url: string | null
79
79
  }
80
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
+
81
92
  export interface PullRequestItem {
82
93
  readonly repository: string
83
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 { KeymapProvider } from "@opentui/keymap/react"
7
- import { createKeymap } from "./keyboard/createKeymap.js"
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 = createKeymap(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
+ }
@@ -0,0 +1,16 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface LabelModalCtx {
4
+ readonly closeModal: () => void
5
+ readonly toggleSelected: () => void
6
+ readonly moveSelection: (delta: -1 | 1) => void
7
+ }
8
+
9
+ const Label = context<LabelModalCtx>()
10
+
11
+ export const labelModalKeymap = Label(
12
+ { id: "label-modal.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
13
+ { id: "label-modal.toggle", title: "Toggle label", keys: ["return"], run: (s) => s.toggleSelected() },
14
+ { id: "label-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
15
+ { id: "label-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
16
+ )