Skip to content

Commit d4a1aa5

Browse files
authored
Merge pull request #3 from jeancdevx/feature/cli-component-architecture
feat(cli-component): Enhance CLI interface with new components and ESLint configuration
2 parents e413860 + 3346830 commit d4a1aa5

12 files changed

Lines changed: 523 additions & 6 deletions

File tree

eslint.config.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import js from '@eslint/js'
2+
import reactHooks from 'eslint-plugin-react-hooks'
23
import { defineConfig } from 'eslint/config'
34
import globals from 'globals'
45
import tseslint from 'typescript-eslint'
@@ -16,6 +17,9 @@ export default defineConfig([
1617
{
1718
files: ['packages/*/src/**/*.{ts,tsx}'],
1819
extends: [js.configs.recommended, ...tseslint.configs.recommended],
20+
plugins: {
21+
'react-hooks': reactHooks
22+
},
1923
languageOptions: {
2024
globals: globals.node,
2125
parserOptions: {
@@ -34,7 +38,9 @@ export default defineConfig([
3438
'error',
3539
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' }
3640
],
37-
'@typescript-eslint/no-non-null-assertion': 'warn'
41+
'@typescript-eslint/no-non-null-assertion': 'warn',
42+
'react-hooks/rules-of-hooks': 'error',
43+
'react-hooks/exhaustive-deps': 'warn'
3844
}
3945
}
4046
])

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "bun run --watch src/index.tsx"
88
},
99
"devDependencies": {
10-
"@types/bun": "1.3.14"
10+
"@types/bun": "1.3.14",
11+
"@types/react": "19.2.16"
1112
},
1213
"peerDependencies": {
1314
"typescript": "5"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const EmptyBorder = {
2+
topLeft: '',
3+
bottomLeft: '',
4+
vertical: '',
5+
topRight: '',
6+
bottomRight: '',
7+
horizontal: ' ',
8+
bottomT: '',
9+
topT: '',
10+
cross: '',
11+
leftT: '',
12+
rightT: ''
13+
}
14+
15+
export const SplitBorder = {
16+
border: ['left' as const, 'right' as const],
17+
customBorderChars: {
18+
...EmptyBorder,
19+
vertical: '┃'
20+
}
21+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Command } from './types'
2+
3+
export const COMMANDS: Command[] = [
4+
{
5+
name: 'new',
6+
description: 'Start a new conversation',
7+
value: '/new'
8+
},
9+
{
10+
name: 'agents',
11+
description: 'Switch agents',
12+
value: '/agents'
13+
},
14+
{
15+
name: 'models',
16+
description: 'Select AI model for generation',
17+
value: '/models'
18+
},
19+
{
20+
name: 'sessions',
21+
description: 'Browse your past conversations',
22+
value: '/sessions'
23+
},
24+
{
25+
name: 'theme',
26+
description: 'Change the color scheme',
27+
value: '/theme'
28+
},
29+
{
30+
name: 'login',
31+
description: 'Sign in to your account with your browser',
32+
value: '/login'
33+
},
34+
{
35+
name: 'logout',
36+
description: 'Sign out of your account',
37+
value: '/logout'
38+
},
39+
{
40+
name: 'upgrade',
41+
description: 'Buy more credits',
42+
value: '/upgrade'
43+
},
44+
{
45+
name: 'usage',
46+
description: 'Open billing portal in your browser',
47+
value: '/usage'
48+
},
49+
{
50+
name: 'exit',
51+
description: 'Exit the application',
52+
value: '/exit',
53+
action: ctx => {
54+
ctx.exit()
55+
}
56+
}
57+
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { COMMANDS } from './commands'
2+
import type { Command } from './types'
3+
4+
export const getFilteredCommands = (query: string): Command[] => {
5+
if (query.trim().length === 0) return COMMANDS
6+
7+
return COMMANDS.filter(cmd =>
8+
cmd.name.toLowerCase().startsWith(query.toLowerCase())
9+
)
10+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { TextAttributes, type ScrollBoxRenderable } from '@opentui/core'
2+
import type { RefObject } from 'react'
3+
4+
import { COMMANDS } from './commands'
5+
import { getFilteredCommands } from './filter-commands'
6+
7+
type CommandMenuProps = {
8+
query: string
9+
selectedIndex: number
10+
scrollRef: RefObject<ScrollBoxRenderable | null>
11+
onSelect: (index: number) => void
12+
onExecute: (index: number) => void
13+
}
14+
15+
const MAX_VISIBLE_COMMANDS = 8
16+
17+
const COMMAND_COL_WIDTH = Math.max(...COMMANDS.map(cmd => cmd.name.length)) + 4
18+
19+
const CommandMenu = ({
20+
query,
21+
selectedIndex,
22+
scrollRef,
23+
onSelect,
24+
onExecute
25+
}: CommandMenuProps) => {
26+
const filtered = getFilteredCommands(query)
27+
const visibleHeight = Math.min(filtered.length, MAX_VISIBLE_COMMANDS)
28+
29+
if (filtered.length === 0) {
30+
return (
31+
<box paddingX={1} height={1}>
32+
<text attributes={TextAttributes.DIM}>No matching commands</text>
33+
</box>
34+
)
35+
}
36+
37+
return (
38+
<scrollbox ref={scrollRef} height={visibleHeight}>
39+
{filtered.map((cmd, index) => {
40+
const isSelected = index === selectedIndex
41+
42+
return (
43+
<box
44+
key={cmd.name}
45+
flexDirection='row'
46+
paddingX={1}
47+
height={1}
48+
overflow='hidden'
49+
backgroundColor={isSelected ? '#e60076' : undefined}
50+
onMouseMove={() => onSelect(index)}
51+
onMouseDown={() => onExecute(index)}
52+
>
53+
<box width={COMMAND_COL_WIDTH} flexShrink={0}>
54+
<text selectable={false} fg={isSelected ? '#ffffff' : '#888888'}>
55+
/{cmd.name}
56+
</text>
57+
</box>
58+
<box flexGrow={1} flexShrink={1} overflow='hidden'>
59+
<text
60+
selectable={false}
61+
fg={isSelected ? '#eeeeee' : '#888888'}
62+
attributes={isSelected ? TextAttributes.BOLD : undefined}
63+
>
64+
{cmd.description}
65+
</text>
66+
</box>
67+
</box>
68+
)
69+
})}
70+
</scrollbox>
71+
)
72+
}
73+
74+
export { CommandMenu }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type CommandContext = {
2+
exit: () => void
3+
}
4+
5+
export type Command = {
6+
name: string
7+
description: string
8+
value: string
9+
action?: (ctx: CommandContext) => void | Promise<void>
10+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { ScrollBoxRenderable } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
3+
import { useMemo, useRef, useState, type RefObject } from 'react'
4+
5+
import { getFilteredCommands } from './filter-commands'
6+
import type { Command } from './types'
7+
8+
type UseCommandMenuReturn = {
9+
showCommandMenu: boolean
10+
commandQuery: string
11+
selectedIndex: number
12+
scrollRef: RefObject<ScrollBoxRenderable | null>
13+
handleContentChange: (text: string) => void
14+
resolveCommand: (index: number) => Command | undefined
15+
setSelectedIndex: (index: number) => void
16+
}
17+
18+
const useCommandMenu = (): UseCommandMenuReturn => {
19+
const [textValue, setTextValue] = useState('')
20+
const [selectedIndex, setSelectedIndex] = useState(0)
21+
const [showCommandMenu, setShowCommandMenu] = useState(false)
22+
23+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
24+
25+
const commandQuery =
26+
showCommandMenu && textValue.startsWith('/') ? textValue.slice(1) : ''
27+
28+
const filteredCommands = useMemo(
29+
() => getFilteredCommands(commandQuery),
30+
[commandQuery]
31+
)
32+
33+
const handleContentChange = (text: string) => {
34+
setTextValue(text)
35+
setSelectedIndex(0)
36+
37+
// jump back to top of list when the user types a new caracter
38+
const scrollBox = scrollRef.current
39+
if (scrollBox) scrollBox.scrollTo(0)
40+
41+
const prefixMatch = text.startsWith('/') ? text.slice(1) : null
42+
if (prefixMatch !== null && !prefixMatch.includes(' ')) {
43+
setShowCommandMenu(true)
44+
} else {
45+
setShowCommandMenu(false)
46+
}
47+
}
48+
49+
// resolve a command at a specific index (returns the command, caller, handles execution)
50+
const resolveCommand = (index: number): Command | undefined => {
51+
const command = filteredCommands[index]
52+
53+
if (command) setShowCommandMenu(false)
54+
55+
return command
56+
}
57+
58+
// arrow keys move selection; the list follows along when the highlighted item goes out of view
59+
useKeyboard(key => {
60+
if (!showCommandMenu) return
61+
62+
if (key.name === 'scape') {
63+
key.preventDefault()
64+
setShowCommandMenu(false)
65+
} else if (key.name === 'up') {
66+
key.preventDefault()
67+
setSelectedIndex(i => {
68+
const newIndex = Math.max(0, i - 1)
69+
70+
// keep the highlighted item visible when arrowing past the edge
71+
const sb = scrollRef.current
72+
if (sb && newIndex < sb.scrollTop) {
73+
sb.scrollTo(newIndex)
74+
}
75+
76+
return newIndex
77+
})
78+
} else if (key.name === 'down') {
79+
key.preventDefault()
80+
setSelectedIndex(i => {
81+
if (filteredCommands.length === 0) return 0
82+
83+
const newIndex = Math.min(filteredCommands.length - 1, i + 1)
84+
const sb = scrollRef.current
85+
86+
if (sb) {
87+
const viewportHeight = sb.viewport.height
88+
const visibleEnd = sb.scrollTop + viewportHeight - 1
89+
90+
if (newIndex > visibleEnd) {
91+
sb.scrollTo(newIndex - viewportHeight + 1)
92+
}
93+
}
94+
95+
return newIndex
96+
})
97+
}
98+
})
99+
100+
return {
101+
showCommandMenu,
102+
commandQuery,
103+
selectedIndex,
104+
scrollRef,
105+
handleContentChange,
106+
resolveCommand,
107+
setSelectedIndex
108+
}
109+
}
110+
111+
export { useCommandMenu }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const Header = () => {
2+
return (
3+
<box justifyContent='center' alignItems='center'>
4+
<box
5+
flexDirection='row'
6+
justifyContent='center'
7+
alignItems='center'
8+
gap={0.5}
9+
>
10+
<ascii-font font='block' color='gray' text='Night' />
11+
<ascii-font font='block' text='Code' />
12+
</box>
13+
</box>
14+
)
15+
}
16+
17+
export { Header }

0 commit comments

Comments
 (0)