@kitlangton/ghui 0.1.20 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,19 +42,26 @@
42
42
  "start": "bun run src/index.tsx",
43
43
  "start:mock": "GHUI_MOCK_PR_COUNT=400 GHUI_MOCK_REPO_COUNT=4 bun run src/index.tsx",
44
44
  "test": "bun test test",
45
- "typecheck": "tsc --noEmit"
45
+ "typecheck": "tsc --noEmit",
46
+ "lint": "oxlint --tsconfig tsconfig.json src/ test/",
47
+ "format": "oxfmt src/ test/",
48
+ "format:check": "oxfmt --check src/ test/"
46
49
  },
47
50
  "dependencies": {
48
- "@effect/atom-react": "4.0.0-beta.47",
49
- "@opentui/core": "0.1.97",
50
- "@opentui/react": "0.1.97",
51
- "effect": "4.0.0-beta.47",
51
+ "@effect/atom-react": "4.0.0-beta.59",
52
+ "@opentui/core": "0.2.1",
53
+ "@opentui/keymap": "0.2.1",
54
+ "@opentui/react": "0.2.1",
55
+ "effect": "4.0.0-beta.59",
52
56
  "react": "19.2.5",
53
57
  "scheduler": "0.27.0"
54
58
  },
55
59
  "devDependencies": {
60
+ "@effect/language-service": "0.85.1",
56
61
  "@types/bun": "1.3.12",
57
62
  "@types/react": "19.2.14",
63
+ "oxfmt": "0.47.0",
64
+ "oxlint": "1.62.0",
58
65
  "typescript": "6.0.2"
59
66
  }
60
67
  }
package/src/App.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
+ import { useBindings } from "@opentui/keymap/react"
3
4
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
5
  import { Cause, Effect, Layer, Schedule } from "effect"
5
6
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
@@ -8,7 +9,7 @@ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
8
9
  import { useContext, useEffect, useMemo, useRef, useState } from "react"
9
10
  import { buildAppCommands } from "./appCommands.js"
10
11
  import type { AppCommand } from "./commands.js"
11
- import { clampCommandIndex, commandEnabled, filterCommands } from "./commands.js"
12
+ import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
12
13
  import { config } from "./config.js"
13
14
  import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
14
15
  import { formatShortDate, formatTimestamp } from "./date.js"
@@ -108,6 +109,7 @@ const DIFF_STICKY_HEADER_LINES = 2
108
109
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
109
110
  const MAX_REPOSITORY_CACHE_ENTRIES = 8
110
111
  const LOAD_MORE_SELECTION_THRESHOLD = 8
112
+ const LOAD_MORE_SCROLL_THRESHOLD = 3
111
113
  const DETAIL_PREFETCH_BEHIND = 1
112
114
  const DETAIL_PREFETCH_AHEAD = 3
113
115
  const DETAIL_PREFETCH_CONCURRENCY = 3
@@ -904,6 +906,20 @@ export const App = () => {
904
906
  if (selectedIndex >= thresholdIndex) loadMorePullRequests()
905
907
  }, [selectedIndex, visiblePullRequests.length, filterMode, filterQuery, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
906
908
 
909
+ useEffect(() => {
910
+ if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0 || detailFullView || diffFullView) return
911
+ if (!hasMorePullRequests || isLoadingMorePullRequests) return
912
+ const checkScroll = () => {
913
+ const scroll = prListScrollRef.current
914
+ if (!scroll || scroll.viewport.height <= 0) return
915
+ const bottom = scroll.scrollTop + scroll.viewport.height
916
+ if (bottom >= scroll.scrollHeight - LOAD_MORE_SCROLL_THRESHOLD) loadMorePullRequests()
917
+ }
918
+ checkScroll()
919
+ const interval = globalThis.setInterval(checkScroll, 120)
920
+ return () => globalThis.clearInterval(interval)
921
+ }, [visiblePullRequests.length, filterMode, filterQuery, detailFullView, diffFullView, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
922
+
907
923
  useEffect(() => {
908
924
  const scroll = prListScrollRef.current
909
925
  if (!scroll || selectedPullRequestRowIndex === null) return
@@ -1739,69 +1755,324 @@ export const App = () => {
1739
1755
  const command = appCommands.find((entry) => entry.id === id)
1740
1756
  return command ? runCommand(command, options) : false
1741
1757
  }
1742
- const commandPaletteCommands = commandPaletteActive ? filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query) : []
1758
+ const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1759
+ if (!commandPaletteActive) return []
1760
+ const repository = parseRepositoryInput(commandPalette.query)
1761
+ if (!repository || repository === selectedRepository) return []
1762
+ return [defineCommand({
1763
+ id: `view.repository.dynamic:${repository}`,
1764
+ title: `Open ${repository}`,
1765
+ scope: "View",
1766
+ subtitle: "Switch to this repository",
1767
+ run: () => switchViewTo({ _tag: "Repository", repository }),
1768
+ })]
1769
+ })()
1770
+ // Dynamic commands always pin to the top of the palette; they came directly from the
1771
+ // user's typed input so they shouldn't be filtered by fuzzy score against themselves.
1772
+ const commandPaletteCommands = commandPaletteActive
1773
+ ? [
1774
+ ...dynamicPaletteCommands,
1775
+ ...sortCommandsByScope(filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)),
1776
+ ]
1777
+ : []
1743
1778
  const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1744
1779
  const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1745
1780
 
1781
+ // Keymap migration phase 2: simple cmd-id bindings move out of useKeyboard.
1782
+ // Gated to "global mode" — no modal active, no full-view, not in filter editing —
1783
+ // so these don't dispatch on top of modal-specific handlers below.
1784
+ const globalKeymapActiveRef = useRef(false)
1785
+ globalKeymapActiveRef.current = !commandPaletteActive
1786
+ && !openRepositoryModalActive
1787
+ && !labelModalActive
1788
+ && !commentModalActive
1789
+ && !commentThreadModalActive
1790
+ && !closeModalActive
1791
+ && !mergeModalActive
1792
+ && !themeModalActive
1793
+ && !diffFullView
1794
+ && !detailFullView
1795
+ && !filterMode
1796
+ const runCommandByIdRef = useRef(runCommandById)
1797
+ runCommandByIdRef.current = runCommandById
1798
+ useBindings(() => ({
1799
+ enabled: () => globalKeymapActiveRef.current,
1800
+ bindings: [
1801
+ { key: "/", cmd: () => runCommandByIdRef.current("filter.open") },
1802
+ { key: "r", cmd: () => runCommandByIdRef.current("pull.refresh") },
1803
+ { key: "t", cmd: () => runCommandByIdRef.current("theme.open") },
1804
+ { key: "d", cmd: () => runCommandByIdRef.current("diff.open") },
1805
+ { key: "l", cmd: () => runCommandByIdRef.current("pull.labels") },
1806
+ { key: "m", cmd: () => runCommandByIdRef.current("pull.merge") },
1807
+ { key: "shift+m", cmd: () => runCommandByIdRef.current("pull.merge") },
1808
+ { key: "x", cmd: () => runCommandByIdRef.current("pull.close") },
1809
+ { key: "o", cmd: () => runCommandByIdRef.current("pull.open-browser") },
1810
+ { key: "s", cmd: () => runCommandByIdRef.current("pull.toggle-draft") },
1811
+ { key: "shift+s", cmd: () => runCommandByIdRef.current("pull.toggle-draft") },
1812
+ { key: "y", cmd: () => runCommandByIdRef.current("pull.copy-metadata") },
1813
+ { key: "return", cmd: () => runCommandByIdRef.current("detail.open") },
1814
+ ],
1815
+ }), [])
1816
+ // Always-on bindings — work even while modals are open.
1817
+ useBindings(() => ({
1818
+ bindings: [
1819
+ { key: "ctrl+p", cmd: () => runCommandByIdRef.current("command.open") },
1820
+ { key: "meta+k", cmd: () => runCommandByIdRef.current("command.open") },
1821
+ ],
1822
+ }), [])
1823
+
1824
+ // CloseModal: escape closes, enter confirms.
1825
+ const closeModalActiveRef = useRef(false)
1826
+ closeModalActiveRef.current = closeModalActive
1827
+ const closeActiveModalRef = useRef(closeActiveModal)
1828
+ closeActiveModalRef.current = closeActiveModal
1829
+ const confirmClosePullRequestRef = useRef(confirmClosePullRequest)
1830
+ confirmClosePullRequestRef.current = confirmClosePullRequest
1831
+ useBindings(() => ({
1832
+ enabled: () => closeModalActiveRef.current,
1833
+ bindings: [
1834
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1835
+ { key: "return", cmd: () => confirmClosePullRequestRef.current() },
1836
+ ],
1837
+ }), [])
1838
+
1839
+ // MergeModal: escape, enter (when options>0), up/down/j/k navigation.
1840
+ const mergeModalActiveRef = useRef(false)
1841
+ mergeModalActiveRef.current = mergeModalActive
1842
+ const mergeModalContextRef = useRef({ availableCount: 0, confirm: confirmMergeAction, setMergeModal })
1843
+ mergeModalContextRef.current = {
1844
+ availableCount: availableMergeActions(mergeModal.info).length,
1845
+ confirm: confirmMergeAction,
1846
+ setMergeModal,
1847
+ }
1848
+ const moveMergeSelection = (delta: -1 | 1) => mergeModalContextRef.current.setMergeModal((current) => {
1849
+ const max = Math.max(0, mergeModalContextRef.current.availableCount - 1)
1850
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1851
+ })
1852
+ useBindings(() => ({
1853
+ enabled: () => mergeModalActiveRef.current,
1854
+ bindings: [
1855
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1856
+ { key: "return", cmd: () => {
1857
+ if (mergeModalContextRef.current.availableCount > 0) mergeModalContextRef.current.confirm()
1858
+ } },
1859
+ { key: "up", cmd: () => moveMergeSelection(-1) },
1860
+ { key: "k", cmd: () => moveMergeSelection(-1) },
1861
+ { key: "down", cmd: () => moveMergeSelection(1) },
1862
+ { key: "j", cmd: () => moveMergeSelection(1) },
1863
+ ],
1864
+ }), [])
1865
+
1866
+ // CommentThreadModal: scroll the thread, shortcut to compose a reply.
1867
+ const commentThreadModalActiveRef = useRef(false)
1868
+ commentThreadModalActiveRef.current = commentThreadModalActive
1869
+ const commentThreadCtxRef = useRef({ openDiffCommentModal, setCommentThreadModal, halfPage })
1870
+ commentThreadCtxRef.current = { openDiffCommentModal, setCommentThreadModal, halfPage }
1871
+ const scrollCommentThread = (delta: number) => commentThreadCtxRef.current.setCommentThreadModal((current) => ({
1872
+ ...current,
1873
+ scrollOffset: Math.max(0, current.scrollOffset + delta),
1874
+ }))
1875
+ useBindings(() => ({
1876
+ enabled: () => commentThreadModalActiveRef.current,
1877
+ bindings: [
1878
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1879
+ { key: "return", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1880
+ { key: "a", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1881
+ { key: "c", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1882
+ { key: "up", cmd: () => scrollCommentThread(-1) },
1883
+ { key: "k", cmd: () => scrollCommentThread(-1) },
1884
+ { key: "down", cmd: () => scrollCommentThread(1) },
1885
+ { key: "j", cmd: () => scrollCommentThread(1) },
1886
+ { key: "pageup", cmd: () => scrollCommentThread(-commentThreadCtxRef.current.halfPage) },
1887
+ { key: "ctrl+u", cmd: () => scrollCommentThread(-commentThreadCtxRef.current.halfPage) },
1888
+ { key: "pagedown", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1889
+ { key: "ctrl+d", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1890
+ { key: "ctrl+v", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1891
+ ],
1892
+ }), [])
1893
+
1894
+ // LabelModal: nav keys via keymap; text input stays in useKeyboard fallback.
1895
+ const labelModalActiveRef = useRef(false)
1896
+ labelModalActiveRef.current = labelModalActive
1897
+ const labelModalCtxRef = useRef({ toggleLabelAtIndex, setLabelModal, filteredCount: 0 })
1898
+ labelModalCtxRef.current = {
1899
+ toggleLabelAtIndex,
1900
+ setLabelModal,
1901
+ filteredCount: filterLabels(labelModal.availableLabels, labelModal.query).length,
1902
+ }
1903
+ const moveLabelSelection = (delta: -1 | 1) => labelModalCtxRef.current.setLabelModal((current) => {
1904
+ const max = Math.max(0, labelModalCtxRef.current.filteredCount - 1)
1905
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1906
+ })
1907
+ useBindings(() => ({
1908
+ enabled: () => labelModalActiveRef.current,
1909
+ bindings: [
1910
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1911
+ { key: "return", cmd: () => labelModalCtxRef.current.toggleLabelAtIndex() },
1912
+ { key: "up", cmd: () => moveLabelSelection(-1) },
1913
+ { key: "k", cmd: () => moveLabelSelection(-1) },
1914
+ { key: "down", cmd: () => moveLabelSelection(1) },
1915
+ { key: "j", cmd: () => moveLabelSelection(1) },
1916
+ ],
1917
+ }), [])
1918
+
1919
+ // ThemeModal: nav + filter-mode toggle. j/k only navigate when not in filter mode
1920
+ // (so users can type those letters into the query).
1921
+ const themeModalActiveRef = useRef(false)
1922
+ themeModalActiveRef.current = themeModalActive
1923
+ const themeModalCtxRef = useRef({
1924
+ filterMode: false,
1925
+ hasResults: true,
1926
+ closeThemeModal,
1927
+ updateThemeQuery,
1928
+ moveThemeSelection,
1929
+ })
1930
+ themeModalCtxRef.current = {
1931
+ filterMode: themeModal.filterMode,
1932
+ hasResults: filterThemeDefinitions(themeModal.query).length > 0,
1933
+ closeThemeModal,
1934
+ updateThemeQuery,
1935
+ moveThemeSelection,
1936
+ }
1937
+ useBindings(() => ({
1938
+ enabled: () => themeModalActiveRef.current,
1939
+ bindings: [
1940
+ { key: "escape", cmd: () => {
1941
+ if (themeModalCtxRef.current.filterMode) themeModalCtxRef.current.updateThemeQuery("", { filterMode: false })
1942
+ else themeModalCtxRef.current.closeThemeModal(false)
1943
+ } },
1944
+ { key: "/", cmd: () => themeModalCtxRef.current.updateThemeQuery("", { filterMode: true }) },
1945
+ { key: "return", cmd: () => {
1946
+ if (themeModalCtxRef.current.filterMode && !themeModalCtxRef.current.hasResults) return
1947
+ themeModalCtxRef.current.closeThemeModal(true)
1948
+ } },
1949
+ { key: "up", cmd: () => themeModalCtxRef.current.moveThemeSelection(-1) },
1950
+ { key: "down", cmd: () => themeModalCtxRef.current.moveThemeSelection(1) },
1951
+ { key: "k", cmd: () => { if (!themeModalCtxRef.current.filterMode) themeModalCtxRef.current.moveThemeSelection(-1) } },
1952
+ { key: "j", cmd: () => { if (!themeModalCtxRef.current.filterMode) themeModalCtxRef.current.moveThemeSelection(1) } },
1953
+ ],
1954
+ }), [])
1955
+
1956
+ // OpenRepositoryModal: escape closes, return submits.
1957
+ const openRepositoryModalActiveRef = useRef(false)
1958
+ openRepositoryModalActiveRef.current = openRepositoryModalActive
1959
+ const openRepositoryFromInputRef = useRef(openRepositoryFromInput)
1960
+ openRepositoryFromInputRef.current = openRepositoryFromInput
1961
+ useBindings(() => ({
1962
+ enabled: () => openRepositoryModalActiveRef.current,
1963
+ bindings: [
1964
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1965
+ { key: "return", cmd: () => openRepositoryFromInputRef.current() },
1966
+ ],
1967
+ }), [])
1968
+
1969
+ // CommentModal: full text editor — escape, submit, all the cursor/edit bindings.
1970
+ const commentModalActiveRef = useRef(false)
1971
+ commentModalActiveRef.current = commentModalActive
1972
+ const commentModalCtxRef = useRef({ submitDiffComment, editComment })
1973
+ commentModalCtxRef.current = { submitDiffComment, editComment }
1974
+ const editComm = (transform: Parameters<typeof editComment>[0]) => commentModalCtxRef.current.editComment(transform)
1975
+ useBindings(() => ({
1976
+ enabled: () => commentModalActiveRef.current,
1977
+ bindings: [
1978
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1979
+ { key: "ctrl+s", cmd: () => commentModalCtxRef.current.submitDiffComment() },
1980
+ { key: "ctrl+a", cmd: () => editComm(moveLineStart) },
1981
+ { key: "ctrl+e", cmd: () => editComm(moveLineEnd) },
1982
+ { key: "ctrl+b", cmd: () => editComm(editorMoveLeft) },
1983
+ { key: "ctrl+f", cmd: () => editComm(editorMoveRight) },
1984
+ { key: "ctrl+w", cmd: () => editComm(deleteWordBackward) },
1985
+ { key: "ctrl+u", cmd: () => editComm(deleteToLineStart) },
1986
+ { key: "ctrl+k", cmd: () => editComm(deleteToLineEnd) },
1987
+ { key: "ctrl+d", cmd: () => editComm(editorDeleteForward) },
1988
+ { key: "meta+b", cmd: () => editComm(moveWordBackward) },
1989
+ { key: "meta+left", cmd: () => editComm(moveWordBackward) },
1990
+ { key: "meta+f", cmd: () => editComm(moveWordForward) },
1991
+ { key: "meta+right", cmd: () => editComm(moveWordForward) },
1992
+ { key: "meta+backspace", cmd: () => editComm(deleteWordBackward) },
1993
+ { key: "meta+delete", cmd: () => editComm(deleteWordForward) },
1994
+ { key: "backspace", cmd: () => editComm(editorBackspace) },
1995
+ { key: "delete", cmd: () => editComm(editorDeleteForward) },
1996
+ { key: "left", cmd: () => editComm(editorMoveLeft) },
1997
+ { key: "right", cmd: () => editComm(editorMoveRight) },
1998
+ { key: "up", cmd: () => editComm((state) => moveVertically(state, -1)) },
1999
+ { key: "down", cmd: () => editComm((state) => moveVertically(state, 1)) },
2000
+ { key: "home", cmd: () => editComm(moveLineStart) },
2001
+ { key: "end", cmd: () => editComm(moveLineEnd) },
2002
+ { key: "shift+return", cmd: () => editComm((state) => insertText(state, "\n")) },
2003
+ { key: "return", cmd: () => commentModalCtxRef.current.submitDiffComment() },
2004
+ ],
2005
+ }), [])
2006
+
2007
+ // CommandPalette: escape closes, return runs, up/k & down/j navigate.
2008
+ const commandPaletteActiveRef = useRef(false)
2009
+ commandPaletteActiveRef.current = commandPaletteActive
2010
+ const commandPaletteCtxRef = useRef({
2011
+ runSelected: () => {},
2012
+ setCommandPalette,
2013
+ paletteCommands: commandPaletteCommands,
2014
+ })
2015
+ commandPaletteCtxRef.current = {
2016
+ runSelected: () => { if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true }) },
2017
+ setCommandPalette,
2018
+ paletteCommands: commandPaletteCommands,
2019
+ }
2020
+ const moveCommandPaletteSelection = (delta: -1 | 1) => commandPaletteCtxRef.current.setCommandPalette((current) => {
2021
+ const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCtxRef.current.paletteCommands)
2022
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2023
+ })
2024
+ useBindings(() => ({
2025
+ enabled: () => commandPaletteActiveRef.current,
2026
+ bindings: [
2027
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
2028
+ { key: "ctrl+c", cmd: () => closeActiveModalRef.current() },
2029
+ { key: "return", cmd: () => commandPaletteCtxRef.current.runSelected() },
2030
+ { key: "up", cmd: () => moveCommandPaletteSelection(-1) },
2031
+ { key: "down", cmd: () => moveCommandPaletteSelection(1) },
2032
+ ],
2033
+ }), [])
2034
+
2035
+ // FilterMode: escape cancels, return commits.
2036
+ const filterModeRef = useRef(false)
2037
+ filterModeRef.current = filterMode
2038
+ const filterCtxRef = useRef({ filterQuery, filterDraft, setFilterQuery, setFilterDraft, setFilterMode })
2039
+ filterCtxRef.current = { filterQuery, filterDraft, setFilterQuery, setFilterDraft, setFilterMode }
2040
+ useBindings(() => ({
2041
+ enabled: () => filterModeRef.current,
2042
+ bindings: [
2043
+ { key: "escape", cmd: () => {
2044
+ filterCtxRef.current.setFilterDraft(filterCtxRef.current.filterQuery)
2045
+ filterCtxRef.current.setFilterMode(false)
2046
+ } },
2047
+ { key: "return", cmd: () => {
2048
+ filterCtxRef.current.setFilterQuery(filterCtxRef.current.filterDraft)
2049
+ filterCtxRef.current.setFilterMode(false)
2050
+ } },
2051
+ ],
2052
+ }), [])
2053
+
1746
2054
  useKeyboard((key) => {
1747
2055
  if (commandPaletteActive) {
1748
- if (key.name === "escape" || key.ctrl && key.name === "c") {
1749
- closeActiveModal()
1750
- return
1751
- }
1752
- if (key.name === "return" || key.name === "enter") {
1753
- if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true })
1754
- return
1755
- }
1756
- if (key.name === "up" || key.name === "k" && !key.ctrl && !key.meta) {
1757
- setCommandPalette((current) => {
1758
- const selectedIndex = clampCommandIndex(current.selectedIndex - 1, commandPaletteCommands)
1759
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1760
- })
1761
- return
1762
- }
1763
- if (key.name === "down" || key.name === "j" && !key.ctrl && !key.meta) {
1764
- setCommandPalette((current) => {
1765
- const selectedIndex = clampCommandIndex(current.selectedIndex + 1, commandPaletteCommands)
1766
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1767
- })
1768
- return
1769
- }
1770
2056
  if (isSingleLineInputKey(key)) {
1771
2057
  setCommandPalette((current) => {
1772
2058
  const query = editSingleLineInput(current.query, key) ?? current.query
1773
2059
  return current.query === query && current.selectedIndex === 0 ? current : { ...current, query, selectedIndex: 0 }
1774
2060
  })
1775
- return
1776
2061
  }
1777
2062
  return
1778
2063
  }
1779
2064
 
1780
2065
  if (openRepositoryModalActive) {
1781
- if (key.name === "escape" || key.ctrl && key.name === "c") {
1782
- closeActiveModal()
1783
- return
1784
- }
1785
- if (key.name === "return" || key.name === "enter") {
1786
- openRepositoryFromInput()
1787
- return
1788
- }
1789
2066
  if (isSingleLineInputKey(key)) {
1790
2067
  setOpenRepositoryModal((current) => ({
1791
2068
  ...current,
1792
2069
  query: editSingleLineInput(current.query, key) ?? current.query,
1793
2070
  error: null,
1794
2071
  }))
1795
- return
1796
2072
  }
1797
2073
  return
1798
2074
  }
1799
2075
 
1800
- if ((key.ctrl && key.name === "p") || (key.meta && key.name === "k")) {
1801
- runCommandById("command.open")
1802
- return
1803
- }
1804
-
1805
2076
  if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1806
2077
  if (themeModalActive) {
1807
2078
  closeThemeModal(false)
@@ -1816,237 +2087,28 @@ export const App = () => {
1816
2087
  }
1817
2088
 
1818
2089
  if (themeModalActive) {
1819
- if (key.name === "escape") {
1820
- if (themeModal.filterMode) {
1821
- updateThemeQuery("", { filterMode: false })
1822
- return
1823
- }
1824
- closeThemeModal(false)
1825
- return
1826
- }
1827
- if (key.name === "/") {
1828
- updateThemeQuery("", { filterMode: true })
1829
- return
1830
- }
1831
- if (key.name === "return" || key.name === "enter") {
1832
- if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
1833
- closeThemeModal(true)
1834
- return
1835
- }
1836
- if (key.name === "up" || (!themeModal.filterMode && key.name === "k")) {
1837
- moveThemeSelection(-1)
1838
- return
1839
- }
1840
- if (key.name === "down" || (!themeModal.filterMode && key.name === "j")) {
1841
- moveThemeSelection(1)
1842
- return
1843
- }
1844
2090
  if (themeModal.filterMode && isSingleLineInputKey(key)) {
1845
2091
  editThemeQuery((query) => editSingleLineInput(query, key) ?? query)
1846
- return
1847
2092
  }
1848
2093
  return
1849
2094
  }
1850
2095
 
1851
2096
  if (commentModalActive) {
1852
- if (key.name === "escape") {
1853
- closeActiveModal()
1854
- return
1855
- }
1856
- if (key.ctrl && key.name === "s") {
1857
- submitDiffComment()
1858
- return
1859
- }
1860
- if (key.ctrl && key.name === "a") {
1861
- editComment(moveLineStart)
1862
- return
1863
- }
1864
- if (key.ctrl && key.name === "e") {
1865
- editComment(moveLineEnd)
1866
- return
1867
- }
1868
- if (key.ctrl && key.name === "b") {
1869
- editComment(editorMoveLeft)
1870
- return
1871
- }
1872
- if (key.ctrl && key.name === "f") {
1873
- editComment(editorMoveRight)
1874
- return
1875
- }
1876
- if (key.ctrl && key.name === "w") {
1877
- editComment(deleteWordBackward)
1878
- return
1879
- }
1880
- if (key.ctrl && key.name === "u") {
1881
- editComment(deleteToLineStart)
1882
- return
1883
- }
1884
- if (key.ctrl && key.name === "k") {
1885
- editComment(deleteToLineEnd)
1886
- return
1887
- }
1888
- if (key.ctrl && key.name === "d") {
1889
- editComment(editorDeleteForward)
1890
- return
1891
- }
1892
- if ((key.meta || key.option) && (key.name === "b" || key.name === "left")) {
1893
- editComment(moveWordBackward)
1894
- return
1895
- }
1896
- if ((key.meta || key.option) && (key.name === "f" || key.name === "right")) {
1897
- editComment(moveWordForward)
1898
- return
1899
- }
1900
- if ((key.meta || key.option) && (key.name === "backspace" || key.name === "delete")) {
1901
- editComment(key.name === "delete" ? deleteWordForward : deleteWordBackward)
1902
- return
1903
- }
1904
- if (key.name === "backspace") {
1905
- editComment(editorBackspace)
1906
- return
1907
- }
1908
- if (key.name === "delete") {
1909
- editComment(editorDeleteForward)
1910
- return
1911
- }
1912
- if (key.name === "left") {
1913
- editComment(editorMoveLeft)
1914
- return
1915
- }
1916
- if (key.name === "right") {
1917
- editComment(editorMoveRight)
1918
- return
1919
- }
1920
- if (key.name === "up") {
1921
- editComment((state) => moveVertically(state, -1))
1922
- return
1923
- }
1924
- if (key.name === "down") {
1925
- editComment((state) => moveVertically(state, 1))
1926
- return
1927
- }
1928
- if (key.name === "home") {
1929
- editComment(moveLineStart)
1930
- return
1931
- }
1932
- if (key.name === "end") {
1933
- editComment(moveLineEnd)
1934
- return
1935
- }
1936
- if ((key.name === "return" || key.name === "enter") && key.shift) {
1937
- editComment((state) => insertText(state, "\n"))
1938
- return
1939
- }
1940
- if (key.name === "return" || key.name === "enter") {
1941
- submitDiffComment()
1942
- return
1943
- }
1944
2097
  const text = printableKeyText(key)
1945
- if (text) {
1946
- editComment((state) => insertText(state, text))
1947
- return
1948
- }
2098
+ if (text) editComment((state) => insertText(state, text))
1949
2099
  return
1950
2100
  }
1951
2101
 
1952
- if (commentThreadModalActive) {
1953
- if (key.name === "escape") {
1954
- closeActiveModal()
1955
- return
1956
- }
1957
- if (key.name === "return" || key.name === "enter" || key.name === "a" || key.name === "c") {
1958
- openDiffCommentModal()
1959
- return
1960
- }
1961
- if (key.name === "up" || key.name === "k") {
1962
- setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - 1) }))
1963
- return
1964
- }
1965
- if (key.name === "down" || key.name === "j") {
1966
- setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + 1 }))
1967
- return
1968
- }
1969
- if (key.name === "pageup" || key.ctrl && key.name === "u") {
1970
- setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - halfPage) }))
1971
- return
1972
- }
1973
- if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1974
- setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + halfPage }))
1975
- return
1976
- }
1977
- return
1978
- }
1979
2102
 
1980
- if (closeModalActive) {
1981
- if (key.name === "escape") {
1982
- closeActiveModal()
1983
- return
1984
- }
1985
- if (key.name === "return" || key.name === "enter") {
1986
- confirmClosePullRequest()
1987
- return
1988
- }
1989
- return
1990
- }
1991
2103
 
1992
- if (mergeModalActive) {
1993
- const options = availableMergeActions(mergeModal.info)
1994
- if (key.name === "escape") {
1995
- closeActiveModal()
1996
- return
1997
- }
1998
- if ((key.name === "return" || key.name === "enter") && options.length > 0) {
1999
- confirmMergeAction()
2000
- return
2001
- }
2002
- if (key.name === "up" || key.name === "k") {
2003
- setMergeModal((current) => ({
2004
- ...current,
2005
- selectedIndex: Math.max(0, current.selectedIndex - 1),
2006
- }))
2007
- return
2008
- }
2009
- if (key.name === "down" || key.name === "j") {
2010
- setMergeModal((current) => ({
2011
- ...current,
2012
- selectedIndex: Math.min(Math.max(0, options.length - 1), current.selectedIndex + 1),
2013
- }))
2014
- return
2015
- }
2016
- return
2017
- }
2018
2104
 
2019
2105
  if (labelModalActive) {
2020
- if (key.name === "escape") {
2021
- closeActiveModal()
2022
- return
2023
- }
2024
- if (key.name === "return" || key.name === "enter") {
2025
- toggleLabelAtIndex()
2026
- return
2027
- }
2028
- if (key.name === "up" || key.name === "k") {
2029
- setLabelModal((current) => ({
2030
- ...current,
2031
- selectedIndex: Math.max(0, current.selectedIndex - 1),
2032
- }))
2033
- return
2034
- }
2035
- if (key.name === "down" || key.name === "j") {
2036
- const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
2037
- setLabelModal((current) => ({
2038
- ...current,
2039
- selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
2040
- }))
2041
- return
2042
- }
2043
2106
  if (isSingleLineInputKey(key)) {
2044
2107
  setLabelModal((current) => ({
2045
2108
  ...current,
2046
2109
  query: editSingleLineInput(current.query, key) ?? current.query,
2047
2110
  selectedIndex: 0,
2048
2111
  }))
2049
- return
2050
2112
  }
2051
2113
  return
2052
2114
  }
@@ -2271,20 +2333,10 @@ export const App = () => {
2271
2333
  }
2272
2334
 
2273
2335
  if (filterMode) {
2274
- if (key.name === "escape") {
2275
- setFilterDraft(filterQuery)
2276
- setFilterMode(false)
2277
- return
2278
- }
2279
- if (key.name === "return" || key.name === "enter") {
2280
- setFilterQuery(filterDraft)
2281
- setFilterMode(false)
2282
- return
2283
- }
2284
2336
  if (isSingleLineInputKey(key)) {
2285
2337
  setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
2286
- return
2287
2338
  }
2339
+ return
2288
2340
  }
2289
2341
 
2290
2342
  if (key.name === "tab") {
@@ -2292,23 +2344,10 @@ export const App = () => {
2292
2344
  return
2293
2345
  }
2294
2346
 
2295
- if (isThemeKey(key)) {
2296
- runCommandById("theme.open")
2297
- return
2298
- }
2299
-
2300
- if (key.name === "/") {
2301
- runCommandById("filter.open")
2302
- return
2303
- }
2304
2347
  if (key.name === "escape" && filterQuery.length > 0) {
2305
2348
  runCommandById("filter.clear")
2306
2349
  return
2307
2350
  }
2308
- if (key.name === "r") {
2309
- runCommandById("pull.refresh")
2310
- return
2311
- }
2312
2351
  if (isWideLayout && selectedPullRequest && !detailFullView && !diffFullView) {
2313
2352
  if (key.name === "home") {
2314
2353
  scrollDetailPreviewTo(0)
@@ -2391,38 +2430,6 @@ export const App = () => {
2391
2430
  () => setSelectedIndex(0),
2392
2431
  () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2393
2432
  )) return
2394
- if ((key.name === "return" || key.name === "enter") && !detailFullView) {
2395
- runCommandById("detail.open")
2396
- return
2397
- }
2398
- if (key.name === "d" && selectedPullRequest) {
2399
- runCommandById("diff.open")
2400
- return
2401
- }
2402
- if (key.name === "x" && selectedPullRequest?.state === "open") {
2403
- runCommandById("pull.close")
2404
- return
2405
- }
2406
- if (key.name === "l" && selectedPullRequest) {
2407
- runCommandById("pull.labels")
2408
- return
2409
- }
2410
- if (key.name === "m" || key.name === "M") {
2411
- if (selectedPullRequest) runCommandById("pull.merge")
2412
- return
2413
- }
2414
- if (key.name === "o" && selectedPullRequest) {
2415
- runCommandById("pull.open-browser")
2416
- return
2417
- }
2418
- if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
2419
- runCommandById("pull.toggle-draft")
2420
- return
2421
- }
2422
- if (key.name === "y" && selectedPullRequest) {
2423
- runCommandById("pull.copy-metadata")
2424
- return
2425
- }
2426
2433
  })
2427
2434
 
2428
2435
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
package/src/commands.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Navigation" | "System"
2
2
 
3
+ const SCOPE_ORDER: readonly CommandScope[] = ["Global", "View", "Pull request", "Diff", "Navigation", "System"]
4
+
5
+ export const sortCommandsByScope = (commands: readonly AppCommand[]) =>
6
+ [...commands].sort((left, right) => SCOPE_ORDER.indexOf(left.scope) - SCOPE_ORDER.indexOf(right.scope))
7
+
3
8
  export interface AppCommand {
4
9
  readonly id: string
5
10
  readonly title: string
package/src/index.tsx CHANGED
@@ -3,6 +3,8 @@
3
3
  import { addDefaultParsers, createCliRenderer, createTerminalPalette } from "@opentui/core"
4
4
  import { RegistryProvider } from "@effect/atom-react"
5
5
  import { createRoot } from "@opentui/react"
6
+ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
7
+ import { KeymapProvider } from "@opentui/keymap/react"
6
8
 
7
9
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
8
10
 
@@ -43,8 +45,12 @@ const renderer = await createCliRenderer({
43
45
 
44
46
  process.stdout.write(FOCUS_REPORTING_ENABLE)
45
47
 
48
+ const keymap = createDefaultOpenTuiKeymap(renderer)
49
+
46
50
  createRoot(renderer).render(
47
51
  <RegistryProvider>
48
- <App />
52
+ <KeymapProvider keymap={keymap}>
53
+ <App />
54
+ </KeymapProvider>
49
55
  </RegistryProvider>,
50
56
  )
@@ -17,6 +17,7 @@ const scopeLabels = {
17
17
 
18
18
  export type CommandPaletteRow =
19
19
  | { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
20
+ | { readonly _tag: "spacer" }
20
21
  | { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
21
22
 
22
23
  export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
@@ -25,6 +26,7 @@ export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readon
25
26
  for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
26
27
  const command = commands[commandIndex]!
27
28
  if (command.scope !== previousScope) {
29
+ if (previousScope !== null) rows.push({ _tag: "spacer" })
28
30
  rows.push({ _tag: "section", scope: command.scope })
29
31
  previousScope = command.scope
30
32
  }
@@ -112,24 +114,35 @@ export const CommandPalette = ({
112
114
  <>
113
115
  {visibleRows.map((row, index) => {
114
116
  const rowIndex = scrollTop + index
117
+ if (row._tag === "spacer") {
118
+ return <PlainLine key={`spacer-${rowIndex}`} text="" />
119
+ }
115
120
  if (row._tag === "section") {
116
- return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope]}`, rowWidth)} fg={colors.accent} bold />
121
+ return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
117
122
  }
118
123
 
119
124
  const { command, commandIndex } = row
120
125
  const isSelected = commandIndex === clampedIndex
121
126
  const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
122
- const rightWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(8, shortcut.length + 1))
127
+ const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
123
128
  const trailingPadding = shortcut.length === 0 ? 0 : 1
124
- const titleWidth = Math.max(8, rowWidth - rightWidth - trailingPadding - 2)
129
+ // Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
130
+ const SELECTOR_WIDTH = 2
131
+ const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
132
+ const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
133
+ const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
134
+ const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
135
+ const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
125
136
 
126
137
  return (
127
138
  <box key={command.id} height={1}>
128
139
  <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
129
140
  <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
130
141
  <span> </span>
131
- {isSelected ? <span attributes={TextAttributes.BOLD}>{fitCell(command.title, titleWidth)}</span> : <span>{fitCell(command.title, titleWidth)}</span>}
132
- {rightWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, rightWidth, "right")}</span> : null}
142
+ {isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
143
+ {subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
144
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
145
+ {shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
133
146
  {trailingPadding > 0 ? <span> </span> : null}
134
147
  </TextLine>
135
148
  </box>