Skip to content

Commit 7bfb5cc

Browse files
bobbyjohnstxclaude
andcommitted
feat(tui): unified command palette and fix stuck dialog (#59, #86)
Command palette (ctrl+p) now searches across commands, agents, sessions, and skills with frecency ranking. Fix crash when opening palette during model processing — FrecencyProvider was nested inside DialogProvider, so dialog content couldn't access the context. Also fix ErrorComponent double-fault from invalid URL constructor. Constraint: SolidJS context only flows to descendants, not siblings Rejected: defensive try/catch in useFrecency | masks real provider bugs Confidence: high Scope-risk: moderate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cd5f4ac commit 7bfb5cc

4 files changed

Lines changed: 115 additions & 15 deletions

File tree

‎packages/tinycode/src/cli/cmd/tui/app.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,17 +259,17 @@ async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefa
259259
<ThemeProvider mode={mode}>
260260
<LocalProvider>
261261
<PromptStashProvider>
262-
<DialogProvider>
263-
<FrecencyProvider>
262+
<FrecencyProvider>
263+
<DialogProvider>
264264
<PromptHistoryProvider>
265265
<PromptRefProvider>
266266
<EditorContextProvider>
267267
<App onSnapshot={input.onSnapshot} />
268268
</EditorContextProvider>
269269
</PromptRefProvider>
270270
</PromptHistoryProvider>
271-
</FrecencyProvider>
272-
</DialogProvider>
271+
</DialogProvider>
272+
</FrecencyProvider>
273273
</PromptStashProvider>
274274
</LocalProvider>
275275
</ThemeProvider>

‎packages/tinycode/src/cli/cmd/tui/component/command-palette.tsx‎

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createMemo } from "solid-js"
1+
import { createMemo, createResource } from "solid-js"
22
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
33
import { type DialogContext } from "@tui/ui/dialog"
44
import {
@@ -10,6 +10,11 @@ import {
1010
} from "../keymap"
1111
import { useTuiConfig } from "../context/tui-config"
1212
import { useFrecency } from "./prompt/frecency"
13+
import { useLocal } from "@tui/context/local"
14+
import { useSync } from "@tui/context/sync"
15+
import { useRoute } from "@tui/context/route"
16+
import { useSDK } from "@tui/context/sdk"
17+
import { Locale } from "@/util/locale"
1318

1419
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
1520

@@ -28,6 +33,11 @@ export function CommandPaletteDialog() {
2833
const config = useTuiConfig()
2934
const keymap = useTinycodeKeymap()
3035
const frecency = useFrecency()
36+
const local = useLocal()
37+
const sync = useSync()
38+
const route = useRoute()
39+
const sdk = useSDK()
40+
3141
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
3242
const query = {
3343
namespace: "palette",
@@ -47,11 +57,12 @@ export function CommandPaletteDialog() {
4757
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
4858
}))
4959
})
50-
const options = createMemo(() =>
60+
61+
const commandOptions = createMemo(() =>
5162
entries().map((entry) => ({
5263
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
5364
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
54-
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
65+
category: typeof entry.command.category === "string" ? entry.command.category : "Commands",
5566
footer: formatKeyBindings(entry.bindings, config),
5667
value: entry.command.name,
5768
suggested: isSuggestedPaletteCommand(entry),
@@ -64,19 +75,88 @@ export function CommandPaletteDialog() {
6475
})),
6576
)
6677

78+
const currentAgent = createMemo(() => local.agent.current()?.name)
79+
const agentOptions = createMemo(() =>
80+
local.agent
81+
.list()
82+
.filter((a) => !a.hidden && a.name !== currentAgent())
83+
.map((agent) => ({
84+
title: agent.name,
85+
description: agent.description ? Locale.truncate(agent.description, 60) : undefined,
86+
category: "Agents",
87+
footer: agent.name === currentAgent() ? "current" : "",
88+
value: `agent:${agent.name}`,
89+
suggested: false,
90+
frecencyScore: frecency.getFrecency("agents", agent.name),
91+
onSelect: (dialog: DialogContext) => {
92+
dialog.clear()
93+
frecency.updateFrecency("agents", agent.name)
94+
local.agent.set(agent.name)
95+
},
96+
})),
97+
)
98+
99+
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
100+
const sessionOptions = createMemo(() =>
101+
sync.data.session
102+
.filter((s) => !s.parentID && s.id !== currentSessionID())
103+
.toSorted((a, b) => b.time.updated - a.time.updated)
104+
.slice(0, 8)
105+
.map((session) => ({
106+
title: session.title,
107+
description: Locale.time(session.time.updated),
108+
category: "Sessions",
109+
footer: "",
110+
value: `session:${session.id}`,
111+
suggested: false,
112+
frecencyScore: frecency.getFrecency("sessions", session.id),
113+
onSelect: (dialog: DialogContext) => {
114+
dialog.clear()
115+
frecency.updateFrecency("sessions", session.id)
116+
route.navigate({ type: "session", sessionID: session.id })
117+
},
118+
})),
119+
)
120+
121+
const [skills] = createResource(async () => {
122+
const result = await sdk.client.app.skills()
123+
return result.data ?? []
124+
})
125+
const skillOptions = createMemo(() =>
126+
(skills() ?? []).map((skill) => ({
127+
title: `/${skill.name}`,
128+
description: skill.description?.replace(/\s+/g, " ").trim(),
129+
category: "Skills",
130+
footer: "",
131+
value: `skill:${skill.name}`,
132+
suggested: false,
133+
frecencyScore: frecency.getFrecency("skills", skill.name),
134+
onSelect: (dialog: DialogContext) => {
135+
dialog.clear()
136+
frecency.updateFrecency("skills", skill.name)
137+
keymap.dispatchCommand("prompt.skills")
138+
},
139+
})),
140+
)
141+
67142
let ref: DialogSelectRef<string>
68143
const list = () => {
69-
if (ref?.filter) return options()
70-
const suggested = options()
144+
if (ref?.filter) {
145+
return [...commandOptions(), ...agentOptions(), ...sessionOptions(), ...skillOptions()]
146+
}
147+
const suggested = commandOptions()
71148
.filter((option) => option.suggested)
72149
.map((option) => ({
73150
...option,
74151
value: `suggested:${option.value}`,
75152
category: "Suggested",
76153
}))
77-
const rest = [...options()].sort((a, b) => b.frecencyScore - a.frecencyScore)
78-
return [...suggested, ...rest]
154+
const commands = [...commandOptions()].sort((a, b) => b.frecencyScore - a.frecencyScore)
155+
const agents = [...agentOptions()].sort((a, b) => b.frecencyScore - a.frecencyScore)
156+
const sessions = [...sessionOptions()]
157+
const skillsList = [...skillOptions()].sort((a, b) => b.frecencyScore - a.frecencyScore)
158+
return [...suggested, ...commands, ...agents, ...sessions, ...skillsList]
79159
}
80160

81-
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
161+
return <DialogSelect ref={(value) => (ref = value)} title="Command Palette" options={list()} flat />
82162
}

‎packages/tinycode/src/cli/cmd/tui/component/error-component.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function ErrorComponent(props: {
2020
})
2121
const [copied, setCopied] = createSignal(false)
2222

23-
const issueURL = new URL("")
23+
const issueURL = new URL("https://github.com/bobbyjohnstx/tinycode/issues/new")
2424

2525
// Choose safe fallback colors per mode since theme context may not be available
2626
const isLight = props.mode === "light"

‎packages/tinycode/src/cli/cmd/tui/ui/dialog.tsx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
2-
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
2+
import {
3+
batch,
4+
createContext,
5+
createEffect,
6+
ErrorBoundary,
7+
onCleanup,
8+
Show,
9+
useContext,
10+
type JSX,
11+
type ParentProps,
12+
} from "solid-js"
313
import { useTheme } from "@tui/context/theme"
414
import { MouseButton, Renderable, RGBA } from "@opentui/core"
515
import { createStore } from "solid-js/store"
@@ -202,7 +212,17 @@ export function DialogProvider(props: ParentProps) {
202212
>
203213
<Show when={value.stack.length}>
204214
<Dialog onClose={() => value.clear()} size={value.size}>
205-
{value.stack.at(-1)!.element}
215+
<ErrorBoundary
216+
fallback={() => {
217+
queueMicrotask(() => {
218+
value.clear()
219+
toast.show({ message: "Dialog failed to render", variant: "error" })
220+
})
221+
return <></>
222+
}}
223+
>
224+
{value.stack.at(-1)!.element}
225+
</ErrorBoundary>
206226
</Dialog>
207227
</Show>
208228
</box>

0 commit comments

Comments
 (0)