mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
feat(web): add /api/pty WebSocket bridge to embed TUI in dashboard
Exposes hermes --tui over a PTY-backed WebSocket so the dashboard can
embed the real TUI rather than reimplement its surface. The browser
attaches xterm.js to the socket; keystrokes flow in, PTY output bytes
flow out.
Architecture:
browser <Terminal> (xterm.js)
│ onData ───► ws.send(keystrokes)
│ onResize ► ws.send('\x1b[RESIZE:cols;rows]')
│ write ◄── ws.onmessage (PTY bytes)
▼
FastAPI /api/pty (token-gated, loopback-only)
▼
PtyBridge (ptyprocess) ── spawns node ui-tui/dist/entry.js ──► tui_gateway + AIAgent
Components
----------
hermes_cli/pty_bridge.py
Thin wrapper around ptyprocess.PtyProcess: byte-safe read/write on the
master fd via os.read/os.write (not PtyProcessUnicode — ANSI is
inherently byte-oriented and UTF-8 boundaries may land mid-read),
non-blocking select-based reads, TIOCSWINSZ resize, idempotent
SIGHUP→SIGTERM→SIGKILL teardown, platform guard (POSIX-only; Windows
is WSL-supported only).
hermes_cli/web_server.py
@app.websocket("/api/pty") endpoint gated by the existing
_SESSION_TOKEN (via ?token= query param since browsers can't set
Authorization on WS upgrades). Loopback-only enforcement. Reader task
uses run_in_executor to pump PTY bytes without blocking the event
loop. Writer loop intercepts a custom \x1b[RESIZE:cols;rows] escape
before forwarding to the PTY. The endpoint resolves the TUI argv
through a _resolve_chat_argv hook so tests can inject fake commands
without building the real TUI.
Tests
-----
tests/hermes_cli/test_pty_bridge.py — 12 unit tests: spawn, stdout,
stdin round-trip, EOF, resize (via TIOCSWINSZ + tput readback), close
idempotency, cwd, env forwarding, unavailable-platform error.
tests/hermes_cli/test_web_server.py — TestPtyWebSocket adds 7 tests:
missing/bad token rejection (close code 4401), stdout streaming,
stdin round-trip, resize escape forwarding, unavailable-platform ANSI
error frame + 1011 close, resume parameter forwarding to argv.
96 tests pass under scripts/run_tests.sh.
(cherry picked from commit 29b337bca7)
feat(web): add Chat tab with xterm.js terminal + Sessions resume button
(cherry picked from commit 3d21aee8 by emozilla, conflicts resolved
against current main: BUILTIN_ROUTES table + plugin slot layout)
fix(tui): replace OSC 52 jargon in /copy confirmation
When the user ran /copy successfully, Ink confirmed with:
sent OSC52 copy sequence (terminal support required)
That reads like a protocol spec to everyone who isn't a terminal
implementer. The caveat was a historical artifact — OSC 52 wasn't
universally supported when this message was written, so the TUI
honestly couldn't guarantee the copy had landed anywhere.
Today every modern terminal (including the dashboard's embedded
xterm.js) handles OSC 52 reliably. Say what the user actually wants
to know — that it copied, and how much — matching the message the
TUI already uses for selection copy:
copied 1482 chars
(cherry picked from commit a0701b1d5a)
docs: document the dashboard Chat tab
AGENTS.md — new subsection under TUI Architecture explaining that the
dashboard embeds the real hermes --tui rather than rewriting it,
with pointers to the pty_bridge + WebSocket endpoint and the rule
'never add a parallel chat surface in React.'
website/docs/user-guide/features/web-dashboard.md — user-facing Chat
section inside the existing Web Dashboard page, covering how it works
(WebSocket + PTY + xterm.js), the Sessions-page resume flow, and
prerequisites (Node.js, ptyprocess, POSIX kernel / WSL on Windows).
(cherry picked from commit 2c2e32cc45)
feat(tui-gateway): transport-aware dispatch + WebSocket sidecar
Decouples the JSON-RPC dispatcher from its I/O sink so the same handler
surface can drive multiple transports concurrently. The PTY chat tab
already speaks to the TUI binary as bytes — this adds a structured
event channel alongside it for dashboard-side React widgets that need
typed events (tool.start/complete, model picker state, slash catalog)
that PTY can't surface.
- `tui_gateway/transport.py` — `Transport` protocol + `contextvars` binding
+ module-level `StdioTransport` fallback. The stdio stream resolves
through a lambda so existing tests that monkey-patch `_real_stdout`
keep passing without modification.
- `tui_gateway/ws.py` — WebSocket transport implementation; FastAPI
endpoint mounting lives in hermes_cli/web_server.py.
- `tui_gateway/server.py`:
- `write_json` routes via session transport (for async events) →
contextvar transport (for in-request writes) → stdio fallback.
- `dispatch(req, transport=None)` binds the transport for the request
lifetime and propagates it to pool workers via `contextvars.copy_context`
so async handlers don't lose their sink.
- `_init_session` and the manual-session create path stash the
request's transport so out-of-band events (subagent.complete, etc.)
fan out to the right peer.
`tui_gateway.entry` (Ink's stdio handshake) is unchanged externally —
it falls through every precedence step into the stdio fallback, byte-
identical to the previous behaviour.
feat(web): ChatSidebar — JSON-RPC sidecar next to xterm.js terminal
Composes the two transports into a single Chat tab:
┌─────────────────────────────────────────┬──────────────┐
│ xterm.js / PTY (emozilla #13379) │ ChatSidebar │
│ the literal hermes --tui process │ /api/ws │
└─────────────────────────────────────────┴──────────────┘
terminal bytes structured events
The terminal pane stays the canonical chat surface — full TUI fidelity,
slash commands, model picker, mouse, skin engine, wide chars all paint
inside the terminal. The sidebar opens a parallel JSON-RPC WebSocket
to the same gateway and renders metadata that PTY can't surface to
React chrome:
• model + provider badge with connection state (click → switch)
• running tool-call list (driven by tool.start / tool.progress /
tool.complete events)
• model picker dialog (gateway-driven, reuses ModelPickerDialog)
The sidecar is best-effort. If the WS can't connect (older gateway,
network hiccup, missing token) the terminal pane keeps working
unimpaired — sidebar just shows the connection-state badge in the
appropriate tone.
- `web/src/components/ChatSidebar.tsx` — new component (~270 lines).
Owns its GatewayClient, drives the model picker through
`slash.exec`, fans tool events into a capped tool list.
- `web/src/pages/ChatPage.tsx` — split layout: terminal pane
(`flex-1`) + sidebar (`w-80`, `lg+` only).
- `hermes_cli/web_server.py` — mount `/api/ws` (token + loopback
guards mirror /api/pty), delegate to `tui_gateway.ws.handle_ws`.
Co-authored-by: emozilla <emozilla@nousresearch.com>
refactor(web): /clean pass on ChatSidebar + ChatPage lint debt
- ChatSidebar: lift gw out of useRef into a useMemo derived from a
reconnect counter. React 19's react-hooks/refs and react-hooks/
set-state-in-effect rules both fire when you touch a ref during
render or call setState from inside a useEffect body. The
counter-derived gw is the canonical pattern for "external resource
that needs to be replaceable on user action" — re-creating the
client comes from bumping `version`, the effect just wires + tears
down. Drops the imperative `gwRef.current = …` reassign in
reconnect, drops the truthy ref guard in JSX. modelLabel +
banner inlined as derived locals (one-off useMemo was overkill).
- ChatPage: lazy-init the banner state from the missing-token check
so the effect body doesn't have to setState on first run. Drops
the unused react-hooks/exhaustive-deps eslint-disable. Adds a
scoped no-control-regex disable on the SGR mouse parser regex
(the \\x1b is intentional for xterm escape sequences).
All my-touched files now lint clean. Remaining warnings on web/
belong to pre-existing files this PR doesn't touch.
Verified: vitest 249/249, ui-tui eslint clean, web tsc clean,
python imports clean.
chore: uptick
fix(web): drop ChatSidebar tool list — events can't cross PTY/WS boundary
The /api/pty endpoint spawns `hermes --tui` as a child process with its
own tui_gateway and _sessions dict; /api/ws runs handle_ws in-process in
the dashboard server with a separate _sessions dict. Tool events fire on
the child's gateway and never reach the WS sidecar, so the sidebar's
tool.start/progress/complete listeners always observed an empty list.
Drop the misleading list (and the now-orphaned ToolCall primitive),
keep model badge + connection state + model picker + error banner —
those work because they're sidecar-local concerns. Surfacing tool calls
in the sidebar requires cross-process forwarding (PTY child opens a
back-WS to the dashboard, gateway tees emits onto stdio + sidecar
transport) — proper feature for a follow-up.
feat(web): wire ChatSidebar tool list to PTY child via /api/pub broadcast
The dashboard's /api/pty spawns hermes --tui as a child process; tool
events fire in the python tui_gateway grandchild and never crossed the
process boundary into the in-process WS sidecar — so the sidebar tool
list was always empty.
Cross-process forwarding:
- tui_gateway: TeeTransport (transport.py) + WsPublisherTransport
(event_publisher.py, sync websockets client). entry.py installs the
tee on _stdio_transport when HERMES_TUI_SIDECAR_URL is set, mirroring
every dispatcher emit to a back-WS without disturbing Ink's stdio
handshake.
- hermes_cli/web_server.py: new /api/pub (publisher) + /api/events
(subscriber) endpoints with a per-channel registry. /api/pty now
accepts ?channel= and propagates the sidecar URL via env. start_server
also stashes app.state.bound_port so the URL is constructable.
- web/src/pages/ChatPage.tsx: generates a channel UUID per mount,
passes it to /api/pty and as a prop to ChatSidebar.
- web/src/components/ChatSidebar.tsx: opens /api/events?channel=, fans
tool.start/progress/complete back into the ToolCall list. Restores
the ToolCall primitive.
Tests: 4 new TestPtyWebSocket cases cover channel propagation,
broadcast fan-out, and missing-channel rejection (10 PTY tests pass,
120 web_server tests overall).
fix(web): address Copilot review on #14890
Five threads, all real:
- gatewayClient.ts: register `message`/`close` listeners BEFORE awaiting
the open handshake. Server emits `gateway.ready` immediately after
accept, so a listener attached after the open promise could race past
the initial skin payload and lose it.
- ChatSidebar.tsx: wire `error`/`close` on the /api/events subscriber
WS into the existing error banner. 4401/4403 (auth/loopback reject)
surface as a "reload the page" message; mid-stream drops surface as
"events feed disconnected" with the existing reconnect button. Clean
unmount closes (1000/1001) stay silent.
- web-dashboard.md: install hint was `pip install hermes-agent[web]` but
ptyprocess lives in the `pty` extra, not `web`. Switch to
`hermes-agent[web,pty]` in both prerequisite blocks.
- AGENTS.md: previous "never add a parallel React chat surface" guidance
was overbroad and contradicted this PR's sidebar. Tightened to forbid
re-implementing the transcript/composer/PTY terminal while explicitly
allowing structured supporting widgets (sidebar / model picker /
inspectors), matching the actual architecture.
- web/package-lock.json: regenerated cleanly so the wterm sibling
workspace paths (extraneous machine-local entries) stop polluting CI.
Tests: 249/249 vitest, 10/10 PTY/events, web tsc clean.
refactor(web): /clean pass on ChatSidebar events handler
Spotted in the round-2 review:
- Banner flashed on clean unmount: `ws.close()` from the effect cleanup
fires `close` with code 1005, opened=true, neither 1000 nor 1001 —
hit the "unexpected drop" branch. Track `unmounting` in the effect
scope and gate the banner through a `surface()` helper so cleanup
closes stay silent.
- DRY the duplicated "events feed disconnected" string into a local
const used by both the error and close handlers.
- Drop the `opened` flag (no longer needed once the unmount guard is
the source of truth for "is this an expected close?").
This commit is contained in:
360
web/src/components/ChatSidebar.tsx
Normal file
360
web/src/components/ChatSidebar.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* ChatSidebar — structured-events panel that sits next to the xterm.js
|
||||
* terminal in the dashboard Chat tab.
|
||||
*
|
||||
* Two WebSockets, one per concern:
|
||||
*
|
||||
* 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — drives the
|
||||
* sidebar's own slot of the dashboard's in-process gateway. Owns
|
||||
* the model badge / picker / connection state / error banner.
|
||||
* Independent of the PTY pane's session by design — those are the
|
||||
* pieces the sidebar needs to be able to drive directly (model
|
||||
* switch via slash.exec, etc.).
|
||||
*
|
||||
* 2. **Event subscriber** (/api/events?channel=…) — passive, receives
|
||||
* every dispatcher emit from the PTY-side `tui_gateway.entry` that
|
||||
* the dashboard fanned out. This is how `tool.start/progress/
|
||||
* complete` from the agent loop reach the sidebar even though the
|
||||
* PTY child runs three processes deep from us. The `channel` id
|
||||
* ties this listener to the same chat tab's PTY child — see
|
||||
* `ChatPage.tsx` for where the id is generated.
|
||||
*
|
||||
* Best-effort throughout: WS failures show in the badge / banner, the
|
||||
* terminal pane keeps working unimpaired.
|
||||
*/
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||
|
||||
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface SessionInfo {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
credential_warning?: string;
|
||||
}
|
||||
|
||||
interface RpcEnvelope {
|
||||
method?: string;
|
||||
params?: { type?: string; payload?: unknown };
|
||||
}
|
||||
|
||||
const TOOL_LIMIT = 20;
|
||||
|
||||
const STATE_LABEL: Record<ConnectionState, string> = {
|
||||
idle: "idle",
|
||||
connecting: "connecting",
|
||||
open: "live",
|
||||
closed: "closed",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const STATE_TONE: Record<ConnectionState, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
connecting: "bg-primary/10 text-primary",
|
||||
open: "bg-emerald-500/10 text-emerald-500 dark:text-emerald-400",
|
||||
closed: "bg-muted text-muted-foreground",
|
||||
error: "bg-destructive/10 text-destructive",
|
||||
};
|
||||
|
||||
interface ChatSidebarProps {
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export function ChatSidebar({ channel }: ChatSidebarProps) {
|
||||
// `version` bumps on reconnect; gw is derived so we never call setState
|
||||
// for it inside an effect (React 19's set-state-in-effect rule). The
|
||||
// counter is the dependency on purpose — it's not read in the memo body,
|
||||
// it's the signal that says "rebuild the client".
|
||||
const [version, setVersion] = useState(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const gw = useMemo(() => new GatewayClient(), [version]);
|
||||
|
||||
const [state, setState] = useState<ConnectionState>("idle");
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<SessionInfo>({});
|
||||
const [tools, setTools] = useState<ToolEntry[]>([]);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const offState = gw.onState(setState);
|
||||
|
||||
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
|
||||
if (ev.session_id) {
|
||||
setSessionId(ev.session_id);
|
||||
}
|
||||
|
||||
if (ev.payload) {
|
||||
setInfo((prev) => ({ ...prev, ...ev.payload }));
|
||||
}
|
||||
});
|
||||
|
||||
const offError = gw.on<{ message?: string }>("error", (ev) => {
|
||||
const message = ev.payload?.message;
|
||||
|
||||
if (message) {
|
||||
setError(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Adopt whichever session the gateway hands us. session.create on the
|
||||
// sidecar is independent of the PTY pane's session by design — we
|
||||
// only need a sid to drive the model picker's slash.exec calls.
|
||||
gw.connect()
|
||||
.then(() => gw.request<{ session_id: string }>("session.create", {}))
|
||||
.then((created) => {
|
||||
if (created?.session_id) {
|
||||
setSessionId(created.session_id);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
|
||||
return () => {
|
||||
offState();
|
||||
offSessionInfo();
|
||||
offError();
|
||||
gw.close();
|
||||
};
|
||||
}, [gw]);
|
||||
|
||||
// Event subscriber WebSocket — receives the rebroadcast of every
|
||||
// dispatcher emit from the PTY child's gateway. See /api/pub +
|
||||
// /api/events in hermes_cli/web_server.py for the broadcast hop.
|
||||
//
|
||||
// Failures (auth/loopback rejection, server too old to expose the
|
||||
// endpoint, transient drops) surface in the same banner as the
|
||||
// JSON-RPC sidecar so the sidebar matches its documented best-effort
|
||||
// UX and the user always has a reconnect affordance.
|
||||
useEffect(() => {
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
|
||||
if (!token || !channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ token, channel });
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${window.location.host}/api/events?${qs.toString()}`,
|
||||
);
|
||||
|
||||
// `unmounting` suppresses the banner during cleanup — `ws.close()`
|
||||
// from the effect's return fires a close event with code 1005 that
|
||||
// would otherwise look like an unexpected drop.
|
||||
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
|
||||
let unmounting = false;
|
||||
const surface = (msg: string) => !unmounting && setError(msg);
|
||||
|
||||
ws.addEventListener("error", () => surface(DISCONNECTED));
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
if (ev.code === 4401 || ev.code === 4403) {
|
||||
surface(`events feed rejected (${ev.code}) — reload the page`);
|
||||
} else if (ev.code !== 1000) {
|
||||
surface(DISCONNECTED);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
let frame: RpcEnvelope;
|
||||
|
||||
try {
|
||||
frame = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method !== "event" || !frame.params) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = frame.params;
|
||||
|
||||
if (type === "tool.start") {
|
||||
const p = payload as
|
||||
| { tool_id?: string; name?: string; context?: string }
|
||||
| undefined;
|
||||
const toolId = p?.tool_id;
|
||||
|
||||
if (!toolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTools((prev) =>
|
||||
[
|
||||
...prev,
|
||||
{
|
||||
kind: "tool" as const,
|
||||
id: `tool-${toolId}-${prev.length}`,
|
||||
tool_id: toolId,
|
||||
name: p?.name ?? "tool",
|
||||
context: p?.context,
|
||||
status: "running" as const,
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
].slice(-TOOL_LIMIT),
|
||||
);
|
||||
} else if (type === "tool.progress") {
|
||||
const p = payload as
|
||||
| { name?: string; preview?: string }
|
||||
| undefined;
|
||||
|
||||
if (!p?.name || !p.preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTools((prev) =>
|
||||
prev.map((t) =>
|
||||
t.status === "running" && t.name === p.name
|
||||
? { ...t, preview: p.preview }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
} else if (type === "tool.complete") {
|
||||
const p = payload as
|
||||
| {
|
||||
tool_id?: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
inline_diff?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!p?.tool_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTools((prev) =>
|
||||
prev.map((t) =>
|
||||
t.tool_id === p.tool_id
|
||||
? {
|
||||
...t,
|
||||
status: p.error ? "error" : "done",
|
||||
summary: p.summary,
|
||||
error: p.error,
|
||||
inline_diff: p.inline_diff,
|
||||
completedAt: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
ws.close();
|
||||
};
|
||||
}, [channel, version]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
setError(null);
|
||||
setTools([]);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
|
||||
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
|
||||
// via PTY, so the sidebar doesn't need to surface output of its own.
|
||||
const onModelSubmit = useCallback(
|
||||
(slashCommand: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void gw.request("slash.exec", {
|
||||
session_id: sessionId,
|
||||
command: slashCommand,
|
||||
});
|
||||
setModelOpen(false);
|
||||
},
|
||||
[gw, sessionId],
|
||||
);
|
||||
|
||||
const canPickModel = state === "open" && !!sessionId;
|
||||
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
|
||||
const banner = error ?? info.credential_warning ?? null;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-80 shrink-0 flex-col gap-3 normal-case">
|
||||
<Card className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
model
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canPickModel}
|
||||
onClick={() => setModelOpen(true)}
|
||||
className="flex items-center gap-1 truncate text-sm font-medium hover:underline disabled:cursor-not-allowed disabled:opacity-60 disabled:no-underline"
|
||||
title={info.model ?? "switch model"}
|
||||
>
|
||||
<span className="truncate">{modelLabel}</span>
|
||||
|
||||
{canPickModel && (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-60" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Badge className={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
|
||||
</Card>
|
||||
|
||||
{banner && (
|
||||
<Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="wrap-break-word text-destructive">{banner}</div>
|
||||
|
||||
{error && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-6 px-1.5 text-xs"
|
||||
onClick={reconnect}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="flex min-h-0 flex-1 flex-col px-2 py-2">
|
||||
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
tools
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||
{tools.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
no tool calls yet
|
||||
</div>
|
||||
) : (
|
||||
tools.map((t) => <ToolCall key={t.id} tool={t} />)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{modelOpen && canPickModel && sessionId && (
|
||||
<ModelPickerDialog
|
||||
gw={gw}
|
||||
sessionId={sessionId}
|
||||
onClose={() => setModelOpen(false)}
|
||||
onSubmit={onModelSubmit}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer for LLM output.
|
||||
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
||||
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
||||
*
|
||||
* `streaming` renders a blinking caret at the tail of the last block so it
|
||||
* appears to hug the final character instead of wrapping onto a new line
|
||||
* after a block element (paragraph/list/code/…).
|
||||
*/
|
||||
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
|
||||
export function Markdown({
|
||||
content,
|
||||
highlightTerms,
|
||||
streaming,
|
||||
}: {
|
||||
content: string;
|
||||
highlightTerms?: string[];
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||
const caret = streaming ? <StreamingCaret /> : null;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
||||
{blocks.map((block, i) => (
|
||||
<Block key={i} block={block} highlightTerms={highlightTerms} />
|
||||
<Block
|
||||
key={i}
|
||||
block={block}
|
||||
highlightTerms={highlightTerms}
|
||||
caret={caret && i === blocks.length - 1 ? caret : null}
|
||||
/>
|
||||
))}
|
||||
{blocks.length === 0 && caret}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingCaret() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -58,7 +86,11 @@ function parseBlocks(text: string): BlockNode[] {
|
||||
// Heading
|
||||
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||
if (headingMatch) {
|
||||
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
|
||||
blocks.push({
|
||||
type: "heading",
|
||||
level: headingMatch[1].length,
|
||||
content: headingMatch[2],
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -124,12 +156,23 @@ function parseBlocks(text: string): BlockNode[] {
|
||||
/* Block renderer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
|
||||
function Block({
|
||||
block,
|
||||
highlightTerms,
|
||||
caret,
|
||||
}: {
|
||||
block: BlockNode;
|
||||
highlightTerms?: string[];
|
||||
caret?: ReactNode;
|
||||
}) {
|
||||
switch (block.type) {
|
||||
case "code":
|
||||
return (
|
||||
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
||||
<code>{block.content}</code>
|
||||
<code>
|
||||
{block.content}
|
||||
{caret}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
@@ -141,25 +184,46 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
|
||||
h3: "text-sm font-semibold",
|
||||
h4: "text-sm font-medium",
|
||||
};
|
||||
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
|
||||
return (
|
||||
<Tag className={sizes[Tag]}>
|
||||
<InlineContent text={block.content} highlightTerms={highlightTerms} />
|
||||
{caret}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
case "hr":
|
||||
return <hr className="border-border" />;
|
||||
return (
|
||||
<>
|
||||
<hr className="border-border" />
|
||||
{caret}
|
||||
</>
|
||||
);
|
||||
|
||||
case "list": {
|
||||
const Tag = block.ordered ? "ol" : "ul";
|
||||
const last = block.items.length - 1;
|
||||
return (
|
||||
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
|
||||
<Tag
|
||||
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
|
||||
>
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
|
||||
<li key={i}>
|
||||
<InlineContent text={item} highlightTerms={highlightTerms} />
|
||||
{i === last ? caret : null}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
case "paragraph":
|
||||
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
|
||||
return (
|
||||
<p>
|
||||
<InlineContent text={block.content} highlightTerms={highlightTerms} />
|
||||
{caret}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +242,8 @@ type InlineNode =
|
||||
function parseInline(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = [];
|
||||
// Pattern priority: code > link > bold > italic > bare URL > line break
|
||||
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||
const pattern =
|
||||
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -217,7 +282,13 @@ function parseInline(text: string): InlineNode[] {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
|
||||
function InlineContent({
|
||||
text,
|
||||
highlightTerms,
|
||||
}: {
|
||||
text: string;
|
||||
highlightTerms?: string[];
|
||||
}) {
|
||||
const nodes = useMemo(() => parseInline(text), [text]);
|
||||
|
||||
return (
|
||||
@@ -225,17 +296,34 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
|
||||
{nodes.map((node, i) => {
|
||||
switch (node.type) {
|
||||
case "text":
|
||||
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
||||
return (
|
||||
<HighlightedText
|
||||
key={i}
|
||||
text={node.content}
|
||||
terms={highlightTerms}
|
||||
/>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<code key={i} className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
||||
<code
|
||||
key={i}
|
||||
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
|
||||
>
|
||||
{node.content}
|
||||
</code>
|
||||
);
|
||||
case "bold":
|
||||
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
|
||||
return (
|
||||
<strong key={i} className="font-semibold">
|
||||
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||
</strong>
|
||||
);
|
||||
case "italic":
|
||||
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
|
||||
return (
|
||||
<em key={i}>
|
||||
<HighlightedText text={node.content} terms={highlightTerms} />
|
||||
</em>
|
||||
);
|
||||
case "link":
|
||||
return (
|
||||
<a
|
||||
@@ -269,10 +357,12 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-warning/30 text-warning px-0.5">{part}</mark>
|
||||
<mark key={i} className="bg-warning/30 text-warning px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
392
web/src/components/ModelPickerDialog.tsx
Normal file
392
web/src/components/ModelPickerDialog.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Loader2, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Two-stage model picker modal.
|
||||
*
|
||||
* Mirrors ui-tui/src/components/modelPicker.tsx:
|
||||
* Stage 1: pick provider (authenticated providers only)
|
||||
* Stage 2: pick model within that provider
|
||||
*
|
||||
* On confirm, emits `/model <model> --provider <slug> [--global]` through
|
||||
* the parent callback so ChatPage can dispatch it via the existing slash
|
||||
* pipeline. That keeps persistence + actual switch logic in one place.
|
||||
*/
|
||||
|
||||
interface ModelOptionProvider {
|
||||
name: string;
|
||||
slug: string;
|
||||
models?: string[];
|
||||
total_models?: number;
|
||||
is_current?: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface ModelOptionsResponse {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
providers?: ModelOptionProvider[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gw: GatewayClient;
|
||||
sessionId: string;
|
||||
onClose(): void;
|
||||
/** Parent runs the resulting slash command through slashExec. */
|
||||
onSubmit(slashCommand: string): void;
|
||||
}
|
||||
|
||||
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
|
||||
const [currentModel, setCurrentModel] = useState("");
|
||||
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSlug, setSelectedSlug] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [persistGlobal, setPersistGlobal] = useState(false);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
// Load providers + models on open.
|
||||
useEffect(() => {
|
||||
closedRef.current = false;
|
||||
|
||||
gw.request<ModelOptionsResponse>(
|
||||
"model.options",
|
||||
sessionId ? { session_id: sessionId } : {},
|
||||
)
|
||||
.then((r) => {
|
||||
if (closedRef.current) return;
|
||||
const next = r?.providers ?? [];
|
||||
setProviders(next);
|
||||
setCurrentModel(String(r?.model ?? ""));
|
||||
setCurrentProviderSlug(String(r?.provider ?? ""));
|
||||
setSelectedSlug(
|
||||
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
|
||||
);
|
||||
setSelectedModel("");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (closedRef.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
closedRef.current = true;
|
||||
};
|
||||
}, [gw, sessionId]);
|
||||
|
||||
// Esc closes.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const selectedProvider = useMemo(
|
||||
() => providers.find((p) => p.slug === selectedSlug) ?? null,
|
||||
[providers, selectedSlug],
|
||||
);
|
||||
|
||||
const models = useMemo(
|
||||
() => selectedProvider?.models ?? [],
|
||||
[selectedProvider],
|
||||
);
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
const filteredProviders = useMemo(
|
||||
() =>
|
||||
!needle
|
||||
? providers
|
||||
: providers.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(needle) ||
|
||||
p.slug.toLowerCase().includes(needle) ||
|
||||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
|
||||
),
|
||||
[providers, needle],
|
||||
);
|
||||
|
||||
const filteredModels = useMemo(
|
||||
() =>
|
||||
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
|
||||
[models, needle],
|
||||
);
|
||||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel;
|
||||
|
||||
const confirm = () => {
|
||||
if (!canConfirm) return;
|
||||
const global = persistGlobal ? " --global" : "";
|
||||
onSubmit(
|
||||
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="model-picker-title"
|
||||
>
|
||||
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="model-picker-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
>
|
||||
Switch Model
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
current: {currentModel || "(unknown)"}
|
||||
{currentProviderSlug && ` · ${currentProviderSlug}`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="px-5 pt-3 pb-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter providers and models…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
|
||||
<ProviderColumn
|
||||
loading={loading}
|
||||
error={error}
|
||||
providers={filteredProviders}
|
||||
total={providers.length}
|
||||
selectedSlug={selectedSlug}
|
||||
query={needle}
|
||||
onSelect={(slug) => {
|
||||
setSelectedSlug(slug);
|
||||
setSelectedModel("");
|
||||
}}
|
||||
/>
|
||||
|
||||
<ModelColumn
|
||||
provider={selectedProvider}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
selectedModel={selectedModel}
|
||||
currentModel={currentModel}
|
||||
currentProviderSlug={currentProviderSlug}
|
||||
onSelect={setSelectedModel}
|
||||
onConfirm={(m) => {
|
||||
setSelectedModel(m);
|
||||
// Confirm on next tick so state settles.
|
||||
window.setTimeout(confirm, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={persistGlobal}
|
||||
onChange={(e) => setPersistGlobal(e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Persist globally (otherwise this session only)
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirm} disabled={!canConfirm}>
|
||||
Switch
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider column */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ProviderColumn({
|
||||
loading,
|
||||
error,
|
||||
providers,
|
||||
total,
|
||||
selectedSlug,
|
||||
query,
|
||||
onSelect,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
providers: ModelOptionProvider[];
|
||||
total: number;
|
||||
selectedSlug: string;
|
||||
query: string;
|
||||
onSelect(slug: string): void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-r border-border overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
|
||||
|
||||
{!loading && !error && providers.length === 0 && (
|
||||
<div className="p-4 text-xs text-muted-foreground italic">
|
||||
{query
|
||||
? "no matches"
|
||||
: total === 0
|
||||
? "no authenticated providers"
|
||||
: "no matches"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providers.map((p) => {
|
||||
const active = p.slug === selectedSlug;
|
||||
return (
|
||||
<button
|
||||
key={p.slug}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.slug)}
|
||||
className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${
|
||||
active
|
||||
? "bg-primary/10 border-l-primary text-foreground"
|
||||
: "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium truncate">{p.name}</span>
|
||||
{p.is_current && <CurrentTag />}
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
|
||||
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Model column */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ModelColumn({
|
||||
provider,
|
||||
models,
|
||||
allModels,
|
||||
selectedModel,
|
||||
currentModel,
|
||||
currentProviderSlug,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: {
|
||||
provider: ModelOptionProvider | null;
|
||||
models: string[];
|
||||
allModels: string[];
|
||||
selectedModel: string;
|
||||
currentModel: string;
|
||||
currentProviderSlug: string;
|
||||
onSelect(model: string): void;
|
||||
onConfirm(model: string): void;
|
||||
}) {
|
||||
if (!provider) {
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<div className="p-4 text-xs text-muted-foreground italic">
|
||||
pick a provider →
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
{provider.warning && (
|
||||
<div className="p-3 text-xs text-destructive border-b border-border">
|
||||
{provider.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="p-4 text-xs text-muted-foreground italic">
|
||||
{allModels.length
|
||||
? "no models match your filter"
|
||||
: "no models listed for this provider"}
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
const active = m === selectedModel;
|
||||
const isCurrent =
|
||||
m === currentModel && provider.slug === currentProviderSlug;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onSelect(m)}
|
||||
onDoubleClick={() => onConfirm(m)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${
|
||||
active
|
||||
? "bg-primary/15 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate">{m}</span>
|
||||
{isCurrent && <CurrentTag />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentTag() {
|
||||
return (
|
||||
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
|
||||
current
|
||||
</span>
|
||||
);
|
||||
}
|
||||
174
web/src/components/SlashPopover.tsx
Normal file
174
web/src/components/SlashPopover.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
|
||||
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
|
||||
* arrow keys or click to select, Tab to apply, Enter to submit.
|
||||
*
|
||||
* The parent owns all keyboard handling via `ref.handleKey`, which returns
|
||||
* true when the popover consumed the event, so the composer's Enter/arrow
|
||||
* logic stays in one place.
|
||||
*/
|
||||
|
||||
export interface CompletionItem {
|
||||
display: string;
|
||||
text: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface SlashPopoverHandle {
|
||||
/** Returns true if the key was consumed by the popover. */
|
||||
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
input: string;
|
||||
gw: GatewayClient | null;
|
||||
onApply(nextInput: string): void;
|
||||
}
|
||||
|
||||
interface CompletionResponse {
|
||||
items?: CompletionItem[];
|
||||
replace_from?: number;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 60;
|
||||
|
||||
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
|
||||
function SlashPopover({ input, gw, onApply }, ref) {
|
||||
const [items, setItems] = useState<CompletionItem[]>([]);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [replaceFrom, setReplaceFrom] = useState(1);
|
||||
const lastInputRef = useRef<string>("");
|
||||
|
||||
// Debounced completion fetch. We never clear `items` in the effect body
|
||||
// (doing so would flag react-hooks/set-state-in-effect); instead the
|
||||
// render guard below hides stale items once the input stops matching.
|
||||
useEffect(() => {
|
||||
const trimmed = input ?? "";
|
||||
|
||||
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
|
||||
if (!trimmed.startsWith("/")) lastInputRef.current = "";
|
||||
return;
|
||||
}
|
||||
lastInputRef.current = trimmed;
|
||||
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (lastInputRef.current !== trimmed) return;
|
||||
try {
|
||||
const r = await gw.request<CompletionResponse>("complete.slash", {
|
||||
text: trimmed,
|
||||
});
|
||||
if (lastInputRef.current !== trimmed) return;
|
||||
setItems(r?.items ?? []);
|
||||
setReplaceFrom(r?.replace_from ?? 1);
|
||||
setSelected(0);
|
||||
} catch {
|
||||
if (lastInputRef.current === trimmed) setItems([]);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [input, gw]);
|
||||
|
||||
const apply = useCallback(
|
||||
(item: CompletionItem) => {
|
||||
onApply(input.slice(0, replaceFrom) + item.text);
|
||||
},
|
||||
[input, replaceFrom, onApply],
|
||||
);
|
||||
|
||||
// Only consume keys when the popover is actually visible. Stale items from
|
||||
// a previous slash prefix are ignored once the user deletes the "/".
|
||||
const visible = items.length > 0 && input.startsWith("/");
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleKey: (e) => {
|
||||
if (!visible) return false;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelected((s) => (s + 1) % items.length);
|
||||
return true;
|
||||
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelected((s) => (s - 1 + items.length) % items.length);
|
||||
return true;
|
||||
|
||||
case "Tab": {
|
||||
e.preventDefault();
|
||||
const item = items[selected];
|
||||
if (item) apply(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setItems([]);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[visible, items, selected, apply],
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
|
||||
role="listbox"
|
||||
>
|
||||
{items.map((it, i) => {
|
||||
const active = i === selected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${it.text}-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
onClick={() => apply(it)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors ${
|
||||
active
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||
/>
|
||||
|
||||
<span className="font-mono text-xs shrink-0 truncate">
|
||||
{it.display}
|
||||
</span>
|
||||
|
||||
{it.meta && (
|
||||
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
|
||||
{it.meta}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
228
web/src/components/ToolCall.tsx
Normal file
228
web/src/components/ToolCall.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
|
||||
*
|
||||
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
|
||||
* in between) as a single collapsible item in the transcript:
|
||||
*
|
||||
* ▸ ● read_file(path=/foo) 2.3s
|
||||
*
|
||||
* Click the header to reveal a preformatted body with context (args), the
|
||||
* streaming preview (while running), and the final summary or error. Error
|
||||
* rows auto-expand so failures aren't silently collapsed.
|
||||
*/
|
||||
|
||||
export interface ToolEntry {
|
||||
kind: "tool";
|
||||
id: string;
|
||||
tool_id: string;
|
||||
name: string;
|
||||
context?: string;
|
||||
preview?: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
inline_diff?: string;
|
||||
status: "running" | "done" | "error";
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<ToolEntry["status"], string> = {
|
||||
running: "border-primary/40 bg-primary/[0.04]",
|
||||
done: "border-border bg-muted/20",
|
||||
error: "border-destructive/50 bg-destructive/[0.04]",
|
||||
};
|
||||
|
||||
const BULLET_TONE: Record<ToolEntry["status"], string> = {
|
||||
running: "text-primary",
|
||||
done: "text-primary/80",
|
||||
error: "text-destructive",
|
||||
};
|
||||
|
||||
const TICK_MS = 500;
|
||||
|
||||
export function ToolCall({ tool }: { tool: ToolEntry }) {
|
||||
// `open` is derived: errors default-expanded, everything else collapsed.
|
||||
// `null` means "follow the default"; any explicit bool is the user's override.
|
||||
// This lets a running tool flip to expanded automatically when it errors,
|
||||
// without mirroring state in an effect.
|
||||
const [userOverride, setUserOverride] = useState<boolean | null>(null);
|
||||
const open = userOverride ?? tool.status === "error";
|
||||
|
||||
// Tick `now` while the tool is running so the elapsed label updates live.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (tool.status !== "running") return;
|
||||
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [tool.status]);
|
||||
|
||||
// Historical tools (hydrated from session.resume) signal missing timestamps
|
||||
// with `startedAt === 0`; we hide the elapsed badge for those rather than
|
||||
// rendering a misleading "0ms".
|
||||
const hasTimestamps = tool.startedAt > 0;
|
||||
const elapsed = hasTimestamps
|
||||
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
|
||||
: null;
|
||||
|
||||
const hasBody = !!(
|
||||
tool.context ||
|
||||
tool.preview ||
|
||||
tool.summary ||
|
||||
tool.error ||
|
||||
tool.inline_diff
|
||||
);
|
||||
|
||||
const Chevron = open ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserOverride(!open)}
|
||||
disabled={!hasBody}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-foreground/2 disabled:cursor-default cursor-pointer transition-colors"
|
||||
>
|
||||
{hasBody ? (
|
||||
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
|
||||
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
|
||||
|
||||
<span className="font-mono font-medium shrink-0">{tool.name}</span>
|
||||
|
||||
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
|
||||
{tool.context ?? ""}
|
||||
</span>
|
||||
|
||||
{tool.status === "running" && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
|
||||
title="running"
|
||||
/>
|
||||
)}
|
||||
{tool.status === "error" && (
|
||||
<AlertCircle
|
||||
className="h-3 w-3 shrink-0 text-destructive"
|
||||
aria-label="error"
|
||||
/>
|
||||
)}
|
||||
{tool.status === "done" && (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-primary/80"
|
||||
aria-label="done"
|
||||
/>
|
||||
)}
|
||||
|
||||
{elapsed && (
|
||||
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && hasBody && (
|
||||
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
|
||||
{tool.context && <Section label="context">{tool.context}</Section>}
|
||||
|
||||
{tool.preview && tool.status === "running" && (
|
||||
<Section label="streaming">
|
||||
{tool.preview}
|
||||
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tool.inline_diff && (
|
||||
<Section label="diff">
|
||||
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
|
||||
{colorizeDiff(tool.inline_diff)}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tool.summary && (
|
||||
<Section label="result">
|
||||
<span className="text-foreground/90 whitespace-pre-wrap">
|
||||
{tool.summary}
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{tool.error && (
|
||||
<Section label="error" tone="error">
|
||||
<span className="text-destructive whitespace-pre-wrap">
|
||||
{tool.error}
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
tone?: "error";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
|
||||
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtElapsed(ms: number): string {
|
||||
const sec = Math.max(0, ms) / 1000;
|
||||
if (sec < 1) return `${Math.round(ms)}ms`;
|
||||
if (sec < 10) return `${sec.toFixed(1)}s`;
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
return s ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Colorize unified-diff lines for the inline diff section. */
|
||||
function colorizeDiff(diff: string): React.ReactNode {
|
||||
return diff.split("\n").map((line, i) => (
|
||||
<div key={i} className={diffLineClass(line)}>
|
||||
{line || "\u00A0"}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function diffLineClass(line: string): string {
|
||||
if (line.startsWith("+") && !line.startsWith("+++"))
|
||||
return "text-emerald-500 dark:text-emerald-400";
|
||||
if (line.startsWith("-") && !line.startsWith("---"))
|
||||
return "text-destructive";
|
||||
if (line.startsWith("@@")) return "text-primary";
|
||||
return "text-muted-foreground/80";
|
||||
}
|
||||
Reference in New Issue
Block a user