app · git:20260702.18f1c90 · 2026-07-02 · sha256 0958a903e5a30701

app git:20260702.18f1c90A

Immutable. This exact content is served forever at /api/v1/blob/0958a903e5a30701.

---
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**:
```bash
cd src/app
npm run dev        # Vite dev server on port 5173, opens browser
npm run build      # TypeScript check + Vite production build → dist/
npm run preview    # Preview production build
```

Or via the monorepo runner:
```bash
uv run kimix gui                           # backend + Vite dev
uv run kimix gui --build                   # build frontend first
uv run kimix gui --fe-port 3000            # custom frontend port
uv run kimix gui --no-fe                   # backend only (no Node.js/Vite required)
# Backward-compatible wrapper:
uv run scripts/run_app.py                  # delegates to `kimix gui`
```

## Environment Setup (Installation Experience)

First-time setup from a clean clone:

```bash
cd src/app
# 1. Check if node_modules exists
#    If missing, dependencies need installation.

# 2. Install npm dependencies
npm install
# Output: added 15 packages, audited 16 packages in ~2s
# 0 vulnerabilities found
# Installed packages: typescript ^5.7.0 + vite ^6.0.0 (dev only)

# 3. Start Vite dev server
npm run dev
# VITE v6.4.3  ready in ~300 ms
# ➜  Local:   http://localhost:5173/
# ➜  Network: use --host to expose
```

**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

```typescript
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

```typescript
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;  // concatenated TEXT parts
}

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`:
```typescript
DUMMY_TYPE_MAP = {
  Text → TEXT, Thinking → THINKING, ToolCalling → TOOL_CALLING,
  ToolCallingPart → TOOL_CALLING_PART, ToolResult → TOOL_RESULT
}
```
```json
{"type": "Text", "text": "Hello", "time": 1234567890.123}
```

**Opencode format** (from `SessionManager`): `data.info` present → iterates `data.parts`, uses `OPENCODE_TYPE_MAP`:
```typescript
OPENCODE_TYPE_MAP = { tool → TOOL_CALLING, reasoning → THINKING }
```
```json
{
  "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:
1. Fetches last 50 messages
2. Renders new messages from `seenMessageCount` cursor
3. After 4 empty polls (2s), checks session status → if idle/error, stops polling

---

## Styling (`src/styles.css`)

Catppuccin Mocha theme via CSS custom properties:

```css
:root {
  --bg: #1e1e2e;        /* base */
  --fg: #cdd6f4;        /* text */
  --surface: #181825;   /* mantle */
  --surface2: #313244;  /* surface0 */
  --border: #45475a;    /* surface1 */
  --accent: #89b4fa;    /* blue */
  --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`:

```typescript
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

```typescript
// Get element reference
const btnMyAction = document.getElementById("btn-myaction") as HTMLButtonElement;

// Add event listener
btnMyAction.addEventListener("click", () => handleCommand("/myaction"));

// Add case to handleCommand() switch
case "myaction": {
    const result = await client.myAction(session.id, "value");
    log(`[SSE CLI] MyAction: ${JSON.stringify(result)}`, "info");
    break;
}

// Add to setConnected() disabled-buttons array
for (const btn of [..., btnMyAction]) { ... }
```

### Step 5: Add new message type (if needed)

If the backend produces a new `MessageType`, update:
1. `src/types.ts` — add to `MessagePartType` enum and `DUMMY_TYPE_MAP` / `OPENCODE_TYPE_MAP`
2. `src/renderer.ts` — add case in `partTypeClass()` and `renderMessagePart()`
3. `src/styles.css` — add `.part-<newtype>` styling

---

## Testing

### Parse Tests (`src/parse_test.ts`)

Standalone tests for `parseMessage()` and `parseMessagePart()`. Run with:

```bash
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.