| name | app |
| description | Guide for the Kimix web frontend (src/app/) — vanilla TypeScript + Vite, SSE-based chat UI mirroring sse_cli.py. Use when modifying the frontend, adding new UI features, changing the API client, or understanding the message rendering pipeline. |
Kimix Web Frontend (src/app/)
Overview
┌────────────────────────────────────────────────────────────────────┐
│ src/app/ (Vanilla TypeScript + Vite) │
│ │
│ index.html ──► src/main.ts (app glue, DOM, poll loop) │
│ src/client.ts (HTTP + SSE client) │
│ src/types.ts (wire format parsing) │
│ src/renderer.ts (message → HTML) │
│ src/styles.css (Catppuccin Mocha theme) │
│ src/parse_test.ts (unit tests for parsing) │
└────────────────────────────────────────────────────────────────────┘
This is a zero-dependency (except Vite + TypeScript dev tooling) web UI that mirrors the Python sse_cli.py terminal client. It connects to the backend on port 4096 and provides a chat interface with poll-based SSE message streaming.
Start:
cd src/app
npm run dev
npm run build
npm run preview
Or via the monorepo runner:
uv run kimix gui
uv run kimix gui --build
uv run kimix gui --fe-port 3000
uv run kimix gui --no-fe
uv run scripts/run_app.py
Environment Setup (Installation Experience)
First-time setup from a clean clone:
cd src/app
npm install
npm run dev
Key observations:
- Zero runtime dependencies — only
typescript and vite as dev tooling. No framework, no React, no Vue.
- Install is fast —
npm install completes in ~2 seconds due to minimal dependency tree.
- Dev server starts instantly — Vite ready in ~300ms. Hot module replacement (HMR) is active.
- No build step needed for development — Vite serves TypeScript directly.
- The frontend connects to backend on port 4096 — you need the backend server running separately (see
serve skill).
If you encounter issues:
npm install fails → check Node.js version (requires Node >=18).
- Port 5173 in use → modify
vite.config.ts or use --port flag.
- Frontend can't connect → ensure backend is running on port 4096, check the connection panel defaults.
File Map
| File | Role | Lines |
|---|
index.html | Entry HTML — connection panel, command bar, output area, input bar | 66 |
package.json | npm config — dev / build / preview scripts, TS+Vite deps | 16 |
tsconfig.json | TS config — ES2022, strict, bundler resolution | 17 |
vite.config.ts | Vite config — dev port 5173, sourcemaps, dist/ output | 14 |
src/types.ts | Types, enums, and wire-format parsers (parseMessage, parseMessagePart, parseSession) | 175 |
src/client.ts | KimixClient — fetch()-based HTTP client + EventSource SSE | 169 |
src/renderer.ts | renderMessagePart() — MessagePart → HTML <span> element | 134 |
src/main.ts | Application glue — DOM wiring, connect/disconnect, poll loop, slash commands | 421 |
src/styles.css | Catppuccin Mocha theme — CSS custom properties, part-type styling | 335 |
src/parse_test.ts | Standalone parse tests (11 tests, both wire formats) | 343 |
Architecture
Data Flow
User types "hello" → sendPrompt()
→ client.sendPromptAsync(sessionId, "hello") // POST /session/{id}/prompt_async → 204
→ startPolling() // setInterval(pollMessages, 500ms)
↓
pollMessages()
→ client.getMessages(sessionId, 50) // GET /session/{id}/message?limit=50
→ resp.json().map(parseMessage) // detects & parses both wire formats
→ for each new message part:
renderMessagePart(part) → HTML element
appendPart(el) → add to output area
→ idle detection: 4 empty polls (2s) → check status → stop polling if idle
State Machine
Disconnected ──[Connect]──► Connected ──[Send]──► Streaming ──[Idle]──► Connected
▲ │ ▲
└──────[Disconnect]───────┘ │
│
Error ──────────────────┘
Key state variables in main.ts:
client: KimixClient | null — HTTP client instance
session: Session | null — current session
pollTimer — setInterval handle for message polling
seenMessageCount — cursor for incremental message display
emptyPolls — consecutive polls with no new messages (idle detection)
connected / debugMode — UI flags
Type System (src/types.ts)
MessagePartType Enum
enum MessagePartType {
TEXT = "text",
THINKING = "thinking",
TOOL_CALLING = "tool_calling",
TOOL_CALLING_PART = "tool_calling_part",
TOOL_RESULT = "tool_result",
STEP_START = "step-start",
STEP_FINISH = "step-finish",
UNKNOWN = "unknown",
}
Core Interfaces
interface MessagePart {
type: MessagePartType;
text: string | null;
tool_name: string | null;
tool_status: string | null;
tool_state: Record<string, unknown> | null;
tool_result: string | null;
call_id: string | null;
reason: string | null;
cost: number | null;
tokens: Record<string, unknown> | null;
raw_data: Record<string, unknown> | null;
}
interface Message {
id: string;
role: string;
parts: MessagePart[];
created_at: number | null;
text_content: string;
}
interface Session {
id: string;
title: string | null;
created_at: number | null;
updated_at: number | null;
parent_id: string | null;
}
Wire Format Detection
parseMessage() detects the backend format:
Dummy format (from DummySessionManager): data.info is absent → uses DUMMY_TYPE_MAP:
DUMMY_TYPE_MAP = {
Text → TEXT, Thinking → THINKING, ToolCalling → TOOL_CALLING,
ToolCallingPart → TOOL_CALLING_PART, ToolResult → TOOL_RESULT
}
{"type": "Text", "text": "Hello", "time": 1234567890.123}
Opencode format (from SessionManager): data.info present → iterates data.parts, uses OPENCODE_TYPE_MAP:
OPENCODE_TYPE_MAP = { tool → TOOL_CALLING, reasoning → THINKING }
{
"info": {"id": "msg_001", "role": "assistant", "time": {"created": 123}},
"parts": [
{"id": "prt_001", "type": "text", "text": "Hello"},
{"id": "prt_002", "type": "tool", "tool": "read", "callID": "toolu_001",
"state": {"status": "running", "input": {"path": "/foo"}}}
]
}
API Client (src/client.ts)
KimixClient wraps all backend REST endpoints. Base URL: http://{host}:{port}.
| Method | HTTP | Endpoint |
|---|
healthCheck() | GET | /global/health |
createSession(title?) | POST | /session |
getSession(id) | GET | /session/{id} |
deleteSession(id) | DELETE | /session/{id} |
listSessions() | GET | /session |
getMessages(id, limit?) | GET | /session/{id}/message?limit=N |
getSessionStatus(id) | GET | /session/{id}/status |
sendPromptAsync(id, text, agent?) | POST | /session/{id}/prompt_async |
abortSession(id) | POST | /session/{id}/abort |
clearSession(id) | GET | /session/{id}/clear |
compactSession(id, keep?) | GET | /session/{id}/compact?keep=N |
exportSession(id, outputPath?) | GET | /session/{id}/export?output_path=P |
streamEvents(onEvent, onError?) | SSE | /event (EventSource) |
Pattern: fetch(baseUrl + path) → check resp.ok → return JSON or boolean.
Message Rendering (src/renderer.ts)
renderMessagePart(part: MessagePart): HTMLElement
Each MessagePartType → CSS class + rendering:
| Type | CSS class | Rendering |
|---|
TEXT | .part-text | Inline <span> with text content |
THINKING | .part-thinking | Inline <span> with text (cyan color) |
TOOL_CALLING | .part-tool-calling | Block: header (⚡ tool_name) + detail lines (status, input, output, error) |
TOOL_CALLING_PART | .part-tool-calling-part | Inline text (magenta) |
TOOL_RESULT | .part-tool-result | Block: ✓ result or ✗ error prefix |
STEP_START | .part-step-start | Block: [STEP START] (yellow) |
STEP_FINISH | .part-step-finish | Block: [STEP FINISH] reason=... (yellow) |
UNKNOWN | .part-unknown | JSON dump of raw_data (gray) |
Utilities
fmtArg(s, maxLen=120) — truncate long strings keeping head + tail
fmtTs(unixT) — format unix timestamp → HH:MM:SS
partTypeClass(type) — map enum → CSS class name
Application Logic (src/main.ts)
DOM Wiring
All elements are obtained once via getElementById() and stored in module-level variables. setConnected() enables/disables groups of buttons and inputs.
Output Helpers
log(text, cls) — append a <div> with optional CSS class (.info, .error, .debug, .user-input)
logHtml(html, cls) — same but with innerHTML
appendPart(el) — smart append: consecutive TEXT parts chain inline via .has-inline wrapper divs
scrollToBottom() — auto-scroll output area
setStatus(text, ok) — update status indicator with green/red class
Slash Commands
Handled via handleCommand() switch. Commands with optional arguments use : separator (e.g., /compact:5).
| Command | Action |
|---|
/help | Print command list |
/new | Create new session, clear output |
/abort | Cancel running prompt |
/status | Show session status JSON |
/sessions | List all sessions |
/messages | Show recent messages |
/clear | Clear session history + output |
/compact[:N] | Compact context, optional keep count |
/export[:path] | Export session to file |
/exit | Disconnect |
Poll Loop
pollMessages() runs every 500ms:
- Fetches last 50 messages
- Renders new messages from
seenMessageCount cursor
- After 4 empty polls (2s), checks session status → if idle/error, stops polling
Styling (src/styles.css)
Catppuccin Mocha theme via CSS custom properties:
:root {
--bg: #1e1e2e;
--fg: #cdd6f4;
--surface: #181825;
--surface2: #313244;
--border: #45475a;
--accent: #89b4fa;
--green: #a6e3a1;
--red: #f38ba8;
--yellow: #f9e2af;
--cyan: #89dceb;
--magenta: #cba6f7;
--gray: #6c7086;
--font: "Cascadia Code", "Fira Code", "JetBrains Mono", "Consolas", monospace;
}
Layout
#app — flex column, max-width 1200px, height 100vh
#connection-panel — host/port inputs, connect/disconnect buttons, debug toggle
#command-bar — flex row of slash-command shortcut buttons
#output-panel — flex:1, scrollable output area (min-height 200px)
#input-panel — flex row with text input + Send button
Part Styling
.part-text — color: var(--fg) (normal text)
.part-thinking — color: var(--cyan) (thinking text)
.part-tool-calling — display: block; color: var(--magenta) (tool header + details)
.part-tool-calling-part — color: var(--magenta) (inline tool output)
.part-tool-result — display: block; color: var(--green) (success), .tool-error variant in red
.part-step-start / .part-step-finish — display: block; color: var(--yellow)
.part-unknown — color: var(--gray)
How to Add a New UI Feature
Step 1: Add backend endpoint (if needed)
Follow the serve skill guide: add method to DummySessionManager + SessionManager, expose route in dummy_app.py/app.py.
Step 2: Add client method
In src/client.ts, add to KimixClient:
async myAction(sessionId: string, param: string): Promise<SomeType> {
const resp = await fetch(`${this.baseUrl}/session/${sessionId}/my-action?param=${param}`);
if (!resp.ok) throw new Error(`myAction failed: ${resp.status}`);
return await resp.json();
}
Step 3: Add UI elements
In index.html:
- Add button in
#command-bar: <button id="btn-myaction" disabled>/myaction</button>
- Or add input/controls as needed
Step 4: Wire in main.ts
const btnMyAction = document.getElementById("btn-myaction") as HTMLButtonElement;
btnMyAction.addEventListener("click", () => handleCommand("/myaction"));
case "myaction": {
const result = await client.myAction(session.id, "value");
log(`[SSE CLI] MyAction: ${JSON.stringify(result)}`, "info");
break;
}
for (const btn of [..., btnMyAction]) { ... }
Step 5: Add new message type (if needed)
If the backend produces a new MessageType, update:
src/types.ts — add to MessagePartType enum and DUMMY_TYPE_MAP / OPENCODE_TYPE_MAP
src/renderer.ts — add case in partTypeClass() and renderMessagePart()
src/styles.css — add .part-<newtype> styling
Testing
Parse Tests (src/parse_test.ts)
Standalone tests for parseMessage() and parseMessagePart(). Run with:
cd src/app && npx tsx src/parse_test.ts
11 tests covering:
- Dummy format: Text, Thinking, ToolCalling, ToolResult
- Opencode format: Text, Tool (→tool_calling), Reasoning (→thinking), Step-Start/Finish
- Edge cases: empty data, unknown types, multi-part messages
Browser Debugging
- Open DevTools → Console tab for
fetch errors and catch block logs
- Toggle Debug checkbox to enable verbose logging (sets
debugMode)
- Check Network tab for REST calls to port 4096
Dependencies
Runtime: None (vanilla TypeScript, no framework).
Dev only:
typescript ^5.7.0 — type checking
vite ^6.0.0 — dev server + bundler
The package.json is "type": "module" for ESM imports.