saeeol 1.2.1 → 1.2.3

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.
Files changed (151) hide show
  1. package/bin/saeeol.cjs +187 -0
  2. package/npm/bin/saeeol +0 -0
  3. package/package.json +12 -12
  4. package/src/cli/cmd/tui/component/dialog/dialog-agent.tsx +32 -0
  5. package/src/cli/cmd/tui/component/dialog/dialog-command.tsx +190 -0
  6. package/src/cli/cmd/tui/component/dialog/dialog-console-org.tsx +103 -0
  7. package/src/cli/cmd/tui/component/dialog/dialog-go-upsell.tsx +159 -0
  8. package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +86 -0
  9. package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +238 -0
  10. package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +343 -0
  11. package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +103 -0
  12. package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +301 -0
  13. package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +35 -0
  14. package/src/cli/cmd/tui/component/dialog/dialog-skill.tsx +37 -0
  15. package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +87 -0
  16. package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +190 -0
  17. package/src/cli/cmd/tui/component/dialog/dialog-tag.tsx +44 -0
  18. package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +50 -0
  19. package/src/cli/cmd/tui/component/dialog/dialog-variant.tsx +39 -0
  20. package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +200 -0
  21. package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +81 -0
  22. package/src/cli/cmd/tui/component/dialog-agent.tsx +1 -32
  23. package/src/cli/cmd/tui/component/dialog-command.tsx +1 -190
  24. package/src/cli/cmd/tui/component/dialog-console-org.tsx +1 -103
  25. package/src/cli/cmd/tui/component/dialog-go-upsell.tsx +1 -159
  26. package/src/cli/cmd/tui/component/dialog-mcp.tsx +1 -86
  27. package/src/cli/cmd/tui/component/dialog-model.tsx +1 -238
  28. package/src/cli/cmd/tui/component/dialog-provider.tsx +1 -343
  29. package/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +1 -103
  30. package/src/cli/cmd/tui/component/dialog-session-list.tsx +1 -301
  31. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +1 -35
  32. package/src/cli/cmd/tui/component/dialog-skill.tsx +1 -37
  33. package/src/cli/cmd/tui/component/dialog-stash.tsx +1 -87
  34. package/src/cli/cmd/tui/component/dialog-status.tsx +1 -190
  35. package/src/cli/cmd/tui/component/dialog-tag.tsx +1 -44
  36. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +1 -50
  37. package/src/cli/cmd/tui/component/dialog-variant.tsx +1 -39
  38. package/src/cli/cmd/tui/component/dialog-workspace-create.tsx +1 -200
  39. package/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +1 -81
  40. package/src/cli/cmd/tui/context/app/args.tsx +15 -0
  41. package/src/cli/cmd/tui/context/app/directory.ts +15 -0
  42. package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
  43. package/src/cli/cmd/tui/context/app/editor.ts +425 -0
  44. package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
  45. package/src/cli/cmd/tui/context/app/project.tsx +109 -0
  46. package/src/cli/cmd/tui/context/app/route.tsx +67 -0
  47. package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
  48. package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
  49. package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
  50. package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
  51. package/src/cli/cmd/tui/context/args.tsx +1 -15
  52. package/src/cli/cmd/tui/context/directory.ts +1 -15
  53. package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
  54. package/src/cli/cmd/tui/context/editor.ts +1 -425
  55. package/src/cli/cmd/tui/context/event.ts +1 -45
  56. package/src/cli/cmd/tui/context/exit.tsx +1 -67
  57. package/src/cli/cmd/tui/context/helper.tsx +1 -25
  58. package/src/cli/cmd/tui/context/keybind.tsx +1 -105
  59. package/src/cli/cmd/tui/context/kv.tsx +1 -76
  60. package/src/cli/cmd/tui/context/local.tsx +1 -478
  61. package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
  62. package/src/cli/cmd/tui/context/project.tsx +1 -109
  63. package/src/cli/cmd/tui/context/prompt.tsx +1 -18
  64. package/src/cli/cmd/tui/context/route.tsx +1 -67
  65. package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
  66. package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
  67. package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
  68. package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
  69. package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
  70. package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
  71. package/src/cli/cmd/tui/context/sdk.tsx +1 -142
  72. package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
  73. package/src/cli/cmd/tui/context/sync.tsx +1 -713
  74. package/src/cli/cmd/tui/context/theme.tsx +1 -307
  75. package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
  76. package/src/tool/apply_patch.ts +1 -334
  77. package/src/tool/bash.ts +1 -656
  78. package/src/tool/core/external-directory.ts +55 -0
  79. package/src/tool/core/invalid.ts +21 -0
  80. package/src/tool/core/recall.ts +164 -0
  81. package/src/tool/core/recall.txt +12 -0
  82. package/src/tool/core/schema.ts +16 -0
  83. package/src/tool/core/tool.ts +162 -0
  84. package/src/tool/core/truncate.ts +160 -0
  85. package/src/tool/core/truncation-dir.ts +4 -0
  86. package/src/tool/diagnostics.ts +1 -20
  87. package/src/tool/edit-replacers.ts +1 -288
  88. package/src/tool/edit-utils.ts +1 -86
  89. package/src/tool/edit.ts +1 -262
  90. package/src/tool/external-directory.ts +1 -55
  91. package/src/tool/file/apply_patch.ts +334 -0
  92. package/src/tool/file/apply_patch.txt +33 -0
  93. package/src/tool/file/bash.ts +656 -0
  94. package/src/tool/file/bash.txt +119 -0
  95. package/src/tool/file/edit-replacers.ts +288 -0
  96. package/src/tool/file/edit-utils.ts +86 -0
  97. package/src/tool/file/edit.ts +262 -0
  98. package/src/tool/file/edit.txt +10 -0
  99. package/src/tool/file/read.ts +389 -0
  100. package/src/tool/file/read.txt +14 -0
  101. package/src/tool/file/write.ts +114 -0
  102. package/src/tool/file/write.txt +8 -0
  103. package/src/tool/glob.ts +1 -115
  104. package/src/tool/grep.ts +1 -151
  105. package/src/tool/integration/diagnostics.ts +20 -0
  106. package/src/tool/integration/lsp.ts +113 -0
  107. package/src/tool/integration/lsp.txt +24 -0
  108. package/src/tool/integration/mcp-exa.ts +73 -0
  109. package/src/tool/integration/package.ts +168 -0
  110. package/src/tool/integration/registry.ts +375 -0
  111. package/src/tool/invalid.ts +1 -21
  112. package/src/tool/lsp.ts +1 -113
  113. package/src/tool/mcp-exa.ts +1 -73
  114. package/src/tool/package.ts +1 -168
  115. package/src/tool/plan.ts +1 -30
  116. package/src/tool/question.ts +1 -52
  117. package/src/tool/read.ts +1 -389
  118. package/src/tool/recall.ts +1 -164
  119. package/src/tool/registry.ts +1 -375
  120. package/src/tool/schema.ts +1 -16
  121. package/src/tool/search/glob.ts +115 -0
  122. package/src/tool/search/glob.txt +6 -0
  123. package/src/tool/search/grep.ts +151 -0
  124. package/src/tool/search/grep.txt +8 -0
  125. package/src/tool/search/warpgrep.ts +107 -0
  126. package/src/tool/search/warpgrep.txt +10 -0
  127. package/src/tool/search/webfetch.ts +202 -0
  128. package/src/tool/search/webfetch.txt +13 -0
  129. package/src/tool/search/websearch.ts +71 -0
  130. package/src/tool/search/websearch.txt +14 -0
  131. package/src/tool/skill.ts +1 -91
  132. package/src/tool/task.ts +1 -197
  133. package/src/tool/todo.ts +1 -62
  134. package/src/tool/tool.ts +1 -162
  135. package/src/tool/truncate.ts +1 -160
  136. package/src/tool/truncation-dir.ts +1 -4
  137. package/src/tool/warpgrep.ts +1 -107
  138. package/src/tool/webfetch.ts +1 -202
  139. package/src/tool/websearch.ts +1 -71
  140. package/src/tool/workflow/plan-enter.txt +14 -0
  141. package/src/tool/workflow/plan-exit.txt +13 -0
  142. package/src/tool/workflow/plan.ts +30 -0
  143. package/src/tool/workflow/question.ts +52 -0
  144. package/src/tool/workflow/question.txt +11 -0
  145. package/src/tool/workflow/skill.ts +91 -0
  146. package/src/tool/workflow/skill.txt +5 -0
  147. package/src/tool/workflow/task.ts +197 -0
  148. package/src/tool/workflow/task.txt +57 -0
  149. package/src/tool/workflow/todo.ts +62 -0
  150. package/src/tool/workflow/todowrite.txt +167 -0
  151. package/src/tool/write.ts +1 -114
@@ -0,0 +1,159 @@
1
+ import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
2
+ import { useKeyboard } from "@opentui/solid"
3
+ import open from "open"
4
+ import { createSignal, onCleanup, onMount } from "solid-js"
5
+ import { selectedForeground, useTheme } from "@tui/context/theme"
6
+ import { useDialog, type DialogContext } from "@tui/ui/dialog"
7
+ import { Link } from "@tui/ui/link"
8
+ import { GoLogo } from "../logo"
9
+ import { BgPulse, type BgPulseMask } from "../bg-pulse"
10
+
11
+ const GO_URL = "https://saeeol.ai/go"
12
+ const PAD_X = 3
13
+ const PAD_TOP_OUTER = 1
14
+
15
+ export type DialogGoUpsellProps = {
16
+ onClose?: (dontShowAgain?: boolean) => void
17
+ }
18
+
19
+ function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
20
+ open(GO_URL).catch(() => {})
21
+ props.onClose?.()
22
+ dialog.clear()
23
+ }
24
+
25
+ function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
26
+ props.onClose?.(true)
27
+ dialog.clear()
28
+ }
29
+
30
+ export function DialogGoUpsell(props: DialogGoUpsellProps) {
31
+ const dialog = useDialog()
32
+ const { theme } = useTheme()
33
+ const fg = selectedForeground(theme)
34
+ const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
35
+ const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
36
+ const [masks, setMasks] = createSignal<BgPulseMask[]>([])
37
+ let content: BoxRenderable | undefined
38
+ let logoBox: BoxRenderable | undefined
39
+ let headingBox: BoxRenderable | undefined
40
+ let descBox: BoxRenderable | undefined
41
+ let buttonsBox: BoxRenderable | undefined
42
+
43
+ const sync = () => {
44
+ if (!content || !logoBox) return
45
+ setCenter({
46
+ x: logoBox.x - content.x + logoBox.width / 2,
47
+ y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
48
+ })
49
+ const next: BgPulseMask[] = []
50
+ const baseY = PAD_TOP_OUTER
51
+ for (const b of [headingBox, descBox, buttonsBox]) {
52
+ if (!b) continue
53
+ next.push({
54
+ x: b.x - content.x,
55
+ y: b.y - content.y + baseY,
56
+ width: b.width,
57
+ height: b.height,
58
+ pad: 2,
59
+ strength: 0.78,
60
+ })
61
+ }
62
+ setMasks(next)
63
+ }
64
+
65
+ onMount(() => {
66
+ sync()
67
+ for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
68
+ })
69
+
70
+ onCleanup(() => {
71
+ for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
72
+ })
73
+
74
+ useKeyboard((evt) => {
75
+ if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
76
+ setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
77
+ return
78
+ }
79
+ if (evt.name === "return") {
80
+ evt.preventDefault()
81
+ evt.stopPropagation()
82
+ if (selected() === "subscribe") subscribe(props, dialog)
83
+ else dismiss(props, dialog)
84
+ }
85
+ })
86
+
87
+ return (
88
+ <box ref={(item: BoxRenderable) => (content = item)}>
89
+ <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
90
+ <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
91
+ </box>
92
+ <box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
93
+ <box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
94
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
95
+ Free limit reached
96
+ </text>
97
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
98
+ esc
99
+ </text>
100
+ </box>
101
+ <box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
102
+ <box flexDirection="row">
103
+ <text fg={theme.textMuted}>Subscribe to </text>
104
+ <text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
105
+ Saeeol Go
106
+ </text>
107
+ <text fg={theme.textMuted}> for reliable access to the</text>
108
+ </box>
109
+ <text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
110
+ </box>
111
+ <box alignItems="center" gap={1} paddingBottom={1}>
112
+ <box ref={(item: BoxRenderable) => (logoBox = item)}>
113
+ <GoLogo />
114
+ </box>
115
+ <Link href={GO_URL} fg={theme.primary} />
116
+ </box>
117
+ <box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
118
+ <box
119
+ paddingLeft={2}
120
+ paddingRight={2}
121
+ backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
122
+ onMouseOver={() => setSelected("dismiss")}
123
+ onMouseUp={() => dismiss(props, dialog)}
124
+ >
125
+ <text
126
+ fg={selected() === "dismiss" ? fg : theme.textMuted}
127
+ attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
128
+ >
129
+ don't show again
130
+ </text>
131
+ </box>
132
+ <box
133
+ paddingLeft={2}
134
+ paddingRight={2}
135
+ backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
136
+ onMouseOver={() => setSelected("subscribe")}
137
+ onMouseUp={() => subscribe(props, dialog)}
138
+ >
139
+ <text
140
+ fg={selected() === "subscribe" ? fg : theme.text}
141
+ attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
142
+ >
143
+ subscribe
144
+ </text>
145
+ </box>
146
+ </box>
147
+ </box>
148
+ </box>
149
+ )
150
+ }
151
+
152
+ DialogGoUpsell.show = (dialog: DialogContext) => {
153
+ return new Promise<boolean>((resolve) => {
154
+ dialog.replace(
155
+ () => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
156
+ () => resolve(false),
157
+ )
158
+ })
159
+ }
@@ -0,0 +1,86 @@
1
+ import { createMemo, createSignal } from "solid-js"
2
+ import { useLocal } from "@tui/context/local"
3
+ import { useSync } from "@tui/context/sync"
4
+ import { map, pipe, entries, sortBy } from "remeda"
5
+ import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
6
+ import { useTheme } from "../../context/theme"
7
+ import { Keybind } from "@/util/keybind"
8
+ import { TextAttributes } from "@opentui/core"
9
+ import { useSDK } from "@tui/context/sdk"
10
+
11
+ function Status(props: { enabled: boolean; loading: boolean }) {
12
+ const { theme } = useTheme()
13
+ if (props.loading) {
14
+ return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
15
+ }
16
+ if (props.enabled) {
17
+ return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
18
+ }
19
+ return <span style={{ fg: theme.textMuted }}>○ Disabled</span>
20
+ }
21
+
22
+ export function DialogMcp() {
23
+ const local = useLocal()
24
+ const sync = useSync()
25
+ const sdk = useSDK()
26
+ const [, setRef] = createSignal<DialogSelectRef<unknown>>()
27
+ const [loading, setLoading] = createSignal<string | null>(null)
28
+
29
+ const options = createMemo(() => {
30
+ // Track sync data and loading state to trigger re-render when they change
31
+ const mcpData = sync.data.mcp
32
+ const loadingMcp = loading()
33
+
34
+ return pipe(
35
+ mcpData ?? {},
36
+ entries(),
37
+ sortBy(([name]) => name),
38
+ map(([name, status]) => ({
39
+ value: name,
40
+ title: name,
41
+ description: status.status === "failed" ? "failed" : status.status,
42
+ footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />,
43
+ category: undefined,
44
+ })),
45
+ )
46
+ })
47
+
48
+ const keybinds = createMemo(() => [
49
+ {
50
+ keybind: Keybind.parse("space")[0],
51
+ title: "toggle",
52
+ onTrigger: async (option: DialogSelectOption<string>) => {
53
+ // Prevent toggling while an operation is already in progress
54
+ if (loading() !== null) return
55
+
56
+ setLoading(option.value)
57
+ try {
58
+ await local.mcp.toggle(option.value)
59
+ // Refresh MCP status from server
60
+ const status = await sdk.client.mcp.status()
61
+ if (status.data) {
62
+ sync.set("mcp", status.data)
63
+ } else {
64
+ console.error("Failed to refresh MCP status: no data returned")
65
+ }
66
+ } catch (error) {
67
+ console.error("Failed to toggle MCP:", error)
68
+ } finally {
69
+ setLoading(null)
70
+ }
71
+ },
72
+ },
73
+ ])
74
+
75
+ return (
76
+ <DialogSelect
77
+ ref={setRef}
78
+ title="MCPs"
79
+ options={options()}
80
+ keybind={keybinds()}
81
+ onSelect={(_option) => {
82
+ // Don't close on select, only on escape
83
+ }}
84
+ />
85
+ )
86
+ }
@@ -0,0 +1,238 @@
1
+ import { useTerminalDimensions } from "@opentui/solid"
2
+ import { createEffect, createMemo, createSignal, Show } from "solid-js"
3
+ import { useLocal } from "@tui/context/local"
4
+ import { useSync } from "@tui/context/sync"
5
+ import { map, pipe, flatMap, entries, filter, sortBy, take, groupBy } from "remeda"
6
+ import { DialogSelect } from "@tui/ui/dialog-select"
7
+ import { useDialog } from "@tui/ui/dialog"
8
+ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
9
+ import { DialogVariant } from "./dialog-variant"
10
+ import { useKeybind } from "../../context/keybind"
11
+ import type { Model } from "@saeeol/sdk/v2"
12
+ import * as fuzzysort from "fuzzysort"
13
+ import { useConnected } from "../use-connected"
14
+ import { ModelInfoPanel } from "@/saeeol/components/model-info-panel"
15
+ import { t } from "@/util/i18n"
16
+
17
+ export function DialogModel(props: { providerID?: string }) {
18
+ const local = useLocal()
19
+ const sync = useSync()
20
+ const dialog = useDialog()
21
+ const keybind = useKeybind()
22
+ const [query, setQuery] = createSignal("")
23
+ const dimensions = useTerminalDimensions()
24
+
25
+ const connected = useConnected()
26
+ const providers = createDialogProviderOptions()
27
+ // Memoize anything that iterates all Saeeol models to avoid calculating it for
28
+ // each Saeeol model and tanking the UI at a couple hundred models
29
+ const saeeolRank = createMemo(() => {
30
+ const provider = sync.data.provider.find((provider) => provider.id === "saeeol")
31
+ const models = provider?.models ?? {}
32
+ return new Map(Object.entries(models).map(([id, info]) => [id, info.recommendedIndex ?? Infinity] as const))
33
+ })
34
+
35
+ const showExtra = createMemo(() => connected() && !props.providerID)
36
+ const wide = createMemo(() => dimensions().width >= 108)
37
+ const [preview, setPreview] = createSignal<{
38
+ model: Model
39
+ provider: string
40
+ }>()
41
+
42
+ const lookup = (providerID: string, modelID: string) => {
43
+ const provider = sync.data.provider.find((x) => x.id === providerID)
44
+ const model = provider?.models?.[modelID]
45
+ if (!provider || !model) return
46
+ return {
47
+ model,
48
+ provider: provider.name,
49
+ }
50
+ }
51
+
52
+ createEffect(() => {
53
+ dialog.setSize(wide() ? "xlarge" : "large")
54
+ })
55
+
56
+ createEffect(() => {
57
+ const current = local.model.current()
58
+ if (!current) return
59
+ const next = lookup(current.providerID, current.modelID)
60
+ if (!next) return
61
+ setPreview(next)
62
+ })
63
+
64
+ const options = createMemo(() => {
65
+ const needle = query().trim()
66
+ const favorites = connected() ? local.model.favorite() : []
67
+ const recents = local.model.recent()
68
+
69
+ function toOptions(items: typeof favorites, category: string) {
70
+ if (!showExtra()) return []
71
+ return items.flatMap((item) => {
72
+ const provider = sync.data.provider.find((x) => x.id === item.providerID)
73
+ if (!provider) return []
74
+ const model = provider.models?.[item.modelID]
75
+ if (!model) return []
76
+ return [
77
+ {
78
+ key: item,
79
+ value: { providerID: provider.id, modelID: model.id },
80
+ title: model.name ?? item.modelID,
81
+ description: provider.name,
82
+ category,
83
+ disabled: provider.id === "saeeol" && model.id.includes("-nano"),
84
+ footer: model.cost?.input === 0 && provider.id === "saeeol" ? "Free" : undefined,
85
+ onSelect: () => {
86
+ onSelect(provider.id, model.id)
87
+ },
88
+ },
89
+ ]
90
+ })
91
+ }
92
+
93
+ const favoriteOptions = toOptions(favorites, t("cmd.model.favorites"))
94
+ const recentOptions = toOptions(
95
+ recents.filter(
96
+ (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
97
+ ),
98
+ t("cmd.model.recent"),
99
+ )
100
+
101
+ const providerOptions = pipe(
102
+ sync.data.provider,
103
+ sortBy(
104
+ (provider) => provider.id !== "saeeol",
105
+ (provider) => provider.name,
106
+ ),
107
+ flatMap((provider) =>
108
+ pipe(
109
+ provider.models,
110
+ entries(),
111
+ filter(([_, info]) => info.status !== "deprecated"),
112
+ filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
113
+ map(([model, info]) => ({
114
+ value: { providerID: provider.id, modelID: model },
115
+ title: info.name ?? model,
116
+ description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
117
+ ? "(Favorite)"
118
+ : undefined,
119
+ category: connected()
120
+ ? provider.id === "saeeol" && info.recommendedIndex !== undefined
121
+ ? t("cmd.model.recommended")
122
+ : provider.name
123
+ : undefined,
124
+ disabled: provider.id === "saeeol" && model.includes("-nano"),
125
+ footer: info.cost?.input === 0 && provider.id === "saeeol" ? "Free" : undefined,
126
+ onSelect() {
127
+ onSelect(provider.id, model)
128
+ },
129
+ })),
130
+ filter((x) => {
131
+ if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
132
+ return false
133
+ if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
134
+ return false
135
+ return true
136
+ }),
137
+ sortBy(
138
+ (x) => (x.value.providerID === "saeeol" ? (saeeolRank().get(x.value.modelID) ?? Infinity) : 0),
139
+ (x) => x.footer !== "Free",
140
+ (x) => x.title,
141
+ ),
142
+ ),
143
+ ),
144
+ )
145
+
146
+ const popularProviders = !connected()
147
+ ? pipe(
148
+ providers(),
149
+ map((option) => ({
150
+ ...option,
151
+ category: t("cmd.model.popular_providers"),
152
+ })),
153
+ take(6),
154
+ )
155
+ : []
156
+ if (needle) {
157
+ const rank = <U extends { title: string; category?: string }>(items: U[]) =>
158
+ fuzzysort.go(needle, items, { keys: ["title", "category"] }).map((x) => x.obj)
159
+ // rank within each provider category to preserve category order
160
+ const rankedProviders = pipe(
161
+ providerOptions,
162
+ groupBy((x) => x.category ?? ""),
163
+ entries(),
164
+ flatMap(([_, items]) => rank(items)),
165
+ )
166
+ return [...rank(favoriteOptions), ...rank(recentOptions), ...rankedProviders, ...rank(popularProviders)]
167
+ }
168
+
169
+ return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
170
+ })
171
+
172
+ const provider = createMemo(() =>
173
+ props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
174
+ )
175
+
176
+ const title = createMemo(() => {
177
+ const value = provider()
178
+ if (!value) return t("cmd.model.title")
179
+ return value.name
180
+ })
181
+
182
+ function onSelect(providerID: string, modelID: string) {
183
+ local.model.set({ providerID, modelID }, { recent: true })
184
+ const list = local.model.variant.list()
185
+ const cur = local.model.variant.selected()
186
+ if (cur === "default" || (cur && list.includes(cur))) {
187
+ dialog.clear()
188
+ return
189
+ }
190
+ if (list.length > 0) {
191
+ dialog.replace(() => <DialogVariant />)
192
+ return
193
+ }
194
+ dialog.clear()
195
+ }
196
+ return (
197
+ <box flexDirection="row">
198
+ <box flexGrow={1} flexShrink={1}>
199
+ <DialogSelect<ReturnType<typeof options>[number]["value"]>
200
+ options={options()}
201
+ keybind={[
202
+ {
203
+ keybind: keybind.all.model_provider_list?.[0],
204
+ title: connected() ? t("cmd.model.connect_provider") : t("cmd.model.view_all_providers"),
205
+ onTrigger() {
206
+ dialog.replace(() => <DialogProvider />)
207
+ },
208
+ },
209
+ {
210
+ keybind: keybind.all.model_favorite_toggle?.[0],
211
+ title: t("cmd.model.favorite"),
212
+ disabled: !connected(),
213
+ onTrigger: (option) => {
214
+ local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
215
+ },
216
+ },
217
+ ]}
218
+ onFilter={setQuery}
219
+ onMove={(option) => {
220
+ if (typeof option.value === "string") {
221
+ setPreview(undefined)
222
+ return
223
+ }
224
+ const next = lookup(option.value.providerID, option.value.modelID)
225
+ if (!next) return
226
+ setPreview(next)
227
+ }}
228
+ skipFilter={true}
229
+ title={title()}
230
+ current={local.model.current()}
231
+ />
232
+ </box>
233
+ <Show when={wide() && preview()}>
234
+ {(item) => <ModelInfoPanel model={item().model} provider={item().provider} />}
235
+ </Show>
236
+ </box>
237
+ )
238
+ }