chenbhao commited on
Commit
50b01e4
·
1 Parent(s): 38d7632

fix: /model's search

Browse files
src/commands/model/model.tsx CHANGED
@@ -2,7 +2,7 @@ import { c as _c } from "react/compiler-runtime";
2
  import chalk from 'chalk';
3
  import * as React from 'react';
4
  import type { CommandResultDisplay } from '../../commands.js';
5
- import { ModelPicker } from '../../components/ModelPicker.js';
6
  import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js';
7
  import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
8
  import { useAppState, useSetAppState } from '../../state/AppState.js';
@@ -100,7 +100,7 @@ function ModelPickerWrapper(t0) {
100
  }
101
  let t4;
102
  if ($[11] !== handleCancel || $[12] !== handleSelect || $[13] !== mainLoopModel || $[14] !== mainLoopModelForSession || $[15] !== t3) {
103
- t4 = <ModelPicker initial={mainLoopModel} sessionModel={mainLoopModelForSession} onSelect={handleSelect} onCancel={handleCancel} isStandaloneCommand={true} showFastModeNotice={t3} />;
104
  $[11] = handleCancel;
105
  $[12] = handleSelect;
106
  $[13] = mainLoopModel;
 
2
  import chalk from 'chalk';
3
  import * as React from 'react';
4
  import type { CommandResultDisplay } from '../../commands.js';
5
+ import { SearchableModelPicker } from '../../components/SearchableModelPicker.js';
6
  import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js';
7
  import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
8
  import { useAppState, useSetAppState } from '../../state/AppState.js';
 
100
  }
101
  let t4;
102
  if ($[11] !== handleCancel || $[12] !== handleSelect || $[13] !== mainLoopModel || $[14] !== mainLoopModelForSession || $[15] !== t3) {
103
+ t4 = <SearchableModelPicker initial={mainLoopModel} sessionModel={mainLoopModelForSession} onSelect={handleSelect} onCancel={handleCancel} isStandaloneCommand={true} showFastModeNotice={t3} />;
104
  $[11] = handleCancel;
105
  $[12] = handleSelect;
106
  $[13] = mainLoopModel;
src/components/SearchableModelPicker.tsx ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react'
2
+ import { useCallback, useMemo, useState } from 'react'
3
+ import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'
4
+ import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'
5
+ import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled } from 'src/utils/fastMode.js'
6
+ import { Box, Text } from '../ink.js'
7
+ import { useKeybindings } from '../keybindings/useKeybinding.js'
8
+ import { useAppState, useSetAppState } from '../state/AppState.js'
9
+ import { convertEffortValueToLevel, type EffortLevel, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js'
10
+ import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js'
11
+ import { getModelOptions } from '../utils/model/modelOptions.js'
12
+ import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'
13
+ import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
14
+ import { Byline } from './design-system/Byline.js'
15
+ import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'
16
+ import { Pane } from './design-system/Pane.js'
17
+ import { effortLevelToSymbol } from './EffortIndicator.js'
18
+ import { FuzzyPicker } from './design-system/FuzzyPicker.js'
19
+ import capitalize from 'lodash-es/capitalize.js'
20
+
21
+ export type Props = {
22
+ initial: string | null
23
+ sessionModel?: ModelSetting
24
+ onSelect: (model: string | null, effort: EffortLevel | undefined) => void
25
+ onCancel?: () => void
26
+ isStandaloneCommand?: boolean
27
+ showFastModeNotice?: boolean
28
+ headerText?: string
29
+ skipSettingsWrite?: boolean
30
+ }
31
+
32
+ const NO_PREFERENCE = '__NO_PREFERENCE__'
33
+
34
+ export function SearchableModelPicker({
35
+ initial,
36
+ sessionModel,
37
+ onSelect,
38
+ onCancel,
39
+ isStandaloneCommand,
40
+ showFastModeNotice,
41
+ headerText,
42
+ skipSettingsWrite,
43
+ }: Props) {
44
+ const setAppState = useSetAppState()
45
+ const exitState = useExitOnCtrlCDWithKeybindings()
46
+ const initialValue = initial === null ? NO_PREFERENCE : initial
47
+ const [focusedModel, setFocusedModel] = useState<string | undefined>(initialValue)
48
+
49
+ const isFastMode = useAppState(s => isFastModeEnabled() ? s.fastMode : false)
50
+ const [hasToggledEffort, setHasToggledEffort] = useState(false)
51
+ const effortValue = useAppState(s => s.effortValue)
52
+ const [effort, setEffort] = useState<EffortLevel | undefined>(
53
+ effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined
54
+ )
55
+
56
+ const [searchQuery, setSearchQuery] = useState('')
57
+
58
+ const modelOptions = React.useMemo(() => {
59
+ try {
60
+ return getModelOptions(isFastMode ?? false)
61
+ } catch (error) {
62
+ console.error('Error getting model options:', error)
63
+ return []
64
+ }
65
+ }, [isFastMode])
66
+
67
+ // Filter options based on search query
68
+ const filteredOptions = React.useMemo(() => {
69
+ if (!Array.isArray(modelOptions)) {
70
+ return []
71
+ }
72
+ if (!searchQuery.trim()) {
73
+ return modelOptions
74
+ }
75
+ const query = searchQuery.toLowerCase()
76
+ return modelOptions.filter(opt => {
77
+ const label = typeof opt.label === 'string' ? opt.label.toLowerCase() : ''
78
+ const description = typeof opt.description === 'string' ? opt.description.toLowerCase() : ''
79
+ const value = typeof opt.value === 'string' ? opt.value.toLowerCase() : ''
80
+ return label.includes(query) || description.includes(query) || value.includes(query)
81
+ })
82
+ }, [modelOptions, searchQuery])
83
+
84
+ // Add initial model if not in filtered options
85
+ const optionsWithInitial = React.useMemo(() => {
86
+ if (!Array.isArray(filteredOptions)) {
87
+ return []
88
+ }
89
+ if (initial !== null && !filteredOptions.some(opt => opt.value === initial)) {
90
+ try {
91
+ const displayModel = modelDisplayString(initial)
92
+ const customOption = {
93
+ value: initial,
94
+ label: displayModel,
95
+ description: 'Current model',
96
+ }
97
+ return [...filteredOptions, customOption]
98
+ } catch (error) {
99
+ console.error('Error displaying initial model:', error)
100
+ return filteredOptions
101
+ }
102
+ }
103
+ return filteredOptions
104
+ }, [filteredOptions, initial])
105
+
106
+ const initialFocusValue = Array.isArray(optionsWithInitial) && optionsWithInitial.some(opt => opt.value === initialValue)
107
+ ? initialValue
108
+ : (Array.isArray(optionsWithInitial) && optionsWithInitial[0]?.value) ?? undefined
109
+
110
+ // Helper functions
111
+ function resolveOptionModel(value?: string): string | undefined {
112
+ if (!value) return undefined
113
+ return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value)
114
+ }
115
+
116
+ function getDefaultEffortLevelForOption(value?: string): EffortLevel {
117
+ const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel()
118
+ const defaultValue = getDefaultEffortForModel(resolved)
119
+ return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high'
120
+ }
121
+
122
+ function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel {
123
+ const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']
124
+ const idx = levels.indexOf(current)
125
+ const currentIndex = idx !== -1 ? idx : levels.indexOf('high')
126
+ if (direction === 'right') {
127
+ return levels[(currentIndex + 1) % levels.length]!
128
+ } else {
129
+ return levels[(currentIndex - 1 + levels.length) % levels.length]!
130
+ }
131
+ }
132
+
133
+ // Handle effort cycling
134
+ const handleCycleEffort = useCallback((direction: 'left' | 'right') => {
135
+ const resolvedModel = resolveOptionModel(focusedModel)
136
+ if (!resolvedModel || !modelSupportsEffort(resolvedModel)) {
137
+ return
138
+ }
139
+ const includeMax = modelSupportsMaxEffort(resolvedModel)
140
+ setEffort(prev => cycleEffortLevel(prev ?? getDefaultEffortLevelForOption(focusedModel), direction, includeMax))
141
+ setHasToggledEffort(true)
142
+ }, [focusedModel])
143
+
144
+ useKeybindings({
145
+ 'modelPicker:decreaseEffort': () => handleCycleEffort('left'),
146
+ 'modelPicker:increaseEffort': () => handleCycleEffort('right'),
147
+ }, { context: 'ModelPicker' })
148
+
149
+ // Handle model selection
150
+ const handleSelect = useCallback((option: { value: string | null; label: string; description: string }) => {
151
+ logEvent('tengu_model_command_menu_effort', {
152
+ effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
153
+ })
154
+
155
+ if (!skipSettingsWrite) {
156
+ const effortLevel = resolvePickerEffortPersistence(
157
+ effort,
158
+ getDefaultEffortLevelForOption(option.value ?? undefined),
159
+ getSettingsForSource('userSettings')?.effortLevel,
160
+ hasToggledEffort
161
+ )
162
+ const persistable = toPersistableEffort(effortLevel)
163
+ if (persistable !== undefined) {
164
+ updateSettingsForSource('userSettings', { effortLevel: persistable })
165
+ }
166
+ setAppState(prev => ({ ...prev, effortValue: effortLevel }))
167
+ }
168
+
169
+ const selectedModel = resolveOptionModel(option.value ?? undefined)
170
+ const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel)
171
+ ? effort
172
+ : undefined
173
+
174
+ if (option.value === null) {
175
+ onSelect(null, selectedEffort)
176
+ return
177
+ }
178
+ onSelect(option.value, selectedEffort)
179
+ }, [effort, hasToggledEffort, onSelect, setAppState, skipSettingsWrite])
180
+
181
+ // Render model item
182
+ const renderItem = useCallback((option: { value: string | null; label: string; description: string }, isFocused: boolean) => {
183
+ return (
184
+ <Box flexDirection="column">
185
+ <Text bold={isFocused}>{option.label}</Text>
186
+ <Text dimColor>{option.description}</Text>
187
+ </Box>
188
+ )
189
+ }, [])
190
+
191
+ // Render preview with effort
192
+ const renderPreview = useCallback((option: { value: string | null; label: string; description: string }) => {
193
+ const resolvedModel = resolveOptionModel(option.value ?? undefined)
194
+ const supportsEffort = resolvedModel ? modelSupportsEffort(resolvedModel) : false
195
+ const supportsMax = resolvedModel ? modelSupportsMaxEffort(resolvedModel) : false
196
+ const defaultEffort = getDefaultEffortLevelForOption(option.value ?? undefined)
197
+ const displayEffort = effort === 'max' && !supportsMax ? 'high' : effort
198
+
199
+ if (!supportsEffort) {
200
+ return (
201
+ <Box flexDirection="column" gap={1}>
202
+ <Text dimColor>Effort not supported</Text>
203
+ </Box>
204
+ )
205
+ }
206
+
207
+ return (
208
+ <Box flexDirection="column" gap={1}>
209
+ <Text dimColor>
210
+ {capitalize(displayEffort)} effort
211
+ {displayEffort === defaultEffort ? ' (default)' : ''}
212
+ </Text>
213
+ <Text color="subtle">← → to adjust</Text>
214
+ </Box>
215
+ )
216
+ }, [effort])
217
+
218
+ // Handle focus change
219
+ const handleFocus = useCallback((option: { value: string | null; label: string; description: string } | undefined) => {
220
+ setFocusedModel(option?.value ?? undefined)
221
+ if (!hasToggledEffort && effortValue === undefined) {
222
+ setEffort(getDefaultEffortLevelForOption(option?.value ?? undefined))
223
+ }
224
+ }, [hasToggledEffort, effortValue])
225
+
226
+ // Handle query change
227
+ const handleQueryChange = useCallback((query: string) => {
228
+ setSearchQuery(query)
229
+ setFocusedModel(undefined)
230
+ }, [])
231
+
232
+ // Fast mode notice
233
+ let fastModeNotice: React.ReactNode = null
234
+ if (isFastModeEnabled()) {
235
+ if (showFastModeNotice) {
236
+ fastModeNotice = (
237
+ <Text dimColor>
238
+ Fast mode is <Text bold>ON</Text> and available with{' '}
239
+ {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other models turn off fast mode.
240
+ </Text>
241
+ )
242
+ } else if (isFastModeAvailable() && !isFastModeCooldown()) {
243
+ fastModeNotice = (
244
+ <Text dimColor>
245
+ Use <Text bold>/fast</Text> to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only).
246
+ </Text>
247
+ )
248
+ }
249
+ }
250
+
251
+ // Match label
252
+ const matchLabel = searchQuery.trim()
253
+ ? `${optionsWithInitial.length} ${optionsWithInitial.length === 1 ? 'match' : 'matches'}`
254
+ : undefined
255
+
256
+ const content = (
257
+ <FuzzyPicker
258
+ title="Select model"
259
+ placeholder="Search models..."
260
+ items={optionsWithInitial}
261
+ getKey={opt => opt.value ?? 'no-preference'}
262
+ renderItem={renderItem}
263
+ renderPreview={renderPreview}
264
+ initialQuery={searchQuery}
265
+ onQueryChange={handleQueryChange}
266
+ onSelect={handleSelect}
267
+ onFocus={handleFocus}
268
+ onCancel={onCancel ?? (() => {
269
+ logEvent('tengu_model_command_menu', {
270
+ action: 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
271
+ })
272
+ })}
273
+ emptyMessage="No models match your search"
274
+ matchLabel={matchLabel}
275
+ selectAction="select"
276
+ extraHints={
277
+ isStandaloneCommand && (
278
+ <>
279
+ {fastModeNotice && (
280
+ <>
281
+ <Text dimColor> · </Text>
282
+ {fastModeNotice}
283
+ </>
284
+ )}
285
+ </>
286
+ )
287
+ }
288
+ />
289
+ )
290
+
291
+ if (!isStandaloneCommand) {
292
+ return content
293
+ }
294
+
295
+ return <Pane color="permission">{content}</Pane>
296
+ }