command bar

This commit is contained in:
Dax Raad 2025-09-22 23:55:35 -04:00
parent 9d54cdac08
commit b45ac53de7
5 changed files with 149 additions and 66 deletions

View file

@ -1,61 +1,65 @@
import { useDialog } from "../ui/dialog"
import { DialogModel } from "./dialog-model"
import { DialogSelect } from "../ui/dialog-select"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { DialogSessionList } from "./dialog-session-list"
import { DialogAgent } from "./dialog-agent"
import {
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
export function DialogCommand() {
const dialog = useDialog()
const route = useRoute()
return (
<DialogSelect
title="Commands"
options={[
{
title: "Switch model",
value: "switch-model",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch agent",
value: "switch-agent",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
},
{
title: "Switch session",
value: "switch-session",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "new-session",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Share session",
value: "share-session",
category: "Session",
onSelect: () => {
console.log("share session")
},
},
]}
/>
)
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
function init() {
const [registrations, setRegistrations] = createSignal<Accessor<DialogSelectOption[]>[]>([])
const options = createMemo(() => {
return registrations().flatMap((x) => x())
})
return {
register(cb: () => DialogSelectOption[]) {
const results = createMemo(cb)
setRegistrations((x) => [...x, results])
onCleanup(() => {
setRegistrations((x) => x.filter((x) => x !== results))
})
},
get options() {
return options()
},
}
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
useKeyboard((evt) => {
if (evt.name === "k" && evt.ctrl) {
dialog.replace(() => <DialogCommand options={value.options} />)
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: DialogSelectOption[] }) {
return <DialogSelect title="Commands" options={props.options} />
}

View file

@ -34,6 +34,7 @@ export function Prompt(props: PromptProps) {
const sdk = useSDK()
const route = useRoute()
const sync = useSync()
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
const [store, setStore] = createStore<Prompt>({
input: "",
@ -206,6 +207,9 @@ export function Prompt(props: PromptProps) {
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
<Switch>
<Match when={session()?.time.compacting}>
<text fg={Theme.textMuted}>compacting...</text>
</Match>
<Match when={working()}>
<box flexDirection="row" gap={1}>
<text>

View file

@ -25,10 +25,12 @@ import type { WebFetchTool } from "../../../tool/webfetch"
import type { TaskTool } from "../../../tool/task"
import { useKeyboard, type BoxProps, type JSX } from "@opentui/solid"
import { useSDK } from "./context/sdk"
import { useCommandDialog } from "./component/dialog-command"
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const command = useCommandDialog()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const todo = createMemo(() => sync.data.todo[route.sessionID] ?? [])
@ -48,6 +50,37 @@ export function Session() {
})
})
command.register(() => [
{
title: "Share session",
value: "session.share",
disabled: !session().share?.url,
category: "Session",
onSelect: (ctx) => {
sdk.session.share({
path: {
id: route.sessionID,
},
})
ctx.clear()
},
},
{
title: "Unshare session",
value: "session.unshare",
disabled: !!session().share?.url,
category: "Session",
onSelect: (ctx) => {
sdk.session.unshare({
path: {
id: route.sessionID,
},
})
ctx.clear()
},
},
])
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1}>
<Show when={session()}>

View file

@ -12,10 +12,12 @@ import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
import { DialogCommand } from "./component/dialog-command"
import { Session } from "./session"
import { Instance } from "../../../project/instance"
import { EventLoop } from "../../../util/eventloop"
import { CommandProvider, useCommandDialog } from "./component/dialog-command"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
export const TuiCommand = cmd({
command: "$0 [project]",
@ -79,7 +81,9 @@ export const TuiCommand = cmd({
<SyncProvider>
<LocalProvider>
<DialogProvider>
<App />
<CommandProvider>
<App />
</CommandProvider>
</DialogProvider>
</LocalProvider>
</SyncProvider>
@ -103,6 +107,7 @@ function App() {
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const command = useCommandDialog()
useKeyboard(async (evt) => {
if (evt.name === "tab") {
@ -110,11 +115,6 @@ function App() {
return
}
if (evt.ctrl && evt.name === "k") {
dialog.replace(() => <DialogCommand />)
return
}
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
@ -134,6 +134,44 @@ function App() {
console.log(JSON.stringify(route.data))
})
command.register(() => [
{
title: "Switch session",
value: "switch-session",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "new-session",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Switch model",
value: "switch-model",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch agent",
value: "switch-agent",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
},
])
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box flexDirection="column" flexGrow={1}>

View file

@ -1,11 +1,12 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { entries, flatMap, groupBy, pipe } from "remeda"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "./dialog"
export interface DialogSelectProps<T> {
title: string
@ -15,15 +16,17 @@ export interface DialogSelectProps<T> {
current?: T
}
export interface DialogSelectOption<T> {
value: T
export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
category?: string
onSelect?: () => void
disabled?: boolean
onSelect?: (ctx: DialogContext) => void
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const [store, setStore] = createStore({
selected: 0,
filter: "",
@ -35,6 +38,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
filter((x) => x.disabled !== false),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
groupBy((x) => x.category ?? ""),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
@ -84,7 +88,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (evt.name === "down") move(1)
if (evt.name === "return") {
const option = selected()
if (option.onSelect) option.onSelect()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
})