mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat: KiloClaw Chat on TUI
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user