mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
feat(opentui-v2): Phase 4a — slash command system + local confirm dialog
The composer now routes `/command` through the Ink-parity dispatch ladder
instead of submitting it as a prompt (spec §1):
- logic/slash.ts: parseSlash + dispatchSlash — client-local command →
slash.exec {command, session_id} (output → system line) → on reject
command.dispatch {arg, name, session_id} with typed handling
(exec/plugin→system · alias→re-dispatch · skill/send→submit a turn ·
prefill→notice). 6 client commands: help/quit/exit/clear/new/logs.
- /help renders the live `commands.catalog` (reads the `pairs` shape).
- view/prompts/confirmPrompt.tsx + store.setConfirm: a LOCAL (non-gateway) Y/N
dialog for /clear and /new; store gains pushSystem + clearTranscript.
- entry: a Promise-returning `request` adapter + the SlashContext wiring (quit →
renderer.destroy, confirm, clearTranscript, logTail, submit).
Also fixes a keystroke-leak: the key that ANSWERED a prompt was bleeding into the
freshly-refocused composer (`/clear`→y left "y" in the input, breaking the next
`/quit`). PromptOverlay now defers the prompt-clear (composer remount) past the
current keystroke — this hardens every Phase 3 prompt too.
Verified: bun run check green (36 tests / 6 files) — slash.test covers parse + the
full ladder against a fake context. LIVE tmux: /help → full gateway catalog;
/version → slash.exec output; /clear → confirm → cleared, no key-leak (typed "hi"
not "yhi"); /quit → clean quit, child reaped. Remaining TUI-only commands,
completions, pager routing, and session resume are 4b/4c. Smoke P4 + matrix updated.
This commit is contained in:
parent
d01b573796
commit
87634e19fd
6 changed files with 375 additions and 6 deletions
|
|
@ -27,6 +27,7 @@ import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
|||
import { getLog } from '../boundary/log.ts'
|
||||
import { acquireRenderer } from '../boundary/renderer.ts'
|
||||
import { makeAppLayer } from '../boundary/runtime.ts'
|
||||
import { dispatchSlash, type SlashContext } from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
|
|
@ -96,10 +97,9 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
const gateway = yield* GatewayService
|
||||
yield* gateway.subscribe(event => store.apply(event))
|
||||
|
||||
// Composer submit: a plain callback the Solid view calls. The service value
|
||||
// is already in hand, so `gateway.request(...)` is Effect<…, never> — fire it
|
||||
// detached with runFork; failures are logged, never thrown into the view.
|
||||
const submit = (text: string) => {
|
||||
// Submit a user turn: the service value is in hand, so `gateway.request(...)`
|
||||
// is Effect<…, never> — fire it detached with runFork; failures are logged.
|
||||
const submitPrompt = (text: string) => {
|
||||
store.pushUser(text)
|
||||
const sid = gateway.sessionId()
|
||||
if (!sid) {
|
||||
|
|
@ -115,6 +115,30 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
)
|
||||
}
|
||||
|
||||
// Slash dispatch context (Solid logic; the boundary just hands it a
|
||||
// Promise-returning `request` + the host capabilities it needs).
|
||||
const slashCtx: SlashContext = {
|
||||
clearTranscript: () => store.clearTranscript(),
|
||||
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
||||
logTail: () =>
|
||||
getLog()
|
||||
.tail(40)
|
||||
.map(e => `${e.scope}: ${e.msg}`),
|
||||
pushSystem: text => store.pushSystem(text),
|
||||
quit: () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
},
|
||||
request: (method, params) => Effect.runPromise(gateway.request(method, params)),
|
||||
sessionId: () => gateway.sessionId(),
|
||||
submit: submitPrompt
|
||||
}
|
||||
|
||||
// The composer's submit: route `/command` through the slash ladder, else a prompt.
|
||||
const submit = (text: string) => {
|
||||
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
|
||||
else submitPrompt(text)
|
||||
}
|
||||
|
||||
// Blocking-prompt replies (clarify/approval/sudo/secret `*.respond`). Same
|
||||
// detached-runFork pattern; failures logged, never thrown into the view.
|
||||
const respond = (method: string, params: Record<string, unknown>) => {
|
||||
|
|
|
|||
154
ui-tui-opentui-v2/src/logic/slash.ts
Normal file
154
ui-tui-opentui-v2/src/logic/slash.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Slash command system — the SOLID side (spec §1; mirrors Ink
|
||||
* `app/createSlashHandler.ts` + `domain/slash.ts`). Plain functions/data, NOT
|
||||
* Effect; the boundary injects a Promise-returning `request` so dispatch can call
|
||||
* `slash.exec` / `command.dispatch` / `commands.catalog`.
|
||||
*
|
||||
* Dispatch ladder (Ink parity):
|
||||
* 1. client-local command (the TUI-only set — handled in-process)
|
||||
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
|
||||
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
|
||||
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
||||
* prefill → notice). Pager routing for long output lands with Phase 5a; for
|
||||
* now long output is shown as a (multi-line) system message.
|
||||
*/
|
||||
export interface ParsedSlash {
|
||||
name: string
|
||||
arg: string
|
||||
}
|
||||
|
||||
/** Parse `/name rest…` → {name, arg}; null if not a slash command. */
|
||||
export function parseSlash(input: string): ParsedSlash | null {
|
||||
if (!input.startsWith('/')) return null
|
||||
const body = input.slice(1).trimStart()
|
||||
if (!body) return null
|
||||
const sp = body.indexOf(' ')
|
||||
return sp === -1 ? { arg: '', name: body } : { arg: body.slice(sp + 1).trim(), name: body.slice(0, sp) }
|
||||
}
|
||||
|
||||
/** The host capabilities the dispatcher needs (wired by the entry boundary). */
|
||||
export interface SlashContext {
|
||||
/** Server RPC (resolves with the result, rejects on GatewayError). */
|
||||
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
readonly sessionId: () => string | undefined
|
||||
readonly pushSystem: (text: string) => void
|
||||
/** Submit a user turn (skill/send dispatch results). */
|
||||
readonly submit: (text: string) => void
|
||||
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
|
||||
readonly confirm: (message: string, onConfirm: () => void) => void
|
||||
readonly clearTranscript: () => void
|
||||
readonly quit: () => void
|
||||
/** Recent log lines for `/logs` (the ring buffer). */
|
||||
readonly logTail: () => string[]
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const CLIENT_HELP = [
|
||||
'/help — list commands',
|
||||
'/clear, /new — clear the transcript (confirm)',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
'(other /commands run on the gateway)'
|
||||
].join('\n')
|
||||
|
||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
||||
|
||||
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
||||
const CLIENT: Record<string, ClientHandler> = {
|
||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||
exit: (_arg, ctx) => ctx.quit(),
|
||||
help: async (_arg, ctx) => {
|
||||
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
||||
try {
|
||||
const cat = await ctx.request('commands.catalog', {})
|
||||
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
|
||||
} catch {
|
||||
ctx.pushSystem(CLIENT_HELP)
|
||||
}
|
||||
},
|
||||
logs: (_arg, ctx) => ctx.pushSystem(ctx.logTail().slice(-40).join('\n') || '(log empty)'),
|
||||
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
|
||||
quit: (_arg, ctx) => ctx.quit()
|
||||
}
|
||||
|
||||
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
|
||||
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
|
||||
* (tui_gateway/server.py `commands.catalog`). */
|
||||
function renderCatalog(cat: unknown): string {
|
||||
if (!cat || typeof cat !== 'object') return ''
|
||||
const pairs = (cat as { pairs?: unknown }).pairs
|
||||
if (!Array.isArray(pairs)) return ''
|
||||
const lines = pairs
|
||||
.map(pair => {
|
||||
if (!Array.isArray(pair) || typeof pair[0] !== 'string') return null
|
||||
const desc = typeof pair[1] === 'string' ? pair[1] : ''
|
||||
return desc ? `${pair[0]} — ${desc}` : pair[0]
|
||||
})
|
||||
.filter((l): l is string => l !== null)
|
||||
return lines.length ? lines.join('\n') : ''
|
||||
}
|
||||
|
||||
function handleDispatchResult(parsed: ParsedSlash, raw: unknown, ctx: SlashContext): void {
|
||||
const type = readStr(raw, 'type')
|
||||
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
|
||||
switch (type) {
|
||||
case 'exec':
|
||||
case 'plugin':
|
||||
ctx.pushSystem(readStr(raw, 'output') || '(no output)')
|
||||
return
|
||||
case 'alias': {
|
||||
const target = readStr(raw, 'target')
|
||||
if (target) void dispatchSlash(`/${target}${argTail}`, ctx)
|
||||
return
|
||||
}
|
||||
case 'skill':
|
||||
case 'send': {
|
||||
const notice = readStr(raw, 'notice')
|
||||
if (notice) ctx.pushSystem(notice)
|
||||
const message = readStr(raw, 'message')
|
||||
if (message?.trim()) ctx.submit(message)
|
||||
else ctx.pushSystem(`/${parsed.name}: empty message`)
|
||||
return
|
||||
}
|
||||
case 'prefill': {
|
||||
// /undo etc. — composer prefill lands with the composer-ref plumbing; show it for now.
|
||||
const message = readStr(raw, 'message')
|
||||
ctx.pushSystem(message ? `(edit & resubmit) ${message}` : `/${parsed.name}: nothing to prefill`)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.pushSystem(`error: invalid response: command.dispatch`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a `/command` through the ladder. Returns once the (async) work settles. */
|
||||
export async function dispatchSlash(input: string, ctx: SlashContext): Promise<void> {
|
||||
const parsed = parseSlash(input)
|
||||
if (!parsed) return
|
||||
|
||||
const client = CLIENT[parsed.name]
|
||||
if (client) {
|
||||
await client(parsed.arg, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
const sid = ctx.sessionId()
|
||||
try {
|
||||
const result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
|
||||
const output = readStr(result, 'output') || `/${parsed.name}: no output`
|
||||
const warning = readStr(result, 'warning')
|
||||
ctx.pushSystem(warning ? `warning: ${warning}\n${output}` : output)
|
||||
} catch {
|
||||
try {
|
||||
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
||||
handleDispatchResult(parsed, raw, ctx)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,8 @@ export type ActivePrompt =
|
|||
| { kind: 'approval'; command: string; description: string }
|
||||
| { kind: 'sudo'; requestId: string }
|
||||
| { kind: 'secret'; envVar: string; prompt: string; requestId: string }
|
||||
// local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a)
|
||||
| { kind: 'confirm'; message: string; onConfirm: () => void }
|
||||
|
||||
export interface StoreState {
|
||||
ready: boolean
|
||||
|
|
@ -152,6 +154,25 @@ export function createSessionStore() {
|
|||
)
|
||||
}
|
||||
|
||||
/** Push a system line (slash output, errors, notices). */
|
||||
function pushSystem(text: string) {
|
||||
setState(
|
||||
produce(draft => {
|
||||
draft.messages.push({ role: 'system', text })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Clear the transcript (e.g. /clear, /new). */
|
||||
function clearTranscript() {
|
||||
setState('messages', [])
|
||||
}
|
||||
|
||||
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
|
||||
function setConfirm(message: string, onConfirm: () => void) {
|
||||
setState('prompt', { kind: 'confirm', message, onConfirm })
|
||||
}
|
||||
|
||||
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
|
||||
function apply(event: GatewayEvent): void {
|
||||
if (buffering) {
|
||||
|
|
@ -299,7 +320,17 @@ export function createSessionStore() {
|
|||
for (const event of pending) applyNow(event)
|
||||
}
|
||||
|
||||
return { state, apply, pushUser, hydrate, duplicate, clearPrompt } as const
|
||||
return {
|
||||
state,
|
||||
apply,
|
||||
pushUser,
|
||||
pushSystem,
|
||||
clearTranscript,
|
||||
setConfirm,
|
||||
hydrate,
|
||||
duplicate,
|
||||
clearPrompt
|
||||
} as const
|
||||
}
|
||||
|
||||
export type SessionStore = ReturnType<typeof createSessionStore>
|
||||
|
|
|
|||
111
ui-tui-opentui-v2/src/test/slash.test.ts
Normal file
111
ui-tui-opentui-v2/src/test/slash.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Slash dispatch test (spec §5 Layer 3/4). Pure logic: parse + the dispatch
|
||||
* ladder (client → slash.exec → command.dispatch) against a fake SlashContext.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts'
|
||||
|
||||
describe('parseSlash', () => {
|
||||
test('splits name + arg; rejects non-slash / empty', () => {
|
||||
expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' })
|
||||
expect(parseSlash('/model anthropic/claude')).toEqual({ name: 'model', arg: 'anthropic/claude' })
|
||||
expect(parseSlash('hello')).toBeNull()
|
||||
expect(parseSlash('/')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
interface Probe {
|
||||
ctx: SlashContext
|
||||
calls: Array<{ method: string; params: Record<string, unknown> }>
|
||||
system: string[]
|
||||
submitted: string[]
|
||||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||||
quit: { value: boolean }
|
||||
cleared: { value: boolean }
|
||||
}
|
||||
|
||||
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
|
||||
const calls: Probe['calls'] = []
|
||||
const system: string[] = []
|
||||
const submitted: string[] = []
|
||||
const confirmed: Probe['confirmed'] = []
|
||||
const quit = { value: false }
|
||||
const cleared = { value: false }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||
pushSystem: text => system.push(text),
|
||||
quit: () => (quit.value = true),
|
||||
request: (method, params) => {
|
||||
calls.push({ method, params })
|
||||
return request(method, params)
|
||||
},
|
||||
sessionId: () => 'sid-1',
|
||||
submit: text => submitted.push(text)
|
||||
}
|
||||
return { calls, cleared, confirmed, ctx, quit, submitted, system }
|
||||
}
|
||||
|
||||
describe('dispatchSlash — client commands', () => {
|
||||
test('/quit quits without hitting the gateway', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/quit', p.ctx)
|
||||
expect(p.quit.value).toBe(true)
|
||||
expect(p.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('/clear opens a confirm; running onConfirm clears the transcript', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/clear', p.ctx)
|
||||
expect(p.confirmed).toHaveLength(1)
|
||||
expect(p.cleared.value).toBe(false)
|
||||
p.confirmed[0]!.onConfirm()
|
||||
expect(p.cleared.value).toBe(true)
|
||||
})
|
||||
|
||||
test('/logs prints the recent ring lines', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/logs', p.ctx)
|
||||
expect(p.system.join('\n')).toContain('session created')
|
||||
})
|
||||
|
||||
test('/help renders the gateway catalog', async () => {
|
||||
const p = makeCtx(async method =>
|
||||
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
|
||||
)
|
||||
await dispatchSlash('/help', p.ctx)
|
||||
expect(p.calls[0]?.method).toBe('commands.catalog')
|
||||
expect(p.system.join('\n')).toContain('/model — switch model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchSlash — server ladder', () => {
|
||||
test('unknown command → slash.exec; output shown as a system line', async () => {
|
||||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
|
||||
await dispatchSlash('/status', p.ctx)
|
||||
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
|
||||
expect(p.system).toContain('all good')
|
||||
})
|
||||
|
||||
test('slash.exec rejects → command.dispatch; send result submits a user turn', async () => {
|
||||
const p = makeCtx(async method => {
|
||||
if (method === 'slash.exec') throw new Error('not a worker command')
|
||||
if (method === 'command.dispatch') return { type: 'send', message: 'run the thing' }
|
||||
return {}
|
||||
})
|
||||
await dispatchSlash('/dothing', p.ctx)
|
||||
expect(p.calls.map(c => c.method)).toEqual(['slash.exec', 'command.dispatch'])
|
||||
expect(p.submitted).toEqual(['run the thing'])
|
||||
})
|
||||
|
||||
test('command.dispatch exec → system output', async () => {
|
||||
const p = makeCtx(async method => {
|
||||
if (method === 'slash.exec') throw new Error('reject')
|
||||
return { type: 'exec', output: 'done' }
|
||||
})
|
||||
await dispatchSlash('/whatever', p.ctx)
|
||||
expect(p.system).toContain('done')
|
||||
})
|
||||
})
|
||||
28
ui-tui-opentui-v2/src/view/prompts/confirmPrompt.tsx
Normal file
28
ui-tui-opentui-v2/src/view/prompts/confirmPrompt.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* ConfirmPrompt — a LOCAL (non-gateway) Y/N dialog (spec §2a). Driven by a local
|
||||
* callback, not an RPC: y/Enter → confirm, n/Esc/Ctrl+C → cancel. Used by client
|
||||
* slash commands like /clear and /new.
|
||||
*/
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
export function ConfirmPrompt(props: { message: string; onYes: () => void; onNo: () => void }) {
|
||||
const theme = useTheme()
|
||||
useKeyboard(key => {
|
||||
if (key.name === 'y' || key.name === 'return') props.onYes()
|
||||
else if (key.name === 'n' || key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onNo()
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.warn}>
|
||||
<b>{props.message}</b>
|
||||
</text>
|
||||
<text fg={theme().color.muted}>y/Enter confirm · n/Esc cancel</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { Match, Switch } from 'solid-js'
|
|||
import type { SessionStore } from '../../logic/store.ts'
|
||||
import { ApprovalPrompt } from './approvalPrompt.tsx'
|
||||
import { ClarifyPrompt } from './clarifyPrompt.tsx'
|
||||
import { ConfirmPrompt } from './confirmPrompt.tsx'
|
||||
import { MaskedPrompt } from './maskedPrompt.tsx'
|
||||
|
||||
export interface PromptOverlayProps {
|
||||
|
|
@ -24,9 +25,13 @@ export interface PromptOverlayProps {
|
|||
|
||||
export function PromptOverlay(props: PromptOverlayProps) {
|
||||
const prompt = () => props.store.state.prompt
|
||||
// Defer the prompt-clear (which remounts + refocuses the composer) past the
|
||||
// CURRENT keystroke, so the key that answered the prompt (Enter/y/select) can't
|
||||
// leak into the freshly-focused composer (e.g. `/clear`→y left "y" in the input).
|
||||
const clearSoon = () => setTimeout(() => props.store.clearPrompt(), 0)
|
||||
const respond = (method: string, params: Record<string, unknown>) => {
|
||||
props.onRespond(method, params)
|
||||
props.store.clearPrompt()
|
||||
clearSoon()
|
||||
}
|
||||
|
||||
const asApproval = () => {
|
||||
|
|
@ -45,6 +50,10 @@ export function PromptOverlay(props: PromptOverlayProps) {
|
|||
const p = prompt()
|
||||
return p && p.kind === 'secret' ? p : undefined
|
||||
}
|
||||
const asConfirm = () => {
|
||||
const p = prompt()
|
||||
return p && p.kind === 'confirm' ? p : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
|
|
@ -89,6 +98,18 @@ export function PromptOverlay(props: PromptOverlayProps) {
|
|||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={asConfirm()}>
|
||||
{p => (
|
||||
<ConfirmPrompt
|
||||
message={p().message}
|
||||
onYes={() => {
|
||||
p().onConfirm()
|
||||
clearSoon()
|
||||
}}
|
||||
onNo={clearSoon}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue