feat: KiloClaw Chat on TUI

This commit is contained in:
Catriel Müller
2026-03-27 10:48:01 -03:00
parent ab66954f4d
commit 8737bdfb19
20 changed files with 1360 additions and 8 deletions
+7
View File
@@ -403,6 +403,7 @@
"remeda": "catalog:",
"simple-git": "3.31.1",
"solid-js": "catalog:",
"stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
@@ -1983,6 +1984,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
@@ -3261,6 +3264,8 @@
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
"load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -3977,6 +3982,8 @@
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="],
"stream-chat": ["stream-chat@9.38.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.5.14", "axios": "^1.12.2", "base64-js": "^1.5.1", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "jsonwebtoken": "^9.0.3", "linkifyjs": "^4.3.2", "ws": "^8.18.1" } }, "sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg=="],
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -18,7 +18,7 @@ If you'd like to migrate your memory bank content to AGENTS.md:
1. Examine the contents in `.kilocode/rules/memory-bank/`
2. Move that content into your project's `AGENTS.md` file (or ask Kilo to do it for you)
{% /callout %}
{% /callout %}
## What is AGENTS.md?
@@ -496,6 +496,103 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
}
},
)
.get(
"/claw/status",
describeRoute({
summary: "Get KiloClaw instance status",
description: "Fetch the user's KiloClaw instance status via the KiloClaw worker",
operationId: "kilo.claw.status",
responses: {
200: {
description: "Instance status",
content: {
"application/json": {
schema: resolver(z.unknown()),
},
},
},
...errors(401, 502),
},
}),
async (c: any) => {
try {
const auth = await Auth.get("kilo")
if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json({ error: "No valid token found" }, 401)
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
if (!response.ok) {
const text = await response.text()
return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any)
}
return c.json(await response.json())
} catch (err: any) {
console.error("[Kilo Gateway] claw/status: error", err?.message ?? err)
return c.json({ error: "Failed to reach KiloClaw" }, 502)
}
},
)
.get(
"/claw/chat-credentials",
describeRoute({
summary: "Get KiloClaw chat credentials",
description: "Fetch Stream Chat credentials for the user's KiloClaw instance",
operationId: "kilo.claw.chatCredentials",
responses: {
200: {
description: "Stream Chat credentials or null",
content: {
"application/json": {
schema: resolver(
z
.object({
apiKey: z.string(),
userId: z.string(),
userToken: z.string(),
channelId: z.string(),
})
.nullable(),
),
},
},
},
...errors(401, 502),
},
}),
async (c: any) => {
try {
const auth = await Auth.get("kilo")
if (!auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json({ error: "No valid token found" }, 401)
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
if (!response.ok) {
const text = await response.text()
return c.json({ error: `KiloClaw request failed: ${response.status} ${text}` }, response.status as any)
}
return c.json(await response.json())
} catch (err: any) {
console.error("[Kilo Gateway] claw/chat-credentials: error", err?.message ?? err)
return c.json({ error: "Failed to reach KiloClaw" }, 502)
}
},
)
.get(
"/cloud-sessions",
describeRoute({
+1
View File
@@ -128,6 +128,7 @@
"remeda": "catalog:",
"simple-git": "3.31.1",
"solid-js": "catalog:",
"stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
+2 -1
View File
@@ -97,7 +97,8 @@ export const PrCommand = cmd({
// Launch opencode TUI with session ID if available
const { spawn } = await import("child_process")
const opencodeArgs = sessionId ? ["-s", sessionId] : []
const opencodeProcess = spawn(bin, opencodeArgs, { // kilocode_change
const opencodeProcess = spawn(bin, opencodeArgs, {
// kilocode_change
stdio: "inherit",
cwd: process.cwd(),
windowsHide: true, // kilocode_change - prevent CMD window flash on Windows
+1 -1
View File
@@ -317,7 +317,7 @@ export const RunCommand = cmd({
describe: "auto-approve all permissions (for autonomous/pipeline usage)",
default: false,
})
// kilocode_change end
// kilocode_change end
)
},
handler: async (args) => {
+12
View File
@@ -43,6 +43,7 @@ import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { registerKiloCommands } from "@/kilocode/kilo-commands" // kilocode_change
import { KiloClawView } from "@/kilocode/claw/view" // kilocode_change
import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui" // kilocode_change
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
@@ -301,6 +302,12 @@ function App() {
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`${titleDefault} | ${title}`) // kilocode_change
}
// kilocode_change start
if (route.data.type === "kiloclaw") {
renderer.setTerminalTitle(`${titleDefault} | KiloClaw`)
}
// kilocode_change end
})
const args = useArgs()
@@ -812,6 +819,11 @@ function App() {
<Match when={route.data.type === "session"}>
<Session />
</Match>
{/* kilocode_change start */}
<Match when={route.data.type === "kiloclaw"}>
<KiloClawView />
</Match>
{/* kilocode_change end */}
</Switch>
</box>
)
@@ -13,7 +13,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
export type Route = HomeRoute | SessionRoute
// kilocode_change start
export type KiloClawRoute = {
type: "kiloclaw"
}
// kilocode_change end
export type Route = HomeRoute | SessionRoute | KiloClawRoute // kilocode_change
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -26,14 +32,27 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
},
)
// kilocode_change start
let previous: Route | undefined
// kilocode_change end
return {
get data() {
return store
},
navigate(route: Route) {
console.log("navigate", route)
previous = { ...store } // kilocode_change
setStore(route)
},
// kilocode_change start
back() {
const target = previous ?? ({ type: "home" } as const)
previous = undefined
console.log("navigate", target)
setStore(target)
},
// kilocode_change end
}
},
})
@@ -0,0 +1,216 @@
// kilocode_change - new file
/**
* KiloClaw chat panel
*
* Renders a scrollable message list and a textarea input for chatting
* with the KiloClaw bot via Stream Chat.
*
* Visual style mirrors the session chat TUI (routes/session/index.tsx).
*/
import { createEffect, createMemo, For, Show } from "solid-js"
import { type KeyBinding, type MouseEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useTheme } from "@tui/context/theme"
import { SplitBorder, EmptyBorder } from "@tui/component/border"
import { useKV } from "@tui/context/kv"
import type { ChatMessage } from "./types"
function UserMessageRow(props: { msg: ChatMessage; index: number }) {
const { theme } = useTheme()
return (
<box
border={["left"]}
borderColor={theme.success}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
flexShrink={0}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
<text fg={theme.text} wrapMode="word">
{props.msg.text}
</text>
</box>
</box>
)
}
function BotMessageRow(props: { msg: ChatMessage; index: number }) {
const { theme, syntax } = useTheme()
const empty = () => !props.msg.text || !props.msg.text.trim()
return (
<box marginTop={props.index === 0 ? 0 : 1} flexShrink={0}>
<box paddingLeft={3}>
<Show when={!empty()} fallback={<text fg={theme.textMuted}>Thinking...</text>}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={props.msg.text}
fg={theme.text}
/>
</Show>
</box>
</box>
)
}
export function ClawChat(props: {
messages: ChatMessage[]
online: boolean
connected: boolean
loading: boolean
error: string | null
disabled: boolean
onSend: (text: string) => void
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const kv = useKV()
const [showScrollbar] = kv.signal("scrollbar_visible", true)
let input: TextareaRenderable
const active = createMemo(() => !props.disabled && props.connected)
const placeholder = createMemo(() => {
if (props.error) return props.error
if (props.loading) return "Connecting..."
if (!props.connected) return "Chat unavailable"
if (props.disabled) return "Instance is stopped"
return ""
})
const submit = () => {
if (!input) return
const text = input.plainText.trim()
if (!text) return
if (!active()) return
props.onSend(text)
input.clear()
}
createEffect(() => {
if (active()) input?.focus()
})
// Stream Chat WebSocket events fire outside OpenTUI's render cycle.
// Track props driven by external callbacks and explicitly request a repaint.
createEffect(() => {
props.messages.length
props.online
renderer.requestRender()
})
return (
<box flexDirection="column" flexGrow={1} gap={1} paddingTop={1}>
{/* Messages */}
<scrollbox
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
>
<Show when={!props.loading && props.messages.length === 0 && props.connected}>
<text fg={theme.textMuted} paddingLeft={2}>
No messages yet. Say hello!
</text>
</Show>
<Show when={!props.connected && !props.loading && !props.error}>
<text fg={theme.textMuted} paddingLeft={2}>
Chat not available. Your instance may need to be provisioned or upgraded.
</text>
</Show>
<For each={props.messages}>
{(msg, index) => (
<Show when={msg.bot} fallback={<UserMessageRow msg={msg} index={index()} />}>
<BotMessageRow msg={msg} index={index()} />
</Show>
)}
</For>
</scrollbox>
{/* Input area */}
<box flexShrink={0}>
<box
border={["left"]}
borderColor={active() ? theme.primary : theme.textMuted}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
paddingLeft={2}
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<Show when={active()} fallback={<text fg={theme.textMuted}>{placeholder()}</text>}>
<textarea
ref={(r: TextareaRenderable) => {
input = r
}}
placeholder="Type a message... (Enter to send)"
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={2}
maxHeight={4}
cursorColor={theme.text}
focusedBackgroundColor={theme.backgroundElement}
onMouseDown={(e: MouseEvent) => e.target?.focus()}
keyBindings={[
{ name: "return", action: "submit" } satisfies KeyBinding,
{ name: "return", shift: true, action: "newline" } satisfies KeyBinding,
]}
onSubmit={submit}
/>
</Show>
</box>
</box>
<box
height={1}
border={["left"]}
borderColor={active() ? theme.primary : theme.textMuted}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
</box>
</box>
)
}
@@ -0,0 +1,152 @@
// kilocode_change - new file
/**
* KiloClaw Stream Chat client wrapper
*
* Headless Stream Chat JS SDK integration for the TUI.
* Uses the same channel/credentials as the web dashboard's ChatTab.
*
* stream-chat is dynamically imported to avoid crashing Bun at module
* load time (follow-redirects uses Error.captureStackTrace in a way
* that is incompatible with Bun's runtime).
*/
import type { ChatCredentials, ChatMessage } from "./types"
import { Log } from "@/util/log"
const log = Log.create({ service: "claw-chat" })
export type ClawChatClient = {
channel: any
disconnect: () => Promise<void>
send: (text: string) => Promise<void>
onMessage: (cb: (msg: ChatMessage) => void) => () => void
onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void
onPresence: (cb: (online: boolean) => void) => () => void
}
function botId(creds: ChatCredentials): string {
return `bot-${creds.channelId.replace(/^default-/, "")}`
}
function toMessage(raw: any, bot: string): ChatMessage {
return {
id: raw.id ?? "",
text: raw.text ?? "",
user: raw.user?.id ?? raw.user_id ?? "",
created: raw.created_at ? new Date(raw.created_at) : new Date(),
bot: (raw.user?.id ?? raw.user_id ?? "") === bot,
}
}
/**
* Apply Bun compatibility patches for stream-chat and its dependencies.
*
* 1. Error.captureStackTrace — follow-redirects passes a plain object with
* Error.prototype in its chain which crashes Bun.
* 2. net.Socket.prototype.destroy — Bun throws "First argument must be an
* Error object" when destroy() receives a non-Error. The ws library and
* stream-chat internals can trigger this during WebSocket lifecycle events.
*
* Both patches are applied once and remain active for the process lifetime.
*/
let patched = false
function applyBunPatches() {
if (patched) return
patched = true
const orig = Error.captureStackTrace
Error.captureStackTrace = function safe(target: any, ctor?: Function) {
try {
return orig.call(Error, target, ctor)
} catch {
// Bun throws when target is not a real Error (e.g. follow-redirects
// CustomError which has Error.prototype in its chain but is not
// constructed via `new Error()`). Silently skip.
}
} as typeof Error.captureStackTrace
try {
const net = require("net")
const origDestroy = net.Socket.prototype.destroy
net.Socket.prototype.destroy = function (err: any, cb: any) {
if (err != null && !(err instanceof Error)) {
err = new Error(String(err))
}
return origDestroy.call(this, err, cb)
}
} catch {
// net module unavailable — skip socket patch
}
}
async function loadStreamChat() {
applyBunPatches()
const mod = await import("stream-chat")
return mod.StreamChat
}
export async function connect(creds: ChatCredentials): Promise<ClawChatClient> {
log.info("loading stream-chat")
const StreamChat = await loadStreamChat()
log.info("getInstance", { key: creds.apiKey?.substring(0, 8) + "..." })
const client = StreamChat.getInstance(creds.apiKey)
log.info("connectUser", { user: creds.userId })
await client.connectUser({ id: creds.userId }, creds.userToken)
log.info("watching channel", { channel: creds.channelId })
const channel = client.channel("messaging", creds.channelId)
await channel.watch({ presence: true })
log.info("connected successfully")
const bot = botId(creds)
return {
channel,
async disconnect() {
await client.disconnectUser()
},
async send(text: string) {
await channel.sendMessage({ text })
},
onMessage(cb) {
const handler = (event: any) => {
if (event.message) cb(toMessage(event.message, bot))
}
channel.on("message.new", handler)
return () => channel.off("message.new", handler)
},
onMessageUpdated(cb) {
const handler = (event: any) => {
if (event.message) cb(toMessage(event.message, bot))
}
channel.on("message.updated", handler)
return () => channel.off("message.updated", handler)
},
onPresence(cb) {
const handler = (event: any) => {
if (event.user?.id === bot) {
cb(event.user.online ?? false)
}
}
client.on("user.presence.changed", handler)
return () => client.off("user.presence.changed", handler)
},
}
}
export async function history(channel: any, bot: string): Promise<ChatMessage[]> {
const state = channel.state.messages
return state.map((raw: any) => toMessage(raw, bot))
}
/**
* Read the bot's initial online status from channel member state.
* Mirrors the cloud's `useBotOnlineStatus` which reads
* `channel.state.members[botUserId]?.user?.online`.
*/
export function presence(channel: any, bot: string): boolean {
const member = channel.state.members?.[bot]
return !!member?.user?.online
}
@@ -0,0 +1,141 @@
// kilocode_change - new file
/**
* KiloClaw SolidJS reactive helpers
*
* Provides reactive state management for polling instance status
* and managing the Stream Chat connection lifecycle.
*/
import { createSignal, onMount, onCleanup } from "solid-js"
import type { ClawStatus, ChatCredentials, ChatMessage } from "./types"
import { connect, history, presence, type ClawChatClient } from "./client"
import { Log } from "@/util/log"
const log = Log.create({ service: "claw-chat" })
/**
* Poll the KiloClaw instance status every `interval` ms.
* Returns a reactive signal with the latest status.
*/
export function createClawStatus(sdk: any, interval = 10_000) {
const [status, setStatus] = createSignal<ClawStatus | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [loading, setLoading] = createSignal(true)
onMount(() => {
const poll = async () => {
const res = await sdk.client.kilo.claw.status().catch(() => null)
if (res?.data && !res.error) {
setStatus(res.data as ClawStatus)
setError(null)
} else if (res?.error) {
setError(typeof res.error === "string" ? res.error : (res.error?.error ?? "Unknown error"))
}
setLoading(false)
}
poll()
const timer = setInterval(poll, interval)
onCleanup(() => clearInterval(timer))
})
return { status, error, loading }
}
/**
* Fetch Stream Chat credentials and manage the chat connection.
* Returns reactive signals for messages, bot presence, and connection state.
*/
export function createClawChat(sdk: any) {
const [messages, setMessages] = createSignal<ChatMessage[]>([])
const [online, setOnline] = createSignal(false)
const [connected, setConnected] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [loading, setLoading] = createSignal(true)
let chat: ClawChatClient | null = null
const send = async (text: string) => {
if (!chat) return
await chat.send(text)
}
onMount(async () => {
log.info("fetching credentials")
const res = await sdk.client.kilo.claw.chatCredentials().catch((e: any) => {
log.error("chatCredentials() threw", { error: e?.message ?? String(e) })
return null
})
log.info("credentials response", {
hasData: String(!!res?.data),
dataIsNull: String(res?.data === null),
error: res?.error ? String(res.error) : "none",
dataKeys: res?.data ? Object.keys(res.data).join(",") : "none",
})
if (!res?.data || res.error) {
setError(res?.data === null ? null : "Failed to fetch chat credentials")
setLoading(false)
return
}
const creds = res.data as ChatCredentials
try {
log.info("calling connect()")
chat = await connect(creds)
log.info("connect() succeeded")
// Load existing messages
const bot = `bot-${creds.channelId.replace(/^default-/, "")}`
const existing = await history(chat.channel, bot)
log.info("loaded history", { count: existing.length })
setMessages(existing)
// Read initial bot presence from channel member state
setOnline(presence(chat.channel, bot))
// Subscribe to new messages
const unsub = chat.onMessage((msg) => {
setMessages((prev) => [...prev, msg])
})
// Subscribe to message updates (bot streams token-by-token via message.updated)
const unsubUpdated = chat.onMessageUpdated((msg) => {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === msg.id)
if (idx === -1) return [...prev, msg]
const next = [...prev]
next[idx] = msg
return next
})
})
// Subscribe to bot presence changes
const unsubPresence = chat.onPresence(setOnline)
setConnected(true)
setLoading(false)
onCleanup(async () => {
unsub()
unsubUpdated()
unsubPresence()
if (chat) await chat.disconnect()
chat = null
})
} catch (err: any) {
log.error("connect failed", {
error: err?.message ?? String(err),
name: err?.name,
code: err?.code,
stack: err?.stack?.split("\n").slice(0, 5).join("\n"),
})
setError(err?.message ?? "Failed to connect to chat")
setLoading(false)
}
})
return { messages, online, connected, error, loading, send }
}
@@ -0,0 +1,150 @@
// kilocode_change - new file
/**
* KiloClaw status sidebar
*
* Displays essential instance information: status, bot presence,
* region, sandbox ID, uptime, and version.
*
* Visual style mirrors the session sidebar (routes/session/sidebar.tsx).
*/
import { Show } from "solid-js"
import { useTheme } from "@tui/context/theme"
import type { ClawStatus } from "./types"
function dot(status: string | null | undefined, theme: any): string {
if (!status) return theme.textMuted
if (status === "running") return theme.success
if (status === "starting" || status === "restarting") return theme.warning
if (status === "destroying") return theme.error
return theme.textMuted
}
function uptime(started: string | null | undefined): string {
if (!started) return "—"
const ms = Date.now() - new Date(started).getTime()
if (ms < 0) return "—"
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ${hours % 24}h`
if (hours > 0) return `${hours}h ${minutes % 60}m`
if (minutes > 0) return `${minutes}m`
return `${seconds}s`
}
export function ClawSidebar(props: {
status: ClawStatus | null
loading: boolean
error: string | null
online: boolean
connected: boolean
chatLoading: boolean
chatError: string | null
}) {
const { theme } = useTheme()
return (
<box
backgroundColor={theme.backgroundPanel}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<scrollbox flexGrow={1}>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text}>
<b>KiloClaw</b>
</text>
</box>
<Show when={props.loading}>
<text fg={theme.textMuted}>Loading...</text>
</Show>
<Show when={props.error}>
<text fg={theme.error}>{props.error}</text>
</Show>
<Show when={!props.loading && !props.error && props.status}>
<box>
<text fg={theme.text}>
<b>Instance</b>
</text>
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: dot(props.status!.status, theme) }}>
•
</text>
<text fg={theme.text}>
{(props.status!.status ?? "unknown").replace(/^./, (c) => c.toUpperCase())}{" "}
<span style={{ fg: theme.textMuted }}>
{props.status!.status === "running" ? uptime(props.status!.lastStartedAt) : ""}
</span>
</text>
</box>
</box>
<box>
<text fg={theme.text}>
<b>Bot</b>
</text>
<Show when={!props.chatLoading} fallback={<text fg={theme.textMuted}>Connecting...</text>}>
<Show when={props.chatError}>
<text fg={theme.error}>{props.chatError}</text>
</Show>
<Show when={!props.chatError && props.connected}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: props.online ? theme.success : theme.textMuted }}>
•
</text>
<text fg={theme.text}>{props.online ? "Online" : "Offline"}</text>
</box>
</Show>
<Show when={!props.chatError && !props.connected}>
<text fg={theme.textMuted}>Unavailable</text>
</Show>
</Show>
</box>
<box>
<text fg={theme.text}>
<b>Details</b>
</text>
<text fg={theme.textMuted}>
Region <span style={{ fg: theme.text }}>{props.status!.flyRegion?.toUpperCase() ?? "—"}</span>
</text>
<text fg={theme.textMuted}>
Version <span style={{ fg: theme.text }}>{props.status!.openclawVersion ?? "—"}</span>
</text>
<Show when={props.status!.channelCount != null && props.status!.channelCount >= 1}>
<text fg={theme.textMuted}>
Channels <span style={{ fg: theme.text }}>{props.status!.channelCount}</span>
</text>
</Show>
</box>
</Show>
<Show when={!props.loading && !props.error && !props.status}>
<box>
<text fg={theme.textMuted}>No instance found.</text>
<text fg={theme.textMuted}>Visit kilo.ai/claw</text>
<text fg={theme.textMuted}>to set one up.</text>
</box>
</Show>
</box>
</scrollbox>
<box flexShrink={0} paddingTop={1}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>Esc</span> back
</text>
</box>
</box>
)
}
@@ -0,0 +1,34 @@
// kilocode_change - new file
/**
* KiloClaw TUI types
*
* Types for the KiloClaw chat and dashboard feature in the TUI.
*/
export type ClawStatus = {
status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null
sandboxId?: string
flyRegion?: string
machineSize?: { cpus: number; memory_mb: number }
openclawVersion?: string | null
lastStartedAt?: string | null
lastStoppedAt?: string | null
channelCount?: number
secretCount?: number
}
export type ChatCredentials = {
apiKey: string
userId: string
userToken: string
channelId: string
}
export type ChatMessage = {
id: string
text: string
user: string
created: Date
bot: boolean
}
@@ -0,0 +1,86 @@
// kilocode_change - new file
/**
* KiloClaw full-screen view
*
* Main layout component for the /kiloclaw route.
* Renders a chat panel on the left and a status sidebar on the right.
* Escape navigates back to the previous route.
*/
import { createMemo } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useRoute } from "@tui/context/route"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { Toast } from "@tui/ui/toast"
import { ClawChat } from "./chat"
import { ClawSidebar } from "./sidebar"
import { createClawStatus, createClawChat } from "./hooks"
export function KiloClawView() {
const route = useRoute()
const sdk = useSDK()
const command = useCommandDialog()
// Poll instance status
const { status, error: statusError, loading: statusLoading } = createClawStatus(sdk)
// Connect to chat
const chat = createClawChat(sdk)
// Determine if chat input should be disabled
const disabled = createMemo(() => {
const s = status()
return !s || s.status !== "running"
})
// Register escape to navigate back
useKeyboard((evt) => {
if (evt.name === "escape") {
route.back()
evt.preventDefault()
evt.stopPropagation()
}
})
// Register view-specific commands
command.register(() => [
{
value: "kiloclaw.back",
title: "Back",
category: "KiloClaw",
hidden: true,
keybind: "escape" as any,
onSelect: () => {
route.back()
},
},
])
return (
<box flexDirection="row" flexGrow={1} paddingLeft={2} gap={1}>
<box flexGrow={1} flexDirection="column">
<ClawChat
messages={chat.messages()}
online={chat.online()}
connected={chat.connected()}
loading={chat.loading()}
error={chat.error()}
disabled={disabled()}
onSend={chat.send}
/>
<Toast />
</box>
<ClawSidebar
status={status()}
loading={statusLoading()}
error={statusError()}
online={chat.online()}
connected={chat.connected()}
chatLoading={chat.loading()}
chatError={chat.error()}
/>
</box>
)
}
@@ -0,0 +1,66 @@
// kilocode_change - new file
/**
* KiloClaw Setup Dialog
*
* Shown when the user has no KiloClaw instance provisioned.
* Provides links to set up an instance and learn more.
*/
import { useKeyboard } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
export function DialogClawSetup() {
const { theme } = useTheme()
const dialog = useDialog()
useKeyboard((evt: any) => {
if (evt.name === "return") {
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
KiloClaw
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingBottom={1} gap={1}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Personal AI for everyday life
</text>
<text fg={theme.textMuted} wrapMode="word">
KiloClaw gives you a personal AI that reads email, manages your calendar, monitors your projects, and lives in
Telegram, Slack — whatever you already use.
</text>
<text fg={theme.textMuted} wrapMode="word">
No app to install. No new interface to learn. Just message it like a friend.
</text>
<box flexDirection="row" marginTop={1}>
<Link href="https://kilo.ai/kiloclaw" fg={theme.text}>
Learn more
</Link>
</box>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} flexDirection="row">
<text fg={theme.text}>{"🦀 "}</text>
<Link href="https://app.kilo.ai/claw" fg={theme.selectedListItemText}>
Try KiloClaw
</Link>
</box>
</box>
</box>
)
}
@@ -0,0 +1,54 @@
// kilocode_change - new file
/**
* KiloClaw Upgrade Dialog
*
* Shown when the user has an active KiloClaw instance but no chat credentials,
* indicating the instance was provisioned before chat was enabled and needs
* an upgrade to the latest version.
*/
import { useKeyboard } from "@opentui/solid"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
export function DialogClawUpgrade() {
const { theme } = useTheme()
const dialog = useDialog()
useKeyboard((evt: any) => {
if (evt.name === "return") {
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<span style={{ bold: true }}>KiloClaw Chat requires an upgrade</span>
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingBottom={1} gap={1}>
<text fg={theme.textMuted} wrapMode="word">
This instance was provisioned before chat was enabled.
</text>
<text fg={theme.textMuted} wrapMode="word">
Use the <span style={{ fg: theme.warning, bold: true }}>↻ Upgrade to Latest</span> button on the KiloClaw
dashboard to activate real-time chat with your bot.
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary}>
<Link href="https://app.kilo.ai/claw" fg={theme.selectedListItemText}>
Dashboard
</Link>
</box>
</box>
</box>
)
}
@@ -7,12 +7,15 @@
import { createMemo } from "solid-js"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useSync } from "@tui/context/sync"
import { useRoute } from "@tui/context/route"
import { useDialog } from "@tui/ui/dialog"
import { useToast } from "@tui/ui/toast"
import { DialogAlert } from "@tui/ui/dialog-alert"
import type { Organization } from "@kilocode/kilo-gateway"
import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js"
import { DialogKiloProfile } from "./components/dialog-kilo-profile.js"
import { DialogClawSetup } from "./components/dialog-claw-setup.js"
import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js"
// These types are OpenCode-internal and imported at runtime
type UseSDK = any
@@ -27,6 +30,7 @@ type SDK = any
export function registerKiloCommands(useSDK: () => UseSDK) {
const command = useCommandDialog()
const sync = useSync()
const route = useRoute()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
@@ -37,6 +41,40 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
})
command.register(() => [
// /kiloclaw command
{
value: "kilo.claw",
title: "KiloClaw",
description: "Open KiloClaw chat & dashboard",
category: "Kilo",
slash: { name: "kiloclaw", aliases: ["claw"] },
enabled: isKiloConnected(),
hidden: !isKiloConnected(),
onSelect: async () => {
// Check instance status first
const res = await sdk.client.kilo.claw.status().catch(() => null)
// No instance provisioned
if (!res?.data || !res.data.userId || res.error) {
dialog.replace(() => <DialogClawSetup />)
return
}
// Instance exists — check for chat credentials
const creds = await sdk.client.kilo.claw.chatCredentials().catch(() => null)
if (!creds?.data || creds.error) {
// Instance exists but no chat credentials — needs upgrade
dialog.replace(() => <DialogClawUpgrade />)
return
}
// Everything ready — navigate to full-screen chat view
route.navigate({ type: "kiloclaw" })
dialog.clear()
},
},
// /remote command
{
value: "remote.toggle",
+69
View File
@@ -51,6 +51,8 @@ import type {
GlobalEventResponses,
GlobalHealthResponses,
InstanceDisposeResponses,
KiloClawChatCredentialsResponses,
KiloClawStatusResponses,
KiloCloudSessionGetErrors,
KiloCloudSessionGetResponses,
KiloCloudSessionImportErrors,
@@ -3298,6 +3300,68 @@ export class Cloud extends HeyApiClient {
}
}
export class Claw extends HeyApiClient {
/**
* Get KiloClaw instance status
*
* Fetch the user's KiloClaw instance status via the KiloClaw worker
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<KiloClawStatusResponses, unknown, ThrowOnError>({
url: "/kilo/claw/status",
...options,
...params,
})
}
/**
* Get KiloClaw chat credentials
*
* Fetch Stream Chat credentials for the user's KiloClaw instance
*/
public chatCredentials<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<KiloClawChatCredentialsResponses, unknown, ThrowOnError>({
url: "/kilo/claw/chat-credentials",
...options,
...params,
})
}
}
export class Kilo extends HeyApiClient {
/**
* Get Kilo Gateway profile
@@ -3479,6 +3543,11 @@ export class Kilo extends HeyApiClient {
get cloud(): Cloud {
return (this._cloud ??= new Cloud({ client: this.client }))
}
private _claw?: Claw
get claw(): Claw {
return (this._claw ??= new Claw({ client: this.client }))
}
}
export class Find extends HeyApiClient {
+41
View File
@@ -4844,6 +4844,47 @@ export type KiloCloudSessionImportResponses = {
200: unknown
}
export type KiloClawStatusData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilo/claw/status"
}
export type KiloClawStatusResponses = {
/**
* Instance status
*/
200: unknown
}
export type KiloClawChatCredentialsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilo/claw/chat-credentials"
}
export type KiloClawChatCredentialsResponses = {
/**
* Stream Chat credentials or null
*/
200: {
apiKey: string
userId: string
userToken: string
channelId: string
} | null
}
export type KiloClawChatCredentialsResponse = KiloClawChatCredentialsResponses[keyof KiloClawChatCredentialsResponses]
export type KiloCloudSessionsData = {
body?: never
path?: never
+172 -4
View File
@@ -5042,6 +5042,15 @@
"isFree": {
"type": "boolean"
},
"ai_sdk_provider": {
"type": "string",
"enum": [
"anthropic",
"openai",
"openai-compatible",
"openrouter"
]
},
"experimental": {
"type": "boolean"
},
@@ -5473,7 +5482,10 @@
"type": "boolean"
}
},
"required": ["enabled", "connected"]
"required": [
"enabled",
"connected"
]
}
}
}
@@ -5523,7 +5535,10 @@
"type": "boolean"
}
},
"required": ["enabled", "connected"]
"required": [
"enabled",
"connected"
]
}
}
}
@@ -5573,7 +5588,10 @@
"type": "boolean"
}
},
"required": ["enabled", "connected"]
"required": [
"enabled",
"connected"
]
}
}
}
@@ -6158,7 +6176,32 @@
},
"groups": {
"type": "array",
"items": {}
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"prefixItems": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"fileRegex": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
]
}
]
}
}
}
}
@@ -6532,6 +6575,113 @@
]
}
},
"/kilo/claw/status": {
"get": {
"operationId": "kilo.claw.status",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Get KiloClaw instance status",
"description": "Fetch the user's KiloClaw instance status via the KiloClaw worker",
"responses": {
"200": {
"description": "Instance status",
"content": {
"application/json": {
"schema": {}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.claw.status({\n ...\n})"
}
]
}
},
"/kilo/claw/chat-credentials": {
"get": {
"operationId": "kilo.claw.chatCredentials",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Get KiloClaw chat credentials",
"description": "Fetch Stream Chat credentials for the user's KiloClaw instance",
"responses": {
"200": {
"description": "Stream Chat credentials or null",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"apiKey": {
"type": "string"
},
"userId": {
"type": "string"
},
"userToken": {
"type": "string"
},
"channelId": {
"type": "string"
}
},
"required": [
"apiKey",
"userId",
"userToken",
"channelId"
]
},
{
"type": "null"
}
]
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.claw.chatCredentials({\n ...\n})"
}
]
}
},
"/kilo/cloud-sessions": {
"get": {
"operationId": "kilo.cloudSessions",
@@ -12659,6 +12809,15 @@
"isFree": {
"type": "boolean"
},
"ai_sdk_provider": {
"type": "string",
"enum": [
"anthropic",
"openai",
"openai-compatible",
"openrouter"
]
},
"experimental": {
"type": "boolean"
},
@@ -13740,6 +13899,15 @@
},
"isFree": {
"type": "boolean"
},
"ai_sdk_provider": {
"type": "string",
"enum": [
"anthropic",
"openai",
"openai-compatible",
"openrouter"
]
}
},
"required": [