Skip to content

Commit 4b7bc16

Browse files
committed
feat(cli): add project picker screen and refactor banner system
1 parent a5b1506 commit 4b7bc16

File tree

74 files changed

+3970
-539
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+3970
-539
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"readable-stream": "^4.7.0",
9494
"seedrandom": "^3.0.5",
9595
"stripe": "^16.11.0",
96+
"ts-pattern": "^5.9.0",
9697
"zod": "^4.0.0",
9798
},
9899
"devDependencies": {

cli/src/__tests__/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ The `.bin/bun` wrapper automatically checks for tmux when running integration/E2
8484
- **Skips** tests gracefully if tmux unavailable
8585

8686
**Benefits:**
87+
8788
- ✅ Project-wide (works in any package)
8889
- ✅ No hardcoded paths
8990
- ✅ Clear test categorization
@@ -165,17 +166,19 @@ await sleep(1000)
165166
## tmux Testing
166167

167168
**See [`../../tmux.knowledge.md`](../../tmux.knowledge.md) for comprehensive tmux documentation**, including:
169+
168170
- Why standard `send-keys` doesn't work (must use bracketed paste mode)
169171
- Helper functions for Bash and TypeScript
170172
- Complete example scripts
171173
- Debugging and troubleshooting tips
172174

173175
**Quick reference:**
176+
174177
```typescript
175-
// ❌ Broken:
178+
// ❌ Broken:
176179
await tmux(['send-keys', '-t', session, 'hello'])
177180

178-
// ✅ Works:
181+
// ✅ Works:
179182
await tmux(['send-keys', '-t', session, '-l', '\x1b[200~hello\x1b[201~'])
180183
```
181184

cli/src/__tests__/bash-mode.test.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ describe('bash-mode', () => {
8989
}
9090
const inputMode = 'bash' as InputMode
9191

92-
const userTypedBang = inputMode === ('default' as InputMode) && inputValue.text === '!'
92+
const userTypedBang =
93+
inputMode === ('default' as InputMode) && inputValue.text === '!'
9394

9495
if (userTypedBang) {
9596
setInputMode('bash')
@@ -231,15 +232,17 @@ describe('bash-mode', () => {
231232
const trimmedInput = 'ls -la' // The stored value WITHOUT '!'
232233

233234
// Router logic prepends '!' when in bash mode
234-
const commandWithBang = inputMode === 'bash' ? '!' + trimmedInput : trimmedInput
235+
const commandWithBang =
236+
inputMode === 'bash' ? '!' + trimmedInput : trimmedInput
235237

236238
expect(commandWithBang).toBe('!ls -la')
237239
})
238240

239241
test('submission displays "!" in user message', () => {
240242
const inputMode: InputMode = 'bash'
241243
const trimmedInput = 'pwd'
242-
const commandWithBang = inputMode === 'bash' ? '!' + trimmedInput : trimmedInput
244+
const commandWithBang =
245+
inputMode === 'bash' ? '!' + trimmedInput : trimmedInput
243246

244247
// The user message should show the command WITH '!'
245248
const userMessage = { content: commandWithBang }
@@ -291,8 +294,14 @@ describe('bash-mode', () => {
291294
describe('bash mode UI state', () => {
292295
test('input mode is stored separately from input value', () => {
293296
// The inputMode is independent of the input text
294-
const state1: { inputMode: InputMode; inputValue: string } = { inputMode: 'bash', inputValue: 'ls' }
295-
const state2: { inputMode: InputMode; inputValue: string } = { inputMode: 'default', inputValue: 'hello' }
297+
const state1: { inputMode: InputMode; inputValue: string } = {
298+
inputMode: 'bash',
299+
inputValue: 'ls',
300+
}
301+
const state2: { inputMode: InputMode; inputValue: string } = {
302+
inputMode: 'default',
303+
inputValue: 'hello',
304+
}
296305

297306
expect(state1.inputMode).toBe('bash')
298307
expect(state1.inputValue).not.toContain('!')
@@ -317,7 +326,9 @@ describe('bash-mode', () => {
317326
const inputModeValue = 'default' as InputMode
318327

319328
const adjustedInputWidth =
320-
inputModeValue === ('bash' as InputMode) ? baseInputWidth - 2 : baseInputWidth
329+
inputModeValue === ('bash' as InputMode)
330+
? baseInputWidth - 2
331+
: baseInputWidth
321332

322333
expect(adjustedInputWidth).toBe(100)
323334
})
@@ -339,7 +350,9 @@ describe('bash-mode', () => {
339350
const inputMode = 'default' as InputMode
340351

341352
const effectivePlaceholder =
342-
inputMode === ('bash' as InputMode) ? bashPlaceholder : normalPlaceholder
353+
inputMode === ('bash' as InputMode)
354+
? bashPlaceholder
355+
: normalPlaceholder
343356

344357
expect(effectivePlaceholder).toBe('Ask Buffy anything...')
345358
})

cli/src/__tests__/e2e-cli.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import path from 'path'
44
import { describe, test, expect } from 'bun:test'
55
import stripAnsi from 'strip-ansi'
66

7-
87
import { isSDKBuilt, ensureCliTestEnv } from './test-utils'
98

109
const CLI_PATH = path.join(__dirname, '../index.tsx')

cli/src/__tests__/e2e/logout-relogin-flow.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ describe('Logout and Re-login helpers', () => {
6565
}
6666

6767
const mockLogoutApi = () => {
68-
const apiModule = require('../../utils/codebuff-api') as typeof CodebuffApiModule
68+
const apiModule =
69+
require('../../utils/codebuff-api') as typeof CodebuffApiModule
6970
spyOn(apiModule, 'getApiClient').mockReturnValue({
7071
logout: async () => ({ ok: true, status: 200 }),
7172
} as any)

cli/src/__tests__/e2e/returning-user-auth.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,8 @@ import {
1313
spyOn,
1414
} from 'bun:test'
1515

16-
1716
import { validateApiKey } from '../../hooks/use-auth-query'
18-
import {
19-
getAuthTokenDetails,
20-
saveUserCredentials,
21-
} from '../../utils/auth'
17+
import { getAuthTokenDetails, saveUserCredentials } from '../../utils/auth'
2218

2319
import type * as AuthModule from '../../utils/auth'
2420
import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database'

cli/src/__tests__/helpers/mock-api-client.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,28 @@ export const createMockApiClient = (
3737
overrides: MockApiClientOverrides = {},
3838
): CodebuffApiClient => ({
3939
get: (overrides.get ?? mock(defaultOkResponse)) as CodebuffApiClient['get'],
40-
post: (overrides.post ?? mock(defaultOkResponse)) as CodebuffApiClient['post'],
40+
post: (overrides.post ??
41+
mock(defaultOkResponse)) as CodebuffApiClient['post'],
4142
put: (overrides.put ?? mock(defaultOkResponse)) as CodebuffApiClient['put'],
42-
patch: (overrides.patch ?? mock(defaultOkResponse)) as CodebuffApiClient['patch'],
43-
delete: (overrides.delete ?? mock(defaultOkResponse)) as CodebuffApiClient['delete'],
44-
request: (overrides.request ?? mock(defaultOkResponse)) as CodebuffApiClient['request'],
43+
patch: (overrides.patch ??
44+
mock(defaultOkResponse)) as CodebuffApiClient['patch'],
45+
delete: (overrides.delete ??
46+
mock(defaultOkResponse)) as CodebuffApiClient['delete'],
47+
request: (overrides.request ??
48+
mock(defaultOkResponse)) as CodebuffApiClient['request'],
4549
me: (overrides.me ?? mock(defaultOkResponse)) as CodebuffApiClient['me'],
46-
usage: (overrides.usage ?? mock(defaultOkResponse)) as CodebuffApiClient['usage'],
47-
loginCode: (overrides.loginCode ?? mock(defaultOkResponse)) as CodebuffApiClient['loginCode'],
48-
loginStatus: (overrides.loginStatus ?? mock(defaultOkResponse)) as CodebuffApiClient['loginStatus'],
49-
referral: (overrides.referral ?? mock(defaultOkResponse)) as CodebuffApiClient['referral'],
50-
publish: (overrides.publish ?? mock(defaultOkResponse)) as CodebuffApiClient['publish'],
51-
logout: (overrides.logout ?? mock(defaultOkResponse)) as CodebuffApiClient['logout'],
50+
usage: (overrides.usage ??
51+
mock(defaultOkResponse)) as CodebuffApiClient['usage'],
52+
loginCode: (overrides.loginCode ??
53+
mock(defaultOkResponse)) as CodebuffApiClient['loginCode'],
54+
loginStatus: (overrides.loginStatus ??
55+
mock(defaultOkResponse)) as CodebuffApiClient['loginStatus'],
56+
referral: (overrides.referral ??
57+
mock(defaultOkResponse)) as CodebuffApiClient['referral'],
58+
publish: (overrides.publish ??
59+
mock(defaultOkResponse)) as CodebuffApiClient['publish'],
60+
logout: (overrides.logout ??
61+
mock(defaultOkResponse)) as CodebuffApiClient['logout'],
5262
baseUrl: overrides.baseUrl ?? 'https://test.codebuff.com',
5363
authToken: overrides.authToken,
5464
})
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { describe, test, expect, beforeEach } from 'bun:test'
2+
3+
import { useChatStore } from '../state/chat-store'
4+
import { getInputModeConfig } from '../utils/input-modes'
5+
6+
/**
7+
* Tests for home directory detection and banner behavior
8+
*
9+
* The home directory detection works as follows:
10+
* 1. When fileTreeLoaded becomes true and fileTree.length === 0
11+
* 2. setActiveTopBanner('homeDir') is called to show top banner
12+
* 3. setInputMode('homeDir') is called to show bottom banner
13+
*
14+
* Closing behavior:
15+
* - Closing the top banner calls closeTopBanner() and resets inputMode to 'default'
16+
* - Closing the bottom banner also calls closeTopBanner() and resets inputMode
17+
*/
18+
describe('Home Directory Detection', () => {
19+
beforeEach(() => {
20+
useChatStore.getState().reset()
21+
})
22+
23+
describe('banner state management', () => {
24+
test('should set activeTopBanner to homeDir when home directory detected', () => {
25+
const store = useChatStore.getState()
26+
27+
// Simulate home directory detection
28+
store.setActiveTopBanner('homeDir')
29+
30+
expect(useChatStore.getState().activeTopBanner).toBe('homeDir')
31+
})
32+
33+
test('should set inputMode to homeDir when home directory detected', () => {
34+
const store = useChatStore.getState()
35+
36+
// Simulate home directory detection
37+
store.setInputMode('homeDir')
38+
39+
expect(useChatStore.getState().inputMode).toBe('homeDir')
40+
})
41+
42+
test('should set both top banner and input mode when home directory detected', () => {
43+
const store = useChatStore.getState()
44+
45+
// Simulate the full home directory detection logic
46+
store.setActiveTopBanner('homeDir')
47+
store.setInputMode('homeDir')
48+
49+
const state = useChatStore.getState()
50+
expect(state.activeTopBanner).toBe('homeDir')
51+
expect(state.inputMode).toBe('homeDir')
52+
})
53+
54+
test('closeTopBanner should set activeTopBanner to null', () => {
55+
const store = useChatStore.getState()
56+
57+
store.setActiveTopBanner('homeDir')
58+
expect(useChatStore.getState().activeTopBanner).toBe('homeDir')
59+
60+
store.closeTopBanner()
61+
expect(useChatStore.getState().activeTopBanner).toBeNull()
62+
})
63+
})
64+
65+
describe('detection conditions', () => {
66+
test('should only trigger when fileTreeLoaded is true AND fileTree is empty', () => {
67+
// Simulate the detection logic from chat.tsx
68+
const simulateDetection = (
69+
fileTreeLoaded: boolean,
70+
fileTreeLength: number,
71+
) => {
72+
if (fileTreeLoaded && fileTreeLength === 0) {
73+
useChatStore.getState().setActiveTopBanner('homeDir')
74+
useChatStore.getState().setInputMode('homeDir')
75+
return true
76+
}
77+
return false
78+
}
79+
80+
// Reset state
81+
useChatStore.getState().reset()
82+
83+
// Case 1: fileTreeLoaded=false, empty array - should NOT trigger
84+
expect(simulateDetection(false, 0)).toBe(false)
85+
expect(useChatStore.getState().activeTopBanner).toBeNull()
86+
87+
// Reset state
88+
useChatStore.getState().reset()
89+
90+
// Case 2: fileTreeLoaded=true, non-empty array - should NOT trigger
91+
expect(simulateDetection(true, 5)).toBe(false)
92+
expect(useChatStore.getState().activeTopBanner).toBeNull()
93+
94+
// Reset state
95+
useChatStore.getState().reset()
96+
97+
// Case 3: fileTreeLoaded=true, empty array - SHOULD trigger
98+
expect(simulateDetection(true, 0)).toBe(true)
99+
expect(useChatStore.getState().activeTopBanner).toBe('homeDir')
100+
expect(useChatStore.getState().inputMode).toBe('homeDir')
101+
})
102+
103+
test('should not trigger for non-empty directories', () => {
104+
const fileTreeLoaded = true
105+
const fileTreeLength: number = 10
106+
107+
if (fileTreeLoaded && fileTreeLength === 0) {
108+
useChatStore.getState().setActiveTopBanner('homeDir')
109+
}
110+
111+
expect(useChatStore.getState().activeTopBanner).toBeNull()
112+
})
113+
})
114+
115+
describe('closing behavior', () => {
116+
test('closing top banner should reset both banner and input mode', () => {
117+
const store = useChatStore.getState()
118+
119+
// Set up home directory banner state
120+
store.setActiveTopBanner('homeDir')
121+
store.setInputMode('homeDir')
122+
123+
// Simulate closing top banner (as done in TopBanner component)
124+
store.closeTopBanner()
125+
// The TopBanner component also resets input mode if it's 'homeDir'
126+
if (useChatStore.getState().inputMode === 'homeDir') {
127+
store.setInputMode('default')
128+
}
129+
130+
const state = useChatStore.getState()
131+
expect(state.activeTopBanner).toBeNull()
132+
expect(state.inputMode).toBe('default')
133+
})
134+
135+
test('closing bottom banner should also close top banner', () => {
136+
const store = useChatStore.getState()
137+
138+
// Set up home directory banner state
139+
store.setActiveTopBanner('homeDir')
140+
store.setInputMode('homeDir')
141+
142+
// Simulate closing bottom banner (as done in HomeDirBanner component)
143+
store.setInputMode('default')
144+
store.closeTopBanner()
145+
146+
const state = useChatStore.getState()
147+
expect(state.activeTopBanner).toBeNull()
148+
expect(state.inputMode).toBe('default')
149+
})
150+
})
151+
152+
describe('input mode configuration', () => {
153+
test('homeDir mode should have correct configuration', () => {
154+
const config = getInputModeConfig('homeDir')
155+
156+
expect(config.icon).toBeNull()
157+
expect(config.color).toBe('warning')
158+
expect(config.showAgentModeToggle).toBe(true)
159+
expect(config.disableSlashSuggestions).toBe(false)
160+
})
161+
162+
test('homeDir mode should allow normal input behavior', () => {
163+
const config = getInputModeConfig('homeDir')
164+
165+
// Users should still be able to:
166+
// - Type normal prompts (showAgentModeToggle: true)
167+
// - Use slash commands (disableSlashSuggestions: false)
168+
expect(config.showAgentModeToggle).toBe(true)
169+
expect(config.disableSlashSuggestions).toBe(false)
170+
})
171+
172+
test('homeDir mode should have a placeholder', () => {
173+
const config = getInputModeConfig('homeDir')
174+
175+
expect(config.placeholder).toBeDefined()
176+
expect(config.placeholder.length).toBeGreaterThan(0)
177+
})
178+
})
179+
180+
describe('TopBannerType state', () => {
181+
test('should support homeDir banner type', () => {
182+
const store = useChatStore.getState()
183+
184+
store.setActiveTopBanner('homeDir')
185+
186+
expect(useChatStore.getState().activeTopBanner).toBe('homeDir')
187+
})
188+
189+
test('should support null (no banner)', () => {
190+
const store = useChatStore.getState()
191+
192+
store.setActiveTopBanner('homeDir')
193+
store.setActiveTopBanner(null)
194+
195+
expect(useChatStore.getState().activeTopBanner).toBeNull()
196+
})
197+
})
198+
199+
describe('reset behavior', () => {
200+
test('reset should clear home directory banner state', () => {
201+
const store = useChatStore.getState()
202+
203+
store.setActiveTopBanner('homeDir')
204+
store.setInputMode('homeDir')
205+
206+
store.reset()
207+
208+
const state = useChatStore.getState()
209+
expect(state.activeTopBanner).toBeNull()
210+
expect(state.inputMode).toBe('default')
211+
})
212+
})
213+
})

cli/src/__tests__/integration-tmux.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import path from 'path'
44
import { describe, test, expect, beforeAll } from 'bun:test'
55
import stripAnsi from 'strip-ansi'
66

7-
87
import {
98
isTmuxAvailable,
109
isSDKBuilt,

0 commit comments

Comments
 (0)