fix: menus

This commit is contained in:
Austin Pickett
2026-04-10 00:01:37 -04:00
parent 4fe78d5b88
commit e1df13cf20
4 changed files with 193 additions and 32 deletions

View File

@@ -280,6 +280,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [turnTrail, setTurnTrail] = useState<string[]>([])
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null)
// ── Refs ─────────────────────────────────────────────────────────
@@ -312,7 +313,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw)
function blocked() {
return !!(clarify || approval || pasteReview || picker || secret || sudo)
return !!(clarify || approval || pasteReview || picker || secret || sudo || pager)
}
const empty = !messages.length
@@ -349,6 +350,11 @@ export function App({ gw }: { gw: GatewayClient }) {
const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage])
const page = useCallback((text: string) => {
const lines = text.split('\n')
setPager({ lines, offset: 0 })
}, [])
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => {
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
@@ -803,8 +809,26 @@ export function App({ gw }: { gw: GatewayClient }) {
const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const pagerPageSize = Math.max(5, (stdout?.rows ?? 24) - 6)
useInput((ch, key) => {
if (isBlocked) {
if (pager) {
if (key.return || ch === ' ') {
const next = pager.offset + pagerPageSize
if (next >= pager.lines.length) {
setPager(null)
} else {
setPager({ ...pager, offset: next })
}
} else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') {
setPager(null)
}
return
}
if (pasteReview) {
if (key.return) {
setPasteReview(null)
@@ -970,7 +994,9 @@ export function App({ gw }: { gw: GatewayClient }) {
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: (r.categories ?? []) as SlashCatalog['categories'],
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
})
})
@@ -1272,21 +1298,26 @@ export function App({ gw }: { gw: GatewayClient }) {
switch (name) {
case 'help': {
const rows = catalog?.pairs ?? []
const cap = 52
const cats = catalog?.categories ?? []
const skills = catalog?.skillCount ?? 0
const lines: string[] = []
sys(
[
' Commands:',
...rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`),
rows.length > cap ? `${rows.length - cap} more` : '',
'',
' Hotkeys:',
...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`)
]
.filter(Boolean)
.join('\n')
)
for (const { name: catName, pairs } of cats) {
if (lines.length) lines.push('')
lines.push(` ${catName}:`)
for (const [c, d] of pairs) lines.push(` ${c.padEnd(18)} ${d}`)
}
if (!lines.length) lines.push(' (no commands loaded)')
if (skills > 0) {
lines.push('', ` ${skills} skill commands available — /skills to browse`)
}
lines.push('', ' Hotkeys:')
for (const [k, d] of HOTKEYS) lines.push(` ${k.padEnd(14)} ${d}`)
sys(lines.join('\n'))
return true
}
@@ -1527,6 +1558,13 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid })
.then((r: any) => page(r?.output || '(no output)'))
.catch(() => sys('provider command failed'))
return true
case 'skin':
if (arg) {
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`))
@@ -1714,6 +1752,56 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'skills': {
const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean)
if (!sub || sub === 'list') {
rpc('skills.manage', { action: 'list' }).then((r: any) => {
const sk = r.skills as Record<string, string[]> | undefined
if (!sk || !Object.keys(sk).length) return sys('no skills installed')
const lines: string[] = []
for (const [cat, names] of Object.entries(sk)) {
lines.push(` ${cat}: ${(names as string[]).join(', ')}`)
}
sys(lines.join('\n'))
})
return true
}
if (sub === 'browse') {
const page = parseInt(sArgs[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page }).then((r: any) => {
if (!r.items?.length) return sys('no skills found in the hub')
const lines = [
` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`,
'',
...r.items.map((s: any) =>
` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}`
),
]
if (r.page < r.total_pages) lines.push('', ` /skills browse ${r.page + 1} → next page`)
if (r.page > 1) lines.push(` /skills browse ${r.page - 1} → prev page`)
sys(lines.join('\n'))
})
return true
}
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || '/skills: no output'))
.catch(() => sys(`skills: ${sub} failed`))
return true
}
default:
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || `/${name}: no output`))
@@ -1737,7 +1825,7 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
},
[catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys]
[catalog, compact, gw, lastUserMsg, messages, newSession, page, pastes, pushActivity, rpc, send, sid, statusBar, sys]
)
slashRef.current = slash
@@ -1974,6 +2062,20 @@ export function App({ gw }: { gw: GatewayClient }) {
/>
)}
{pager && (
<Box flexDirection="column">
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Text color={theme.color.dim}>
{pager.offset + pagerPageSize < pager.lines.length
? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length}) ──`
: `── end · q to close (${pager.lines.length} lines) ──`}
</Text>
</Box>
)}
{!isBlocked && (
<Box flexDirection="column">
{inputBuf.map((line, i) => (