2026-08-13 13:46:53 +09:00
|
|
|
|
import {
|
|
|
|
|
|
BoxRenderable,
|
|
|
|
|
|
RGBA,
|
2026-08-23 23:20:17 +08:00
|
|
|
|
StyledText,
|
2026-08-13 13:46:53 +09:00
|
|
|
|
TextAttributes,
|
|
|
|
|
|
TextRenderable,
|
|
|
|
|
|
type CliRenderer,
|
2026-08-23 23:20:17 +08:00
|
|
|
|
type TextChunk,
|
2026-08-13 13:46:53 +09:00
|
|
|
|
} from "@opentui/core"
|
|
|
|
|
|
|
|
|
|
|
|
export interface PickerMenuTheme {
|
|
|
|
|
|
text: string
|
|
|
|
|
|
muted: string
|
|
|
|
|
|
border: string
|
2026-08-23 23:20:17 +08:00
|
|
|
|
accent?: string
|
|
|
|
|
|
warning?: string
|
2026-08-16 20:27:57 +08:00
|
|
|
|
selectedBackground?: string
|
2026-08-13 13:46:53 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface PickerMenuOptions<T> {
|
|
|
|
|
|
id: string
|
2026-08-23 23:28:01 +08:00
|
|
|
|
key?: (item: T) => string
|
2026-08-13 13:46:53 +09:00
|
|
|
|
searchText: (item: T) => string
|
2026-08-23 23:20:17 +08:00
|
|
|
|
render: (item: T, selected: boolean) => string | TextChunk[]
|
2026-08-13 13:46:53 +09:00
|
|
|
|
emptyText?: string
|
2026-08-16 20:27:57 +08:00
|
|
|
|
maxWidth?: number
|
|
|
|
|
|
onSelect?: (item: T) => void
|
2026-08-13 13:46:53 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Shared retained picker for command discovery and session navigation. */
|
|
|
|
|
|
export class PickerMenu<T> {
|
|
|
|
|
|
readonly root: BoxRenderable
|
|
|
|
|
|
private items: T[] = []
|
|
|
|
|
|
private matches: T[] = []
|
|
|
|
|
|
private selected = 0
|
|
|
|
|
|
private query = ""
|
|
|
|
|
|
private limit = 6
|
|
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private readonly renderer: CliRenderer,
|
|
|
|
|
|
private theme: PickerMenuTheme,
|
|
|
|
|
|
private readonly options: PickerMenuOptions<T>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
this.root = new BoxRenderable(renderer, {
|
|
|
|
|
|
id: options.id,
|
|
|
|
|
|
width: "100%",
|
2026-08-16 20:27:57 +08:00
|
|
|
|
...(options.maxWidth ? { maxWidth: options.maxWidth } : {}),
|
2026-08-13 13:46:53 +09:00
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
flexDirection: "column",
|
|
|
|
|
|
border: true,
|
|
|
|
|
|
borderStyle: "rounded",
|
|
|
|
|
|
borderColor: theme.border,
|
|
|
|
|
|
paddingLeft: 1,
|
|
|
|
|
|
paddingRight: 1,
|
|
|
|
|
|
backgroundColor: RGBA.defaultBackground(),
|
|
|
|
|
|
visible: false,
|
2026-08-16 21:40:53 +08:00
|
|
|
|
onMouseDown: (event) => {
|
|
|
|
|
|
if (event.button !== 0) return
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
this.renderer.clearSelection()
|
|
|
|
|
|
},
|
2026-08-13 13:46:53 +09:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get visible(): boolean {
|
|
|
|
|
|
return this.root.visible
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
show(items: T[], query = "", limit = 6): void {
|
|
|
|
|
|
this.items = items
|
|
|
|
|
|
this.root.visible = true
|
|
|
|
|
|
this.update(query, limit)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 23:20:17 +08:00
|
|
|
|
replace(items: T[]): void {
|
|
|
|
|
|
if (!this.visible) return
|
|
|
|
|
|
this.items = items
|
|
|
|
|
|
this.update(this.query, this.limit)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
redraw(): void {
|
|
|
|
|
|
if (this.visible) this.render()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 13:46:53 +09:00
|
|
|
|
update(query: string, limit = this.limit): void {
|
|
|
|
|
|
if (!this.visible) return
|
|
|
|
|
|
const changed = query !== this.query
|
2026-08-23 23:28:01 +08:00
|
|
|
|
const previous = this.matches[this.selected]
|
|
|
|
|
|
const previousKey = previous === undefined ? null : this.options.key?.(previous)
|
2026-08-13 13:46:53 +09:00
|
|
|
|
this.query = query
|
|
|
|
|
|
this.limit = Math.max(1, limit)
|
|
|
|
|
|
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
|
|
|
|
|
this.matches = this.items
|
|
|
|
|
|
.filter((item) => {
|
|
|
|
|
|
const haystack = this.options.searchText(item).toLocaleLowerCase()
|
|
|
|
|
|
return words.every((word) => haystack.includes(word))
|
|
|
|
|
|
})
|
|
|
|
|
|
.slice(0, this.limit)
|
2026-08-23 23:28:01 +08:00
|
|
|
|
if (changed) {
|
|
|
|
|
|
this.selected = 0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const preserved = previous === undefined
|
|
|
|
|
|
? -1
|
|
|
|
|
|
: previousKey === null || previousKey === undefined
|
|
|
|
|
|
? this.matches.indexOf(previous)
|
|
|
|
|
|
: this.matches.findIndex((item) => this.options.key?.(item) === previousKey)
|
|
|
|
|
|
this.selected = preserved >= 0
|
|
|
|
|
|
? preserved
|
|
|
|
|
|
: Math.min(this.selected, Math.max(0, this.matches.length - 1))
|
|
|
|
|
|
}
|
2026-08-13 13:46:53 +09:00
|
|
|
|
this.render()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
move(direction: -1 | 1): boolean {
|
|
|
|
|
|
if (!this.visible || this.matches.length < 2) return false
|
|
|
|
|
|
this.selected = (this.selected + direction + this.matches.length) % this.matches.length
|
|
|
|
|
|
this.render()
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
current(): T | null {
|
|
|
|
|
|
return this.visible ? this.matches[this.selected] ?? null : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
hide(): void {
|
|
|
|
|
|
this.items = []
|
|
|
|
|
|
this.matches = []
|
|
|
|
|
|
this.selected = 0
|
|
|
|
|
|
this.query = ""
|
|
|
|
|
|
this.root.visible = false
|
|
|
|
|
|
this.clear()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTheme(theme: PickerMenuTheme): void {
|
|
|
|
|
|
this.theme = theme
|
|
|
|
|
|
this.root.borderColor = theme.border
|
|
|
|
|
|
if (this.visible) this.render()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private render(): void {
|
|
|
|
|
|
this.clear()
|
|
|
|
|
|
if (this.matches.length === 0) {
|
|
|
|
|
|
this.root.add(new TextRenderable(this.renderer, {
|
|
|
|
|
|
id: `${this.options.id}-empty`,
|
|
|
|
|
|
content: this.options.emptyText || "No matches",
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
fg: this.theme.muted,
|
2026-08-16 21:54:02 +08:00
|
|
|
|
selectable: false,
|
2026-08-13 13:46:53 +09:00
|
|
|
|
}))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [index, item] of this.matches.entries()) {
|
|
|
|
|
|
const selected = index === this.selected
|
2026-08-23 23:20:17 +08:00
|
|
|
|
const rendered = this.options.render(item, selected)
|
|
|
|
|
|
const content = typeof rendered === "string"
|
|
|
|
|
|
? `${selected ? "›" : " "} ${rendered}`
|
|
|
|
|
|
: new StyledText([
|
|
|
|
|
|
chunk(`${selected ? "›" : " "} `, selected ? this.theme.text : this.theme.muted),
|
|
|
|
|
|
...rendered,
|
|
|
|
|
|
])
|
2026-08-13 13:46:53 +09:00
|
|
|
|
this.root.add(new TextRenderable(this.renderer, {
|
|
|
|
|
|
id: `${this.options.id}-${index}`,
|
2026-08-23 23:20:17 +08:00
|
|
|
|
content,
|
2026-08-13 13:46:53 +09:00
|
|
|
|
width: "100%",
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
wrapMode: "none",
|
|
|
|
|
|
fg: selected ? this.theme.text : this.theme.muted,
|
2026-08-16 21:54:02 +08:00
|
|
|
|
selectable: false,
|
2026-08-16 20:27:57 +08:00
|
|
|
|
...(selected && this.theme.selectedBackground
|
|
|
|
|
|
? { backgroundColor: RGBA.fromHex(this.theme.selectedBackground) }
|
|
|
|
|
|
: {}),
|
2026-08-13 13:46:53 +09:00
|
|
|
|
attributes: selected ? TextAttributes.BOLD : 0,
|
2026-08-21 18:02:50 +08:00
|
|
|
|
onMouseMove: () => {
|
2026-08-16 20:27:57 +08:00
|
|
|
|
if (this.selected === index) return
|
|
|
|
|
|
this.selected = index
|
|
|
|
|
|
this.render()
|
|
|
|
|
|
},
|
|
|
|
|
|
onMouseDown: (event) => {
|
|
|
|
|
|
if (event.button !== 0) return
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
event.stopPropagation()
|
2026-08-16 21:40:53 +08:00
|
|
|
|
this.renderer.clearSelection()
|
2026-08-16 20:27:57 +08:00
|
|
|
|
this.selected = index
|
|
|
|
|
|
this.options.onSelect?.(item)
|
|
|
|
|
|
},
|
2026-08-13 13:46:53 +09:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private clear(): void {
|
|
|
|
|
|
for (const child of [...this.root.getChildren()]) {
|
|
|
|
|
|
this.root.remove(child)
|
|
|
|
|
|
child.destroyRecursively()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-23 23:20:17 +08:00
|
|
|
|
|
|
|
|
|
|
function chunk(text: string, color: string): TextChunk {
|
|
|
|
|
|
return {
|
|
|
|
|
|
__isChunk: true,
|
|
|
|
|
|
text,
|
|
|
|
|
|
fg: RGBA.fromHex(color),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|