saeeol 1.2.1 → 1.2.2

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 (113) hide show
  1. package/package.json +11 -11
  2. package/src/cli/cmd/tui/component/dialog/dialog-agent.tsx +32 -0
  3. package/src/cli/cmd/tui/component/dialog/dialog-command.tsx +190 -0
  4. package/src/cli/cmd/tui/component/dialog/dialog-console-org.tsx +103 -0
  5. package/src/cli/cmd/tui/component/dialog/dialog-go-upsell.tsx +159 -0
  6. package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +86 -0
  7. package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +238 -0
  8. package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +343 -0
  9. package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +103 -0
  10. package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +301 -0
  11. package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +35 -0
  12. package/src/cli/cmd/tui/component/dialog/dialog-skill.tsx +37 -0
  13. package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +87 -0
  14. package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +190 -0
  15. package/src/cli/cmd/tui/component/dialog/dialog-tag.tsx +44 -0
  16. package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +50 -0
  17. package/src/cli/cmd/tui/component/dialog/dialog-variant.tsx +39 -0
  18. package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +200 -0
  19. package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +81 -0
  20. package/src/cli/cmd/tui/component/dialog-agent.tsx +1 -32
  21. package/src/cli/cmd/tui/component/dialog-command.tsx +1 -190
  22. package/src/cli/cmd/tui/component/dialog-console-org.tsx +1 -103
  23. package/src/cli/cmd/tui/component/dialog-go-upsell.tsx +1 -159
  24. package/src/cli/cmd/tui/component/dialog-mcp.tsx +1 -86
  25. package/src/cli/cmd/tui/component/dialog-model.tsx +1 -238
  26. package/src/cli/cmd/tui/component/dialog-provider.tsx +1 -343
  27. package/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +1 -103
  28. package/src/cli/cmd/tui/component/dialog-session-list.tsx +1 -301
  29. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +1 -35
  30. package/src/cli/cmd/tui/component/dialog-skill.tsx +1 -37
  31. package/src/cli/cmd/tui/component/dialog-stash.tsx +1 -87
  32. package/src/cli/cmd/tui/component/dialog-status.tsx +1 -190
  33. package/src/cli/cmd/tui/component/dialog-tag.tsx +1 -44
  34. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +1 -50
  35. package/src/cli/cmd/tui/component/dialog-variant.tsx +1 -39
  36. package/src/cli/cmd/tui/component/dialog-workspace-create.tsx +1 -200
  37. package/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +1 -81
  38. package/src/tool/apply_patch.ts +1 -334
  39. package/src/tool/bash.ts +1 -656
  40. package/src/tool/core/external-directory.ts +55 -0
  41. package/src/tool/core/invalid.ts +21 -0
  42. package/src/tool/core/recall.ts +164 -0
  43. package/src/tool/core/recall.txt +12 -0
  44. package/src/tool/core/schema.ts +16 -0
  45. package/src/tool/core/tool.ts +162 -0
  46. package/src/tool/core/truncate.ts +160 -0
  47. package/src/tool/core/truncation-dir.ts +4 -0
  48. package/src/tool/diagnostics.ts +1 -20
  49. package/src/tool/edit-replacers.ts +1 -288
  50. package/src/tool/edit-utils.ts +1 -86
  51. package/src/tool/edit.ts +1 -262
  52. package/src/tool/external-directory.ts +1 -55
  53. package/src/tool/file/apply_patch.ts +334 -0
  54. package/src/tool/file/apply_patch.txt +33 -0
  55. package/src/tool/file/bash.ts +656 -0
  56. package/src/tool/file/bash.txt +119 -0
  57. package/src/tool/file/edit-replacers.ts +288 -0
  58. package/src/tool/file/edit-utils.ts +86 -0
  59. package/src/tool/file/edit.ts +262 -0
  60. package/src/tool/file/edit.txt +10 -0
  61. package/src/tool/file/read.ts +389 -0
  62. package/src/tool/file/read.txt +14 -0
  63. package/src/tool/file/write.ts +114 -0
  64. package/src/tool/file/write.txt +8 -0
  65. package/src/tool/glob.ts +1 -115
  66. package/src/tool/grep.ts +1 -151
  67. package/src/tool/integration/diagnostics.ts +20 -0
  68. package/src/tool/integration/lsp.ts +113 -0
  69. package/src/tool/integration/lsp.txt +24 -0
  70. package/src/tool/integration/mcp-exa.ts +73 -0
  71. package/src/tool/integration/package.ts +168 -0
  72. package/src/tool/integration/registry.ts +375 -0
  73. package/src/tool/invalid.ts +1 -21
  74. package/src/tool/lsp.ts +1 -113
  75. package/src/tool/mcp-exa.ts +1 -73
  76. package/src/tool/package.ts +1 -168
  77. package/src/tool/plan.ts +1 -30
  78. package/src/tool/question.ts +1 -52
  79. package/src/tool/read.ts +1 -389
  80. package/src/tool/recall.ts +1 -164
  81. package/src/tool/registry.ts +1 -375
  82. package/src/tool/schema.ts +1 -16
  83. package/src/tool/search/glob.ts +115 -0
  84. package/src/tool/search/glob.txt +6 -0
  85. package/src/tool/search/grep.ts +151 -0
  86. package/src/tool/search/grep.txt +8 -0
  87. package/src/tool/search/warpgrep.ts +107 -0
  88. package/src/tool/search/warpgrep.txt +10 -0
  89. package/src/tool/search/webfetch.ts +202 -0
  90. package/src/tool/search/webfetch.txt +13 -0
  91. package/src/tool/search/websearch.ts +71 -0
  92. package/src/tool/search/websearch.txt +14 -0
  93. package/src/tool/skill.ts +1 -91
  94. package/src/tool/task.ts +1 -197
  95. package/src/tool/todo.ts +1 -62
  96. package/src/tool/tool.ts +1 -162
  97. package/src/tool/truncate.ts +1 -160
  98. package/src/tool/truncation-dir.ts +1 -4
  99. package/src/tool/warpgrep.ts +1 -107
  100. package/src/tool/webfetch.ts +1 -202
  101. package/src/tool/websearch.ts +1 -71
  102. package/src/tool/workflow/plan-enter.txt +14 -0
  103. package/src/tool/workflow/plan-exit.txt +13 -0
  104. package/src/tool/workflow/plan.ts +30 -0
  105. package/src/tool/workflow/question.ts +52 -0
  106. package/src/tool/workflow/question.txt +11 -0
  107. package/src/tool/workflow/skill.ts +91 -0
  108. package/src/tool/workflow/skill.txt +5 -0
  109. package/src/tool/workflow/task.ts +197 -0
  110. package/src/tool/workflow/task.txt +57 -0
  111. package/src/tool/workflow/todo.ts +62 -0
  112. package/src/tool/workflow/todowrite.txt +167 -0
  113. package/src/tool/write.ts +1 -114
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,8 +50,8 @@
50
50
  "@babel/core": "7.28.4",
51
51
  "@effect/language-service": "0.84.2",
52
52
  "@octokit/webhooks-types": "7.6.1",
53
- "@saeeol/script": "7.3.1",
54
- "@saeeol/core": "7.3.1",
53
+ "@saeeol/script": "7.3.2",
54
+ "@saeeol/core": "7.3.2",
55
55
  "@parcel/watcher-darwin-arm64": "2.5.1",
56
56
  "@parcel/watcher-darwin-x64": "2.5.1",
57
57
  "@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -133,14 +133,14 @@
133
133
  "@parcel/watcher": "2.5.1",
134
134
  "@pierre/diffs": "1.1.0-beta.18",
135
135
  "@saeeol/boxes": "0.2.0",
136
- "@saeeol/core": "7.3.1",
137
- "@saeeol/gateway": "7.3.1",
138
- "@saeeol/i18n": "7.3.1",
139
- "@saeeol/indexing": "7.3.1",
140
- "@saeeol/plugin": "7.3.1",
141
- "@saeeol/script": "7.3.1",
142
- "@saeeol/sdk": "7.3.1",
143
- "@saeeol/telemetry": "7.3.1",
136
+ "@saeeol/core": "7.3.2",
137
+ "@saeeol/gateway": "7.3.2",
138
+ "@saeeol/i18n": "7.3.2",
139
+ "@saeeol/indexing": "7.3.2",
140
+ "@saeeol/plugin": "7.3.3",
141
+ "@saeeol/script": "7.3.2",
142
+ "@saeeol/sdk": "7.3.3",
143
+ "@saeeol/telemetry": "7.3.2",
144
144
  "@solid-primitives/event-bus": "1.1.2",
145
145
  "@solid-primitives/scheduled": "1.5.2",
146
146
  "@standard-schema/spec": "1.0.0",
@@ -0,0 +1,32 @@
1
+ import { createMemo } from "solid-js"
2
+ import { useLocal } from "@tui/context/local"
3
+ import { DialogSelect } from "@tui/ui/dialog-select"
4
+ import { useDialog } from "@tui/ui/dialog"
5
+
6
+ export function DialogAgent() {
7
+ const local = useLocal()
8
+ const dialog = useDialog()
9
+
10
+ const options = createMemo(() =>
11
+ local.agent.list().map((item) => {
12
+ return {
13
+ value: item.name,
14
+ title: item.displayName ?? item.name,
15
+ description:
16
+ [item.deprecated && "deprecated", item.native && "native"].filter(Boolean).join(", ") || item.description,
17
+ }
18
+ }),
19
+ )
20
+
21
+ return (
22
+ <DialogSelect
23
+ title="Select agent"
24
+ current={local.agent.current()?.name ?? ""}
25
+ options={options()}
26
+ onSelect={(option) => {
27
+ local.agent.set(option.value)
28
+ dialog.clear()
29
+ }}
30
+ />
31
+ )
32
+ }
@@ -0,0 +1,190 @@
1
+ import { useDialog } from "@tui/ui/dialog"
2
+ import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
3
+ import {
4
+ createContext,
5
+ createMemo,
6
+ createSignal,
7
+ getOwner,
8
+ onCleanup,
9
+ runWithOwner,
10
+ useContext,
11
+ type Accessor,
12
+ type ParentProps,
13
+ } from "solid-js"
14
+ import { useKeyboard } from "@opentui/solid"
15
+ import { useKeybind } from "@tui/context/keybind"
16
+ import { t } from "@/util/i18n"
17
+
18
+ type Context = ReturnType<typeof init>
19
+ const ctx = createContext<Context>()
20
+
21
+ export type Slash = {
22
+ name: string
23
+ aliases?: string[]
24
+ }
25
+
26
+ export type CommandOption = DialogSelectOption<string> & {
27
+ keybind?: string
28
+ suggested?: boolean
29
+ slash?: Slash
30
+ hidden?: boolean
31
+ enabled?: boolean
32
+ }
33
+
34
+ function init() {
35
+ const root = getOwner()
36
+ const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
37
+ const [suspendCount, setSuspendCount] = createSignal(0)
38
+ const dialog = useDialog()
39
+ const keybind = useKeybind()
40
+
41
+ // Double-tap tracking for agent_cycle keybinds
42
+ const DOUBLE_TAB_KEYS = new Set(["agent_cycle", "agent_cycle_reverse"])
43
+ const DOUBLE_TAB_WINDOW = 400
44
+ let lastTabKey = ""
45
+ let lastTabTime = 0
46
+
47
+ const entries = createMemo(() => {
48
+ const all = registrations().flatMap((x) => x())
49
+ return all.map((x) => ({
50
+ ...x,
51
+ footer: x.keybind ? keybind.print(x.keybind) : undefined,
52
+ }))
53
+ })
54
+
55
+ const isEnabled = (option: CommandOption) => option.enabled !== false
56
+ const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
57
+
58
+ const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
59
+ const suggestedOptions = createMemo(() =>
60
+ visibleOptions()
61
+ .filter((option) => option.suggested)
62
+ .map((option) => ({
63
+ ...option,
64
+ value: `suggested:${option.value}`,
65
+ category: t("cmd.suggested"),
66
+ })),
67
+ )
68
+ const suspended = () => suspendCount() > 0
69
+
70
+ useKeyboard((evt) => {
71
+ if (suspended()) return
72
+ if (dialog.stack.length > 0) return
73
+ if (evt.defaultPrevented) return
74
+ for (const option of entries()) {
75
+ if (!isEnabled(option)) continue
76
+ if (option.keybind && keybind.match(option.keybind, evt)) {
77
+ // Require double-tap for agent cycle keybinds
78
+ if (DOUBLE_TAB_KEYS.has(option.keybind)) {
79
+ const now = Date.now()
80
+ const match = option.keybind === lastTabKey && now - lastTabTime < DOUBLE_TAB_WINDOW
81
+ lastTabKey = option.keybind
82
+ lastTabTime = now
83
+ if (!match) {
84
+ evt.preventDefault()
85
+ return
86
+ }
87
+ }
88
+ evt.preventDefault()
89
+ option.onSelect?.(dialog)
90
+ return
91
+ }
92
+ }
93
+ })
94
+
95
+ const result = {
96
+ trigger(name: string) {
97
+ for (const option of entries()) {
98
+ if (option.value === name) {
99
+ if (!isEnabled(option)) return
100
+ option.onSelect?.(dialog)
101
+ return
102
+ }
103
+ }
104
+ },
105
+ slashes() {
106
+ return visibleOptions().flatMap((option) => {
107
+ const slash = option.slash
108
+ if (!slash) return []
109
+ return {
110
+ display: "/" + slash.name,
111
+ description: option.description ?? option.title,
112
+ aliases: slash.aliases?.map((alias) => "/" + alias),
113
+ onSelect: () => result.trigger(option.value),
114
+ }
115
+ })
116
+ },
117
+ keybinds(enabled: boolean) {
118
+ setSuspendCount((count) => count + (enabled ? -1 : 1))
119
+ },
120
+ suspended,
121
+ show() {
122
+ dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
123
+ },
124
+ register(cb: () => CommandOption[]) {
125
+ const owner = getOwner() ?? root
126
+ if (!owner) return () => {}
127
+
128
+ let list: Accessor<CommandOption[]> | undefined
129
+
130
+ // TUI plugins now register commands via an async store that runs outside an active reactive scope.
131
+ // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
132
+ runWithOwner(owner, () => {
133
+ list = createMemo(cb)
134
+ const ref = list
135
+ if (!ref) return
136
+ setRegistrations((arr) => [ref, ...arr])
137
+ onCleanup(() => {
138
+ setRegistrations((arr) => arr.filter((x) => x !== ref))
139
+ })
140
+ })
141
+
142
+ if (!list) return () => {}
143
+ let done = false
144
+ return () => {
145
+ if (done) return
146
+ done = true
147
+ const ref = list
148
+ if (!ref) return
149
+ setRegistrations((arr) => arr.filter((x) => x !== ref))
150
+ }
151
+ },
152
+ }
153
+ return result
154
+ }
155
+
156
+ export function useCommandDialog() {
157
+ const value = useContext(ctx)
158
+ if (!value) {
159
+ throw new Error("useCommandDialog must be used within a CommandProvider")
160
+ }
161
+ return value
162
+ }
163
+
164
+ export function CommandProvider(props: ParentProps) {
165
+ const value = init()
166
+ const dialog = useDialog()
167
+ const keybind = useKeybind()
168
+
169
+ useKeyboard((evt) => {
170
+ if (value.suspended()) return
171
+ if (dialog.stack.length > 0) return
172
+ if (evt.defaultPrevented) return
173
+ if (keybind.match("command_list", evt)) {
174
+ evt.preventDefault()
175
+ value.show()
176
+ return
177
+ }
178
+ })
179
+
180
+ return <ctx.Provider value={value}>{props.children}</ctx.Provider>
181
+ }
182
+
183
+ function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
184
+ let ref: DialogSelectRef<string>
185
+ const list = () => {
186
+ if (ref?.filter) return props.options
187
+ return [...props.suggestedOptions, ...props.options]
188
+ }
189
+ return <DialogSelect ref={(r) => (ref = r)} title={t("cmd.title")} options={list()} />
190
+ }
@@ -0,0 +1,103 @@
1
+ import { createResource, createMemo } from "solid-js"
2
+ import { DialogSelect } from "@tui/ui/dialog-select"
3
+ import { useSDK } from "@tui/context/sdk"
4
+ import { useDialog } from "@tui/ui/dialog"
5
+ import { useToast } from "@tui/ui/toast"
6
+ import { useTheme } from "@tui/context/theme"
7
+ import type { ExperimentalConsoleListOrgsResponse } from "@saeeol/sdk/v2"
8
+
9
+ type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
10
+
11
+ const accountHost = (url: string) => {
12
+ try {
13
+ return new URL(url).host
14
+ } catch {
15
+ return url
16
+ }
17
+ }
18
+
19
+ const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
20
+ `${item.accountEmail} ${accountHost(item.accountUrl)}`
21
+
22
+ export function DialogConsoleOrg() {
23
+ const sdk = useSDK()
24
+ const dialog = useDialog()
25
+ const toast = useToast()
26
+ const { theme } = useTheme()
27
+
28
+ const [orgs] = createResource(async () => {
29
+ const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
30
+ return result.data?.orgs ?? []
31
+ })
32
+
33
+ const current = createMemo(() => orgs()?.find((item) => item.active))
34
+
35
+ const options = createMemo(() => {
36
+ const listed = orgs()
37
+ if (listed === undefined) {
38
+ return [
39
+ {
40
+ title: "Loading orgs...",
41
+ value: "loading",
42
+ onSelect: () => {},
43
+ },
44
+ ]
45
+ }
46
+
47
+ if (listed.length === 0) {
48
+ return [
49
+ {
50
+ title: "No orgs found",
51
+ value: "empty",
52
+ onSelect: () => {},
53
+ },
54
+ ]
55
+ }
56
+
57
+ return listed
58
+ .toSorted((a, b) => {
59
+ const activeAccountA = a.active ? 0 : 1
60
+ const activeAccountB = b.active ? 0 : 1
61
+ if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
62
+
63
+ const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
64
+ if (accountCompare !== 0) return accountCompare
65
+
66
+ return a.orgName.localeCompare(b.orgName)
67
+ })
68
+ .map((item) => ({
69
+ title: item.orgName,
70
+ value: item,
71
+ category: accountLabel(item),
72
+ categoryView: (
73
+ <box flexDirection="row" gap={2}>
74
+ <text fg={theme.accent}>{item.accountEmail}</text>
75
+ <text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
76
+ </box>
77
+ ),
78
+ onSelect: async () => {
79
+ if (item.active) {
80
+ dialog.clear()
81
+ return
82
+ }
83
+
84
+ await sdk.client.experimental.console.switchOrg(
85
+ {
86
+ accountID: item.accountID,
87
+ orgID: item.orgID,
88
+ },
89
+ { throwOnError: true },
90
+ )
91
+
92
+ await sdk.client.instance.dispose()
93
+ toast.show({
94
+ message: `Switched to ${item.orgName}`,
95
+ variant: "info",
96
+ })
97
+ dialog.clear()
98
+ },
99
+ }))
100
+ })
101
+
102
+ return <DialogSelect<string | OrgOption> title="Switch org" options={options()} current={current()} />
103
+ }
@@ -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
+ }