feat: kiloclaw chat rework

This commit is contained in:
Catriel Müller
2026-05-06 14:01:51 -03:00
parent 5fcc4231c0
commit 98866740af
63 changed files with 6766 additions and 919 deletions
-1
View File
@@ -176,7 +176,6 @@
"semver": "^7.6.3",
"simple-git": "3.35.2",
"solid-js": "catalog:",
"stream-chat": "9.38.0",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -0,0 +1,337 @@
// kilocode_change - new file
/**
* KiloClaw chat slash-command autocomplete.
*
* Slim port of `cli/cmd/tui/component/prompt/autocomplete.tsx` — drops the
* `@`-mode branches (file mentions, agents, MCP resources, frecency,
* extmark wiring) and only handles `/` slash commands.
*
* Source of slash commands is supplied by the caller via the `slashes`
* prop. We intentionally do NOT pull from `useCommandDialog().slashes()`
* because that registry holds globally-registered commands across all
* routes (e.g. the home route's `/new` for sessions), which would clash
* with kiloclaw's own `/new` for conversations.
*
* Mounts as an absolutely-positioned popup above the chat textarea and
* suspends the global command keybinds while open so arrow keys drive the
* popup instead of triggering palette commands.
*/
import type { BoxRenderable, KeyEvent, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { selectedForeground, useTheme } from "@tui/context/theme"
export type ClawAutocompleteRef = {
onInput: (value: string) => void
onCursorChange: () => void
onKeyDown: (e: KeyEvent) => void
dismiss: () => void
visible: boolean
}
export type ClawSlashOption = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
export function ClawAutocomplete(props: {
value: string
slashes: () => ClawSlashOption[]
anchor: () => BoxRenderable | undefined
input: () => TextareaRenderable | undefined
ref: (ref: ClawAutocompleteRef) => void
}) {
const command = useCommandDialog()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false as boolean,
})
const [positionTick, setPositionTick] = createSignal(0)
// Reposition the popup if the anchor moves (e.g., textarea grows).
createEffect(() => {
if (!store.visible) return
let last = { x: 0, y: 0, width: 0 }
const interval = setInterval(() => {
const a = props.anchor()
if (!a) return
if (a.x !== last.x || a.y !== last.y || a.width !== last.width) {
last = { x: a.x, y: a.y, width: a.width }
setPositionTick((t) => t + 1)
}
}, 50)
onCleanup(() => clearInterval(interval))
})
const position = createMemo(() => {
if (!store.visible) return { x: 0, y: 0, width: 0 }
dimensions()
positionTick()
const anchor = props.anchor()
if (!anchor) return { x: 0, y: 0, width: 0 }
const parent = anchor.parent
const parentX = parent?.x ?? 0
const parentY = parent?.y ?? 0
return {
x: anchor.x - parentX,
y: anchor.y - parentY,
width: anchor.width,
}
})
// Filter text — everything after the trigger up to the cursor.
const filter = createMemo(() => {
if (!store.visible) return ""
const inp = props.input()
if (!inp) return ""
props.value // reactive dep
return inp.getTextRange(store.index + 1, inp.cursorOffset)
})
const [search, setSearch] = createSignal("")
createEffect(() => {
setSearch(filter())
})
const options = createMemo<ClawSlashOption[]>(() => {
const all = props.slashes()
const q = search()
if (!q) return all
const result = fuzzysort.go(q, all, {
keys: [(o) => o.display.trimEnd(), "description", (o) => o.aliases?.join(" ") ?? ""],
limit: 10,
scoreFn: (results) => {
const first = results[0]
let score = results.score
if (first && first.target.startsWith("/" + q)) score *= 2
return score
},
})
return result.map((r) => r.obj)
})
// Reset selection when the filter changes
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
const list = options()
if (!list.length) return
let next = store.selected + direction
if (next < 0) next = list.length - 1
if (next >= list.length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
if (!scroll) return
const viewportHeight = Math.min(height(), options().length)
const scrollBottom = scroll.scrollTop + viewportHeight
if (next < scroll.scrollTop) {
scroll.scrollBy(next - scroll.scrollTop)
} else if (next + 1 > scrollBottom) {
scroll.scrollBy(next + 1 - scrollBottom)
}
}
function select() {
const selected = options()[store.selected]
if (!selected) return
// Strip the `/<typed>` from the textarea so the dialog opens on a clean
// slate (mirrors the main Prompt's behavior when running a slash cmd).
const inp = props.input()
if (inp) {
const cursor = inp.logicalCursor
inp.deleteRange(0, 0, cursor.row, cursor.col)
}
hide()
selected.onSelect()
}
function show() {
command.keybinds(false)
setStore({
visible: true,
index: props.input()?.cursorOffset ?? 0,
})
}
function dismiss() {
if (!store.visible) return
command.keybinds(true)
setStore("visible", false)
}
function hide() {
dismiss()
}
onMount(() => {
props.ref({
get visible() {
return store.visible
},
dismiss() {
dismiss()
},
onInput(value) {
const inp = props.input()
if (!inp) return
if (store.visible) {
// Hide if cursor moved before trigger, whitespace appeared between
// trigger and cursor, or the input ceased being a single token.
if (
inp.cursorOffset <= store.index ||
inp.getTextRange(store.index, inp.cursorOffset).match(/\s/) ||
value.match(/^\S+\s+\S+\s*$/)
) {
hide()
}
return
}
// Auto-reopen after backspace deletes a leading space, etc.
const offset = inp.cursorOffset
if (offset === 0) return
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
show()
setStore("index", 0)
}
},
onCursorChange() {
if (!store.visible) return
const inp = props.input()
if (!inp) return
const cursor = inp.cursorOffset
const value = inp.plainText
if (
cursor <= store.index ||
inp.getTextRange(store.index, cursor).match(/\s/) ||
value.match(/^\S+\s+\S+\s*$/)
) {
hide()
}
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
const name = e.name?.toLowerCase()
const ctrlOnly = e.ctrl && !e.meta && !e.shift
const isNavUp = name === "up" || (ctrlOnly && name === "p")
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
move(1)
e.preventDefault()
return
}
if (name === "escape") {
hide()
e.preventDefault()
return
}
if (name === "return" || name === "tab") {
select()
e.preventDefault()
return
}
if (name === "right") {
const inp = props.input()
if (inp && inp.cursorOffset <= store.index) {
hide()
return
}
}
return
}
if (e.name === "/") {
const inp = props.input()
if (inp && inp.cursorOffset === 0) show()
}
},
})
onCleanup(() => {
if (store.visible) command.keybinds(true)
})
})
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
positionTick()
const a = props.anchor()
return Math.min(10, count, Math.max(1, a?.y ?? 1))
})
let scroll: ScrollBoxRenderable
return (
<box
visible={store.visible}
position="absolute"
top={position().y - height()}
left={position().x}
width={position().width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
>
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching commands</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseDown={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{" "}
{option().description}
</text>
</Show>
</box>
)}
</Index>
</scrollbox>
</box>
)
}
+107 -36
View File
@@ -3,19 +3,23 @@
/**
* KiloClaw chat panel
*
* Renders a scrollable message list and a textarea input for chatting
* with the KiloClaw bot via Stream Chat.
* Renders a scrollable message list, a slash-command autocomplete popup,
* and a textarea for chatting with the KiloClaw bot via Kilo Chat.
* Conversation title and context-window usage live in the sidebar (mirrors
* the session route's layout).
*
* 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 { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { type BoxRenderable, 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"
import { Spinner } from "@tui/component/spinner"
import type { ChatMessage, TypingMember } from "./types"
import { ClawAutocomplete, type ClawAutocompleteRef, type ClawSlashOption } from "./autocomplete"
function UserMessageRow(props: { msg: ChatMessage; index: number }) {
const { theme } = useTheme()
@@ -64,45 +68,87 @@ export function ClawChat(props: {
loading: boolean
error: string | null
disabled: boolean
typingMembers: TypingMember[]
slashes: () => ClawSlashOption[]
botName: string
onSend: (text: string) => Promise<boolean>
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const kv = useKV()
const [showScrollbar] = kv.signal("scrollbar_visible", true)
let input: TextareaRenderable
let input: TextareaRenderable | undefined
let inputAnchor: BoxRenderable | undefined
let auto: ClawAutocompleteRef | undefined
const [inputValue, setInputValue] = createSignal("")
const active = createMemo(() => !props.disabled && props.connected)
// Input is usable only when the instance is running, the WebSocket is
// connected, and the bot is online. Sending while the bot is offline
// would be silently dropped (no one to deliver to), so surface that as
// a disabled input rather than a message that vanishes.
const active = createMemo(() => !props.disabled && props.connected && props.online)
// Render the typing banner with friendly names. Bot members come in as
// `bot:kiloclaw:{sandboxId}` — the bot is the only non-self member in 1:1
// chats, so we resolve bot ids to the user-configured `botName` (which
// falls back to "KiloClaw" upstream) and render any human collaborators
// by their raw memberId.
const typingLabel = createMemo(() => {
const list = props.typingMembers
if (!list.length) return null
const names = list.map((m) => (m.memberId.startsWith("bot:") ? props.botName : m.memberId))
if (names.length === 1) return `${names[0]} is typing`
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`
return `${names[0]} and ${names.length - 1} others are typing`
})
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 ""
if (!props.online) return `${props.botName} is offline`
return "Type a message... (/ for commands)"
})
const submit = async () => {
if (!input) return
if (auto?.visible) return // let autocomplete consume Enter
const text = input.plainText.trim()
if (!text) return
if (!active()) return
const ok = await props.onSend(text)
if (ok) input.clear()
if (ok) {
input.clear()
setInputValue("")
}
}
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.
// Repaint when external state arrives (events fire outside OpenTUI's
// render cycle).
createEffect(() => {
props.messages.length
props.online
props.typingMembers.length
renderer.requestRender()
})
// Drive the textarea's traits + focus from active() and autocomplete
// visibility. We always render the textarea (even while loading or
// disconnected) so the input area keeps a stable height; `traits.suspend`
// disables interaction without changing layout. Mirrors DialogPrompt's
// suspend-then-blur pattern so the cursor visibly leaves while inactive.
createEffect(() => {
if (!input || input.isDestroyed) return
const suspend = !active()
const status = props.loading ? "LOADING" : props.disabled ? "OFFLINE" : undefined
input.traits = auto?.visible
? { capture: ["escape", "navigate", "submit", "tab"] as const, suspend, status }
: { suspend, status }
if (suspend) input.blur()
else input.focus()
})
return (
<box flexDirection="column" flexGrow={1} gap={1} paddingTop={1}>
{/* Messages */}
@@ -143,8 +189,15 @@ export function ClawChat(props: {
</For>
</scrollbox>
{/* Typing indicator */}
<Show when={typingLabel()}>
<box flexShrink={0} paddingLeft={2}>
<Spinner color={theme.textMuted}>{typingLabel()}</Spinner>
</box>
</Show>
{/* Input area */}
<box flexShrink={0}>
<box flexShrink={0} ref={(r: BoxRenderable) => (inputAnchor = r)}>
<box
border={["left"]}
borderColor={active() ? theme.primary : theme.textMuted}
@@ -162,26 +215,35 @@ export function ClawChat(props: {
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>
<textarea
ref={(r: TextareaRenderable) => {
input = r
}}
placeholder={placeholder()}
placeholderColor={theme.textMuted}
textColor={active() ? theme.text : theme.textMuted}
focusedTextColor={active() ? theme.text : theme.textMuted}
minHeight={2}
maxHeight={4}
cursorColor={active() ? theme.text : theme.backgroundElement}
focusedBackgroundColor={theme.backgroundElement}
onMouseDown={(e: MouseEvent) => {
if (active()) e.target?.focus()
}}
onContentChange={() => {
if (!input) return
const value = input.plainText
setInputValue(value)
auto?.onInput(value)
}}
onCursorChange={() => auto?.onCursorChange()}
onKeyDown={(e) => auto?.onKeyDown(e)}
keyBindings={[
{ name: "return", action: "submit" } satisfies KeyBinding,
{ name: "return", shift: true, action: "newline" } satisfies KeyBinding,
]}
onSubmit={submit}
/>
</box>
</box>
<box
@@ -211,6 +273,15 @@ export function ClawChat(props: {
/>
</box>
</box>
{/* Slash autocomplete popup, anchored to the textarea wrapper */}
<ClawAutocomplete
value={inputValue()}
slashes={props.slashes}
anchor={() => inputAnchor}
input={() => input}
ref={(r) => (auto = r)}
/>
</box>
)
}
+428 -114
View File
@@ -1,161 +1,475 @@
// kilocode_change - new file
/**
* KiloClaw Stream Chat client wrapper
* KiloClaw Kilo Chat client wrapper for the TUI.
*
* Headless Stream Chat JS SDK integration for the TUI.
* Uses the same channel/credentials as the web dashboard's ChatTab.
* Hosts the HTTP and WebSocket clients, exposes a multi-conversation API
* (list/create/select/rename/delete), and translates Kilo Chat events for
* the active conversation into the legacy `ChatMessage` shape that the
* existing single-pane renderer consumes.
*
* 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).
* Switching the active conversation rotates the WebSocket subscription so
* we only ever stream events for the conversation the user is looking at.
*/
import type { ChatCredentials, ChatMessage } from "./types"
import type {
BotStatusEvent,
ChatMessage,
ChatToken,
ContentBlock,
ConversationActivityEvent,
ConversationListItem,
ConversationRenamedEvent,
ConversationStatusEvent,
ConversationStatusRecord,
Message,
MessageCreatedEvent,
MessageUpdatedEvent,
TypingEvent,
TypingMember,
} from "./types"
import { KiloChatClient } from "./kilo-chat-client"
import { EventServiceClient } from "./event-service-client"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "claw-chat" })
export type ConversationStatusListener = (status: ConversationStatusRecord | null) => void
export type ConversationsListener = (conversations: ConversationListItem[]) => void
export type ActiveConversationListener = (id: string | null) => void
export type TypingMembersListener = (members: TypingMember[]) => void
export type ClawChatClient = {
channel: any
disconnect: () => Promise<void>
// Conversations
listConversations: () => ConversationListItem[]
activeConversationId: () => string | null
selectConversation: (
conversationId: string,
) => Promise<{ messages: ChatMessage[]; status: ConversationStatusRecord | null }>
createConversation: (title?: string) => Promise<string>
renameConversation: (conversationId: string, title: string) => Promise<void>
deleteConversation: (conversationId: string) => Promise<void>
refreshConversations: () => Promise<ConversationListItem[]>
// Active conversation
send: (text: string) => Promise<void>
loadHistory: (conversationId?: string) => Promise<ChatMessage[]>
initialBotOnline: () => boolean
conversationStatus: () => ConversationStatusRecord | null
typingMembers: () => TypingMember[]
// Subscriptions
onMessage: (cb: (msg: ChatMessage) => void) => () => void
onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void
onPresence: (cb: (online: boolean) => void) => () => void
onConversations: (cb: ConversationsListener) => () => void
onActiveConversation: (cb: ActiveConversationListener) => () => void
onConversationStatus: (cb: ConversationStatusListener) => () => void
onTypingMembers: (cb: TypingMembersListener) => () => void
}
export function botId(creds: ChatCredentials): string {
return `bot-${creds.channelId.replace(/^default-/, "")}`
function blocksToText(content: ContentBlock[]): string {
let out = ""
for (const b of content) {
if (b.type === "text") out += b.text
}
return out
}
function toMessage(raw: any, bot: string): ChatMessage {
const ULID_TIME_LEN = 10
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
function ulidToTimestamp(id: string): number {
if (!id || id.length < ULID_TIME_LEN) return Date.now()
const time = id.slice(0, ULID_TIME_LEN).toUpperCase()
let ts = 0
for (const ch of time) {
const idx = ENCODING.indexOf(ch)
if (idx === -1) return Date.now()
ts = ts * ENCODING.length + idx
}
return ts
}
function toChatMessage(m: Message, currentUserId: 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,
id: m.id,
text: m.deleted ? "[deleted message]" : blocksToText(m.content),
user: m.senderId,
created: new Date(ulidToTimestamp(m.id)),
bot: m.senderId.startsWith("bot:") || m.senderId !== currentUserId,
}
}
/**
* 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
function toChatMessageFromCreated(e: MessageCreatedEvent, currentUserId: string): ChatMessage {
return {
id: e.messageId,
text: blocksToText(e.content),
user: e.senderId,
created: new Date(ulidToTimestamp(e.messageId)),
bot: e.senderId.startsWith("bot:") || e.senderId !== currentUserId,
}
}
const orig = Error.captureStackTrace
if (orig) {
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
function toChatMessageFromUpdated(
e: MessageUpdatedEvent,
currentUserId: string,
senderHint: string | null,
): ChatMessage {
const sender = senderHint ?? "bot"
return {
id: e.messageId,
text: blocksToText(e.content),
user: sender,
created: new Date(ulidToTimestamp(e.messageId)),
bot: sender.startsWith("bot:") || sender !== currentUserId,
}
}
export type ConnectInput = {
envelope: ChatToken
sandboxId: string
currentUserId: string
}
export async function connect(input: ConnectInput): Promise<ClawChatClient> {
const events = new EventServiceClient({
url: input.envelope.eventServiceUrl,
getToken: async () => input.envelope.token,
})
const chat = new KiloChatClient({
baseUrl: input.envelope.kiloChatUrl,
getToken: async () => input.envelope.token,
})
log.info("connecting to event-service")
await events.connect()
// Subscribe to sandbox-level context for conversation.* + bot.status events
const sandboxCtx = `/kiloclaw/${input.sandboxId}`
events.subscribe([sandboxCtx])
// Per-message sender cache so message.updated events know who the sender
// was (the event payload only carries messageId + content). Indexed by
// messageId — each conversation's messages live in their own ULID space.
const senderCache = new Map<string, string>()
// Conversation list state (refreshed reactively from server events).
let conversations: ConversationListItem[] = []
let activeId: string | null = null
let activeStatus: ConversationStatusRecord | null = null
let activeCtx: string | null = null
// Typing state for the active conversation. memberId → expiresAt epoch.
// Entries auto-expire after TYPING_TIMEOUT_MS in case typing.stop is missed.
const TYPING_TIMEOUT_MS = 5_000
const typing = new Map<string, number>()
let typingTimer: ReturnType<typeof setInterval> | null = null
const messageListeners = new Set<(msg: ChatMessage) => void>()
const messageUpdatedListeners = new Set<(msg: ChatMessage) => void>()
const presenceListeners = new Set<(online: boolean) => void>()
const conversationsListeners = new Set<ConversationsListener>()
const activeListeners = new Set<ActiveConversationListener>()
const statusListeners = new Set<ConversationStatusListener>()
const typingListeners = new Set<TypingMembersListener>()
function emit<T>(set: Set<(v: T) => void>, value: T): void {
for (const cb of set) cb(value)
}
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)
async function loadConversations(): Promise<ConversationListItem[]> {
const res = await chat.listConversations({ sandboxId: input.sandboxId, limit: 50 })
conversations = res.conversations
emit(conversationsListeners, conversations)
return conversations
}
function snapshotTyping(): TypingMember[] {
const out: TypingMember[] = []
for (const [memberId, expiresAt] of typing) {
out.push({ memberId, at: expiresAt - TYPING_TIMEOUT_MS })
}
} 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)
// getInstance returns a singleton — ensure any stale connection is
// cleaned up before (re-)connecting so navigate-away-and-back works.
if (client.userID) {
log.info("disconnecting stale user", { user: client.userID })
await client.disconnectUser()
return out
}
log.info("connectUser", { user: creds.userId })
await client.connectUser({ id: creds.userId }, creds.userToken)
function emitTyping(): void {
emit(typingListeners, snapshotTyping())
}
log.info("watching channel", { channel: creds.channelId })
const channel = client.channel("messaging", creds.channelId)
await channel.watch({ presence: true })
function clearTyping(): void {
if (typing.size === 0) return
typing.clear()
emitTyping()
}
log.info("connected successfully")
const bot = botId(creds)
function pruneTyping(): void {
const now = Date.now()
let removed = false
for (const [memberId, expiresAt] of typing) {
if (expiresAt <= now) {
typing.delete(memberId)
removed = true
}
}
if (removed) emitTyping()
if (typing.size === 0 && typingTimer !== null) {
clearInterval(typingTimer)
typingTimer = null
}
}
function startTyping(memberId: string): void {
typing.set(memberId, Date.now() + TYPING_TIMEOUT_MS)
if (typingTimer === null) {
typingTimer = setInterval(pruneTyping, 1_000)
}
emitTyping()
}
function stopTyping(memberId: string): void {
if (!typing.delete(memberId)) return
emitTyping()
if (typing.size === 0 && typingTimer !== null) {
clearInterval(typingTimer)
typingTimer = null
}
}
function setActive(id: string | null): void {
if (activeId === id) return
if (activeCtx) {
events.unsubscribe([activeCtx])
activeCtx = null
}
activeId = id
activeStatus = null
clearTyping()
if (id) {
activeCtx = `/kiloclaw/${input.sandboxId}/${id}`
events.subscribe([activeCtx])
}
emit(activeListeners, activeId)
emit(statusListeners, activeStatus)
}
// Initial bot status snapshot
const initialOnline = await chat
.getBotStatus(input.sandboxId)
.then((s) => s.status?.online ?? false)
.catch((err) => {
log.warn("getBotStatus failed", { error: (err as Error)?.message ?? String(err) })
return false
})
// Initial conversation list
await loadConversations().catch((err) => {
log.warn("listConversations failed", { error: (err as Error)?.message ?? String(err) })
})
// ── Sandbox-scoped event handlers ──────────────────────────────────
events.on("conversation.created", (ctx) => {
if (ctx !== sandboxCtx) return
void loadConversations()
})
events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => {
if (ctx !== sandboxCtx) return
conversations = conversations.map((c) => (c.conversationId === e.conversationId ? { ...c, title: e.title } : c))
emit(conversationsListeners, conversations)
})
events.on("conversation.left", (ctx, e) => {
if (ctx !== sandboxCtx) return
conversations = conversations.filter((c) => c.conversationId !== e.conversationId)
emit(conversationsListeners, conversations)
if (activeId === e.conversationId) setActive(null)
})
events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => {
if (ctx !== sandboxCtx) return
conversations = conversations.map((c) =>
c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c,
)
emit(conversationsListeners, conversations)
})
events.on("bot.status", (ctx, e: BotStatusEvent) => {
if (ctx !== sandboxCtx) return
if (e.sandboxId !== input.sandboxId) return
emit(presenceListeners, e.online)
})
// ── Conversation-scoped event handlers ─────────────────────────────
// A fresh message implicitly stops the sender's typing indicator (the bot
// streams via message.updated, so its typing.stop arrives when the next
// bot message id appears). Handle both in a single listener to avoid
// duplicating the activeCtx filter.
events.on("message.created", (ctx, e: MessageCreatedEvent) => {
if (ctx !== activeCtx) return
senderCache.set(e.messageId, e.senderId)
if (e.senderId !== input.currentUserId) stopTyping(e.senderId)
emit(messageListeners, toChatMessageFromCreated(e, input.currentUserId))
})
events.on("message.updated", (ctx, e: MessageUpdatedEvent) => {
if (ctx !== activeCtx) return
const sender = senderCache.get(e.messageId) ?? null
emit(messageUpdatedListeners, toChatMessageFromUpdated(e, input.currentUserId, sender))
})
events.on("conversation.status", (ctx, e: ConversationStatusEvent) => {
if (ctx !== activeCtx) return
if (e.conversationId !== activeId) return
activeStatus = {
conversationId: e.conversationId,
contextTokens: e.contextTokens,
contextWindow: e.contextWindow,
model: e.model,
provider: e.provider,
at: e.at,
updatedAt: Date.now(),
}
emit(statusListeners, activeStatus)
})
events.on("typing", (ctx, e: TypingEvent) => {
if (ctx !== activeCtx) return
if (e.memberId === input.currentUserId) return
startTyping(e.memberId)
})
events.on("typing.stop", (ctx, e: TypingEvent) => {
if (ctx !== activeCtx) return
if (e.memberId === input.currentUserId) return
stopTyping(e.memberId)
})
async function loadHistory(conversationId?: string): Promise<ChatMessage[]> {
const id = conversationId ?? activeId
if (!id) return []
const res = await chat.listMessages(id, { limit: 50 })
const ascending = [...res.messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
for (const m of ascending) senderCache.set(m.id, m.senderId)
return ascending.map((m) => toChatMessage(m, input.currentUserId))
}
async function selectConversation(conversationId: string) {
setActive(conversationId)
const msgs = await loadHistory(conversationId)
// The user may have switched conversations while we awaited. Only
// publish the status via listeners if it still matches the active one,
// but always return the status we fetched for the requested id so the
// caller can use it if they need to.
const status = await chat.getConversationStatus(conversationId).then(
(res) => res.status ?? null,
(err) => {
log.warn("getConversationStatus failed", { error: (err as Error)?.message ?? String(err) })
return null
},
)
if (activeId === conversationId) {
activeStatus = status
emit(statusListeners, activeStatus)
}
await chat.markConversationRead(conversationId).catch((err) => {
log.warn("markConversationRead failed", { error: (err as Error)?.message ?? String(err) })
})
return { messages: msgs, status }
}
async function createConversation(title?: string): Promise<string> {
const res = await chat.createConversation({ sandboxId: input.sandboxId, title })
await loadConversations()
return res.conversationId
}
return {
channel,
async disconnect() {
await client.disconnectUser()
if (typingTimer !== null) {
clearInterval(typingTimer)
typingTimer = null
}
typing.clear()
events.disconnect()
},
listConversations() {
return conversations
},
activeConversationId() {
return activeId
},
typingMembers() {
return snapshotTyping()
},
async refreshConversations() {
return await loadConversations()
},
selectConversation,
createConversation,
async renameConversation(conversationId, title) {
conversations = conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c))
emit(conversationsListeners, conversations)
await chat.renameConversation(conversationId, title)
},
async deleteConversation(conversationId) {
await chat.leaveConversation(conversationId)
conversations = conversations.filter((c) => c.conversationId !== conversationId)
emit(conversationsListeners, conversations)
if (activeId === conversationId) setActive(null)
},
async send(text: string) {
await channel.sendMessage({ text })
const trimmed = text.trim()
if (!trimmed) return
if (!activeId) {
// Auto-create a conversation on first send if none is active.
const id = await createConversation()
await selectConversation(id)
}
if (!activeId) return
await chat.sendMessage({
conversationId: activeId,
content: [{ type: "text", text: trimmed }],
})
},
loadHistory,
initialBotOnline() {
return initialOnline
},
conversationStatus() {
return activeStatus
},
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)
messageListeners.add(cb)
return () => messageListeners.delete(cb)
},
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)
messageUpdatedListeners.add(cb)
return () => messageUpdatedListeners.delete(cb)
},
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)
presenceListeners.add(cb)
return () => presenceListeners.delete(cb)
},
onConversations(cb) {
conversationsListeners.add(cb)
return () => conversationsListeners.delete(cb)
},
onActiveConversation(cb) {
activeListeners.add(cb)
return () => activeListeners.delete(cb)
},
onConversationStatus(cb) {
statusListeners.add(cb)
return () => statusListeners.delete(cb)
},
onTypingMembers(cb) {
typingListeners.add(cb)
return () => typingListeners.delete(cb)
},
}
}
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,129 @@
// kilocode_change - new file
/**
* Conversation selector for the KiloClaw chat.
*
* Mirrors the session-list dialog (`cli/cmd/tui/component/dialog-session-list.tsx`):
* - DialogSelect-backed list, grouped by date bucket
* - "current" marker on the active conversation
* - inline rename via DialogPrompt (replace pattern)
* - two-press delete confirmation with `theme.error` background
*/
import { DialogSelect } from "@tui/ui/dialog-select"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { useDialog } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { useTheme } from "@tui/context/theme"
import { useToast } from "@tui/ui/toast"
import { Locale, Keybind } from "@/util"
import { createMemo, createSignal, onMount } from "solid-js"
import type { ClawChat } from "./hooks"
type Props = {
chat: ClawChat
}
export function DialogConversationList(props: Props) {
const dialog = useDialog()
const keybind = useKeybind()
const { theme } = useTheme()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const options = createMemo(() => {
const today = new Date().toDateString()
const list = [...props.chat.conversations()]
list.sort((a, b) => {
const ax = a.lastActivityAt ?? a.joinedAt
const bx = b.lastActivityAt ?? b.joinedAt
return bx - ax
})
return list.map((c) => {
const ts = c.lastActivityAt ?? c.joinedAt
const date = new Date(ts)
let category = date.toDateString()
if (category === today) category = "Today"
const isDeleting = toDelete() === c.conversationId
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : (c.title ?? "Untitled"),
bg: isDeleting ? theme.error : undefined,
value: c.conversationId,
category,
footer: Locale.time(ts),
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="KiloClaw Conversations"
options={options()}
current={props.chat.activeConversationId() ?? undefined}
onMove={() => setToDelete(undefined)}
onSelect={async (option) => {
await props.chat.selectConversation(option.value)
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
// `deleteConversation` already catches internally and returns
// a boolean; its outer promise never rejects here.
const ok = await props.chat.deleteConversation(option.value)
if (!ok) {
toast.show({
variant: "error",
title: "Failed to delete conversation",
message: "Please try again.",
})
}
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
const item = props.chat.conversations().find((c) => c.conversationId === option.value)
const list = () => dialog.replace(() => <DialogConversationList chat={props.chat} />)
dialog.replace(() => (
<DialogPrompt
title="Rename Conversation"
value={item?.title ?? ""}
placeholder="Enter a title"
onConfirm={async (value) => {
const trimmed = value.trim()
if (trimmed) {
await props.chat.renameConversation(option.value, trimmed)
}
list()
}}
onCancel={list}
/>
))
},
},
{
keybind: Keybind.parse("ctrl+n")[0],
title: "new",
side: "right",
onTrigger: async () => {
await props.chat.newConversation()
dialog.clear()
},
},
]}
/>
)
}
@@ -0,0 +1,331 @@
// kilocode_change - new file
/**
* Event Service WebSocket client for the TUI.
*
* Minimal inline port of `@kilocode/event-service` (cloud monorepo).
* Authenticates with a JWT carried in the `kilo.jwt.<base64url>`
* subprotocol header, subscribes to per-context paths, and dispatches
* server events to typed handlers.
*
* Uses the global `WebSocket` constructor (Bun, Node 22+, browsers).
*/
import type { KiloChatEventMap, KiloChatEventName } from "./types"
const SUBPROTOCOL_PREFIX = "kilo.jwt."
const HANDSHAKE_TIMEOUT_MS = 10_000
const PING_INTERVAL_MS = 15_000
export class WebSocketAuthError extends Error {
constructor(message = "WebSocket authentication failed") {
super(message)
this.name = "WebSocketAuthError"
}
}
export class WebSocketConnectError extends Error {
constructor(
message: string,
public readonly code: number,
) {
super(message)
this.name = "WebSocketConnectError"
}
}
export class HandshakeTimeoutError extends Error {
constructor() {
super("WebSocket handshake timed out")
this.name = "HandshakeTimeoutError"
}
}
// Close codes that signal the server rejected us for auth/policy reasons
// and reconnecting with the same token is pointless. Everything else
// (including 1006 "abnormal closure" from flaky networks) is transient.
function isAuthCloseCode(code: number): boolean {
if (code === 1008) return true // Policy Violation
if (code === 4401 || code === 4403) return true // Custom auth rejection
return false
}
export type EventHandler = (context: string, payload: unknown) => void
export type EventServiceConfig = {
url: string
getToken: () => Promise<string>
onUnauthorized?: () => void
}
function encodeBase64Url(input: string): string {
const b64 = typeof btoa === "function" ? btoa(input) : Buffer.from(input, "utf8").toString("base64")
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
export class EventServiceClient {
private readonly url: string
private readonly getToken: () => Promise<string>
private readonly onUnauthorized: (() => void) | undefined
private ws: WebSocket | null = null
private connected = false
private destroyed = false
private reconnectAttempts = 0
private hasConnectedBefore = false
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private abortHandshake: ((err: Error) => void) | null = null
private eventHandlers = new Map<string, Set<EventHandler>>()
private activeContexts = new Set<string>()
private reconnectHandlers = new Set<() => void>()
constructor(config: EventServiceConfig) {
this.url = config.url
this.getToken = config.getToken
this.onUnauthorized = config.onUnauthorized
}
async connect(): Promise<void> {
this.destroyed = false
this.reconnectAttempts = 0
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
try {
await this.connectOnce()
} catch (err) {
if (this.handleAuthFailure(err)) return
if (!this.destroyed) this.scheduleReconnect()
}
}
disconnect(): void {
this.destroyed = true
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.abortHandshake) {
this.abortHandshake(new Error("disconnected"))
}
if (this.ws) {
this.ws.close()
this.ws = null
}
this.stopPing()
this.connected = false
}
isConnected(): boolean {
return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
subscribe(contexts: string[]): void {
for (const ctx of contexts) this.activeContexts.add(ctx)
if (this.isConnected()) {
this.sendJson({ type: "context.subscribe", contexts })
}
}
unsubscribe(contexts: string[]): void {
for (const ctx of contexts) this.activeContexts.delete(ctx)
if (this.isConnected()) {
this.sendJson({ type: "context.unsubscribe", contexts })
}
}
on<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N])
set.add(wrapped)
this.eventHandlers.set(event, set)
return () => {
set.delete(wrapped)
if (set.size === 0) this.eventHandlers.delete(event)
}
}
onReconnect(handler: () => void): () => void {
this.reconnectHandlers.add(handler)
return () => this.reconnectHandlers.delete(handler)
}
// ── private ────────────────────────────────────────────────────────
private handleAuthFailure(err: unknown): boolean {
if (err instanceof WebSocketAuthError) {
this.destroyed = true
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.onUnauthorized?.()
return true
}
return false
}
private async connectOnce(): Promise<void> {
if (this.ws) {
const old = this.ws
this.ws = null
old.close()
}
const token = await this.getToken()
const subprotocol = `${SUBPROTOCOL_PREFIX}${encodeBase64Url(token)}`
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`${this.url}/connect`, [subprotocol])
this.ws = ws
let settled = false
const settleResolve = () => {
if (settled) return
settled = true
this.clearHandshakeTimer()
this.abortHandshake = null
resolve()
}
const settleReject = (err: Error) => {
if (settled) return
settled = true
this.clearHandshakeTimer()
this.abortHandshake = null
reject(err)
}
this.abortHandshake = settleReject
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (this.ws === ws) ws.close(1000, "handshake-timeout")
settleReject(new HandshakeTimeoutError())
}, HANDSHAKE_TIMEOUT_MS)
ws.addEventListener("open", () => {
const isReconnect = this.hasConnectedBefore
this.connected = true
this.hasConnectedBefore = true
this.reconnectAttempts = 0
this.resubscribeContexts()
if (isReconnect) {
for (const h of this.reconnectHandlers) h()
}
settleResolve()
this.startPing()
})
ws.addEventListener("message", (event: MessageEvent) => {
this.handleMessage(String(event.data))
})
ws.addEventListener("close", (event: CloseEvent) => {
if (this.ws !== ws) return
const wasConnected = this.connected
this.connected = false
this.stopPing()
this.clearHandshakeTimer()
// A handshake failure always fires `close` after `error`, so we
// settle here with a classification based on the close code:
// explicit auth/policy codes → fatal; anything else → transient
// and the caller (`connect`) will schedule a reconnect.
if (!wasConnected) {
if (isAuthCloseCode(event.code)) {
settleReject(new WebSocketAuthError())
} else {
settleReject(
new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code),
)
}
return
}
if (!this.destroyed) this.scheduleReconnect()
})
ws.addEventListener("error", () => {
// Swallowed: the `close` event fires right after and carries the
// close code we need to distinguish auth failures from network
// blips. Settling here loses that context.
})
})
}
private clearHandshakeTimer(): void {
if (this.handshakeTimer !== null) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
private sendJson(msg: unknown): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg))
}
}
private handleMessage(data: string): void {
if (data === "pong") return
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch {
return
}
if (!parsed || typeof parsed !== "object") return
const m = parsed as Record<string, unknown>
if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") {
const handlers = this.eventHandlers.get(m.event)
if (handlers) {
for (const h of handlers) h(m.context, m.payload)
}
return
}
if (m.type === "error") {
console.warn("[Kilo] event-service server error", m)
}
}
private startPing(): void {
this.stopPing()
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send("ping")
}
}, PING_INTERVAL_MS)
}
private stopPing(): void {
if (this.pingTimer !== null) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
private resubscribeContexts(): void {
if (this.activeContexts.size > 0) {
this.sendJson({
type: "context.subscribe",
contexts: Array.from(this.activeContexts),
})
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return
const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts)
const delay = base * (0.5 + Math.random() * 0.5)
this.reconnectAttempts++
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connectOnce().catch((err) => {
if (this.handleAuthFailure(err)) return
if (!this.destroyed) this.scheduleReconnect()
})
}, delay)
}
}
+186 -55
View File
@@ -1,24 +1,38 @@
// kilocode_change - new file
/**
* KiloClaw SolidJS reactive helpers
* KiloClaw SolidJS reactive helpers — Kilo Chat protocol.
*
* Provides reactive state management for polling instance status
* and managing the Stream Chat connection lifecycle.
* Exposes:
* - `createClawStatus`: polled instance status
* - `createClawChat`: Kilo Chat connection + active-conversation state
*
* `createClawChat` returns a reactive surface the view binds to: messages,
* conversation list, active conversation id + status (token counter), bot
* presence, and imperative actions (send / new / select / rename / delete).
*/
import { createSignal, onMount, onCleanup } from "solid-js"
import type { ClawStatus, ChatCredentials, ChatMessage } from "./types"
import { botId, connect, history, presence, type ClawChatClient } from "./client"
import type {
ChatMessage,
ChatToken,
ClawStatus,
ConversationListItem,
ConversationStatusRecord,
TypingMember,
} from "./types"
import { connect, type ClawChatClient } from "./client"
import { useSDK } from "@tui/context/sdk"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "claw-chat" })
type SDK = ReturnType<typeof useSDK>
/**
* Poll the KiloClaw instance status every `interval` ms.
* Returns a reactive signal with the latest status.
*/
export function createClawStatus(sdk: any, interval = 10_000) {
export function createClawStatus(sdk: SDK, interval = 10_000) {
const [status, setStatus] = createSignal<ClawStatus | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [loading, setLoading] = createSignal(true)
@@ -30,7 +44,10 @@ export function createClawStatus(sdk: any, interval = 10_000) {
setStatus(res.data as ClawStatus)
setError(null)
} else if (res?.error) {
setError(typeof res.error === "string" ? res.error : (res.error?.error ?? "Unknown error"))
// Gateway error envelopes are either `{ error: "..." }` or a plain
// string; the SDK types the error body as `unknown`.
const err = res.error as string | { error?: string } | null
setError(typeof err === "string" ? err : (err?.error ?? "Unknown error"))
} else {
setError("Network error")
}
@@ -44,97 +61,189 @@ export function createClawStatus(sdk: any, interval = 10_000) {
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) {
export type ClawChat = ReturnType<typeof createClawChat>
export function createClawChat(sdk: SDK) {
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)
const [conversations, setConversations] = createSignal<ConversationListItem[]>([])
const [activeConversationId, setActiveConversationId] = createSignal<string | null>(null)
const [conversationStatus, setConversationStatus] = createSignal<ConversationStatusRecord | null>(null)
const [typingMembers, setTypingMembers] = createSignal<TypingMember[]>([])
const MAX_MESSAGES = 500
let chat: ClawChatClient | null = null
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
const send = async (text: string): Promise<boolean> => {
if (!chat) return false
try {
await chat.send(text)
return true
} catch (err: any) {
log.error("send failed", { error: err?.message ?? String(err) })
} catch (err) {
log.error("send failed", { error: errText(err) })
setError("Failed to send message")
return false
}
}
const newConversation = async (title?: string): Promise<string | null> => {
if (!chat) return null
try {
const id = await chat.createConversation(title)
await selectConversation(id)
return id
} catch (err) {
log.error("createConversation failed", { error: errText(err) })
setError("Failed to create conversation")
return null
}
}
const selectConversation = async (conversationId: string): Promise<boolean> => {
if (!chat) return false
try {
const result = await chat.selectConversation(conversationId)
setMessages(result.messages)
setConversationStatus(result.status)
return true
} catch (err) {
log.error("selectConversation failed", { error: errText(err) })
setError("Failed to load conversation")
return false
}
}
const renameConversation = async (conversationId: string, title: string): Promise<boolean> => {
if (!chat) return false
try {
await chat.renameConversation(conversationId, title)
return true
} catch (err) {
log.error("renameConversation failed", { error: errText(err) })
setError("Failed to rename conversation")
return false
}
}
const deleteConversation = async (conversationId: string): Promise<boolean> => {
if (!chat) return false
try {
await chat.deleteConversation(conversationId)
return true
} catch (err) {
log.error("deleteConversation failed", { error: errText(err) })
setError("Failed to delete conversation")
return false
}
}
const refreshConversations = async (): Promise<void> => {
if (!chat) return
try {
await chat.refreshConversations()
} catch (err) {
log.warn("refreshConversations failed", { error: errText(err) })
}
}
onMount(async () => {
// Register cleanup synchronously while the SolidJS owner scope is
// still available. After any `await` the owner is lost, so
// `onCleanup` calls would be silently discarded.
const cleanup = {
unsub: null as (() => void) | null,
unsubUpdated: null as (() => void) | null,
unsubPresence: null as (() => void) | null,
unsubConvs: null as (() => void) | null,
unsubActive: null as (() => void) | null,
unsubStatus: null as (() => void) | null,
unsubTyping: null as (() => void) | null,
}
onCleanup(() => {
cleanup.unsub?.()
cleanup.unsubUpdated?.()
cleanup.unsubPresence?.()
cleanup.unsubConvs?.()
cleanup.unsubActive?.()
cleanup.unsubStatus?.()
cleanup.unsubTyping?.()
if (chat) {
chat.disconnect().catch((err) => {
log.error("disconnect failed", { error: err?.message ?? String(err) })
log.error("disconnect failed", { error: errText(err) })
})
}
chat = null
})
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")
log.info("fetching status + credentials")
const statusRes = await sdk.client.kilo.claw.status().catch(() => null)
const statusData = statusRes?.data as (ClawStatus & { userId?: string; sandboxId?: string }) | undefined
if (!statusRes || statusRes.error || !statusData?.userId || !statusData?.sandboxId) {
setError(null)
setLoading(false)
return
}
const creds = res.data as ChatCredentials
const credsRes = await sdk.client.kilo.claw.chatCredentials().catch((e: unknown) => {
log.error("chatCredentials() threw", { error: errText(e) })
return null
})
if (!credsRes?.data || credsRes.error) {
setError(credsRes?.data === null ? null : "Failed to fetch chat credentials")
setLoading(false)
return
}
const envelope = credsRes.data as ChatToken
const missing: string[] = []
if (!envelope.token) missing.push("token")
if (!envelope.kiloChatUrl) missing.push("kiloChatUrl")
if (!envelope.eventServiceUrl) missing.push("eventServiceUrl")
if (missing.length > 0) {
setError(`Malformed chat credentials response: missing ${missing.join(", ")}`)
setLoading(false)
return
}
try {
log.info("calling connect()")
chat = await connect(creds)
log.info("connect() succeeded")
log.info("connecting kilo-chat")
chat = await connect({
envelope,
sandboxId: statusData.sandboxId,
currentUserId: statusData.userId,
})
log.info("connected")
// Load existing messages
const bot = botId(creds)
const existing = await history(chat.channel, bot)
log.info("loaded history", { count: existing.length })
setMessages(existing)
setConversations(chat.listConversations())
setOnline(chat.initialBotOnline())
// Read initial bot presence from channel member state
setOnline(presence(chat.channel, bot))
cleanup.unsubConvs = chat.onConversations((list) => {
setConversations(list)
})
cleanup.unsubActive = chat.onActiveConversation((id) => {
setActiveConversationId(id)
})
cleanup.unsubStatus = chat.onConversationStatus((status) => {
setConversationStatus(status)
})
cleanup.unsubTyping = chat.onTypingMembers((members) => {
setTypingMembers(members)
})
// Subscribe to new messages
cleanup.unsub = chat.onMessage((msg) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev
const next = [...prev, msg]
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next
})
})
// Subscribe to message updates (bot streams token-by-token via message.updated)
cleanup.unsubUpdated = chat.onMessageUpdated((msg) => {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === msg.id)
@@ -148,22 +257,44 @@ export function createClawChat(sdk: any) {
})
})
// Subscribe to bot presence changes
cleanup.unsubPresence = chat.onPresence(setOnline)
// Auto-open the most recent conversation, if any.
const list = chat.listConversations()
if (list.length > 0) {
await selectConversation(list[0]!.conversationId)
}
setConnected(true)
setLoading(false)
} catch (err: any) {
} catch (err) {
const e = err as { message?: string; name?: string; code?: string; stack?: string }
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"),
error: errText(err),
name: e?.name,
code: e?.code,
stack: e?.stack?.split("\n").slice(0, 5).join("\n"),
})
setError(err?.message ?? "Failed to connect to chat")
setError(e?.message ?? "Failed to connect to chat")
setLoading(false)
}
})
return { messages, online, connected, error, loading, send }
return {
messages,
online,
connected,
error,
loading,
send,
conversations,
activeConversationId,
conversationStatus,
typingMembers,
newConversation,
selectConversation,
renameConversation,
deleteConversation,
refreshConversations,
}
}
@@ -0,0 +1,217 @@
// kilocode_change - new file
/**
* HTTP client for the kilo-chat Cloudflare Worker.
*
* Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo)
* tailored to what the TUI needs. The kilo-chat worker validates payloads
* at its edge so we don't run zod here.
*/
import type {
BotStatusRecord,
ContentBlock,
ConversationListItem,
ConversationStatusRecord,
ExecApprovalDecision,
Message,
} from "./types"
export type KiloChatClientConfig = {
baseUrl: string
getToken: () => Promise<string>
onUnauthorized?: () => void
}
export class KiloChatApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
) {
super(`KiloChat request failed: ${status}`)
this.name = "KiloChatApiError"
}
}
type HttpOpts = {
method?: string
body?: unknown
query?: Record<string, string | number | boolean | undefined | null>
}
type SendQueue = Map<string, Promise<unknown>>
export class KiloChatClient {
private readonly baseUrl: string
private readonly getToken: () => Promise<string>
private readonly onUnauthorized: (() => void) | undefined
private readonly sendQueues: SendQueue = new Map()
constructor(config: KiloChatClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, "")
this.getToken = config.getToken
this.onUnauthorized = config.onUnauthorized
}
// ── Conversations ────────────────────────────────────────────────
listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{
conversations: ConversationListItem[]
hasMore: boolean
nextCursor: string | null
}> {
return this.request("/v1/conversations", {
query: {
sandboxId: opts?.sandboxId,
limit: opts?.limit,
cursor: opts?.cursor ?? undefined,
},
})
}
createConversation(req: { sandboxId: string; title?: string }): Promise<{ conversationId: string }> {
return this.request("/v1/conversations", { method: "POST", body: req })
}
renameConversation(conversationId: string, title: string): Promise<{ ok: true }> {
return this.request(`/v1/conversations/${conversationId}`, {
method: "PATCH",
body: { title },
})
}
async leaveConversation(conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/leave`, { method: "POST" })
}
async markConversationRead(conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/mark-read`, { method: "POST" })
}
// ── Messages ─────────────────────────────────────────────────────
sendMessage(req: {
conversationId: string
content: ContentBlock[]
inReplyToMessageId?: string
clientId?: string
}): Promise<{ messageId: string; clientId?: string }> {
const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve()
const send = () =>
this.request<{ messageId: string; clientId?: string }>("/v1/messages", {
method: "POST",
body: req,
})
const next = prev.then(send, send)
this.sendQueues.set(req.conversationId, next)
void next.finally(() => {
if (this.sendQueues.get(req.conversationId) === next) {
this.sendQueues.delete(req.conversationId)
}
})
return next
}
editMessage(
messageId: string,
req: { conversationId: string; content: ContentBlock[]; timestamp: number },
): Promise<{ messageId?: string }> {
return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req })
}
async deleteMessage(messageId: string, conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/messages/${messageId}`, {
method: "DELETE",
query: { conversationId },
})
}
listMessages(conversationId: string, opts?: { before?: string; limit?: number }): Promise<{ messages: Message[] }> {
return this.request(`/v1/conversations/${conversationId}/messages`, {
query: { before: opts?.before, limit: opts?.limit },
})
}
executeAction(
conversationId: string,
messageId: string,
req: { groupId: string; value: ExecApprovalDecision },
): Promise<{ ok: true }> {
return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, {
method: "POST",
body: req,
})
}
// ── Reactions ────────────────────────────────────────────────────
addReaction(messageId: string, req: { conversationId: string; emoji: string }): Promise<{ id: string }> {
return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req })
}
async removeReaction(messageId: string, req: { conversationId: string; emoji: string }): Promise<void> {
await this.request<unknown>(`/v1/messages/${messageId}/reactions`, {
method: "DELETE",
query: req,
})
}
// ── Typing ───────────────────────────────────────────────────────
async sendTyping(conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
}
async sendTypingStop(conversationId: string): Promise<void> {
await this.request<unknown>(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" })
}
// ── Bot / conversation status ────────────────────────────────────
getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> {
return this.request(`/v1/sandboxes/${sandboxId}/bot-status`)
}
async requestBotStatus(sandboxId: string): Promise<void> {
await this.request<unknown>(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" })
}
getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> {
return this.request(`/v1/conversations/${conversationId}/conversation-status`)
}
// ── private ──────────────────────────────────────────────────────
private async request<T>(path: string, opts: HttpOpts = {}): Promise<T> {
const token = await this.getToken()
let url = `${this.baseUrl}${path}`
if (opts.query) {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(opts.query)) {
if (v === undefined || v === null) continue
params.set(k, String(v))
}
const qs = params.toString()
if (qs) url += `?${qs}`
}
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
if (opts.body !== undefined) headers["Content-Type"] = "application/json"
const res = await fetch(url, {
method: opts.method ?? "GET",
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
})
if (!res.ok) {
if (res.status === 401 || res.status === 403) this.onUnauthorized?.()
const body: unknown = await res.json().catch(() => null)
throw new KiloChatApiError(res.status, body)
}
if (res.status === 204) return undefined as unknown as T
return (await res.json()) as T
}
}
+107 -41
View File
@@ -3,15 +3,14 @@
/**
* 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).
* Mirrors the session sidebar (routes/session/sidebar.tsx): bold
* conversation title at the top, then context-window usage, then
* instance/bot/details sections.
*/
import { Show } from "solid-js"
import { useTheme } from "@tui/context/theme"
import type { ClawStatus } from "./types"
import type { ClawStatus, ConversationListItem, ConversationStatusRecord } from "./types"
function dot(status: string | null | undefined, theme: any): string {
if (!status) return theme.textMuted
@@ -35,6 +34,12 @@ function uptime(started: string | null | undefined): string {
return `${seconds}s`
}
function formatTokens(n: number): string {
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
return `${(n / 1_000_000).toFixed(1)}M`
}
export function ClawSidebar(props: {
status: ClawStatus | null
loading: boolean
@@ -43,9 +48,19 @@ export function ClawSidebar(props: {
connected: boolean
chatLoading: boolean
chatError: string | null
conversations: ConversationListItem[]
activeConversationId: string | null
conversationStatus: ConversationStatusRecord | null
}) {
const { theme } = useTheme()
const conversationTitle = () => {
const id = props.activeConversationId
if (!id) return "New conversation"
const conv = props.conversations.find((c) => c.conversationId === id)
return conv?.title ?? "Untitled"
}
return (
<box
backgroundColor={theme.backgroundPanel}
@@ -58,11 +73,81 @@ export function ClawSidebar(props: {
>
<scrollbox flexGrow={1}>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text}>
<b>KiloClaw</b>
</text>
</box>
{/* Conversation title (top, like session title) */}
<Show when={props.connected}>
<box paddingRight={1}>
<text fg={theme.text} wrapMode="word">
<b>{conversationTitle()}</b>
</text>
</box>
</Show>
{/* Bot status — same section pattern as Instance/Details */}
<Show when={props.connected || props.chatLoading || props.chatError}>
<box>
<text fg={theme.text}>
<b>Bot Status</b>
</text>
<Show when={props.chatError}>
<text fg={theme.error}>{props.chatError}</text>
</Show>
<Show when={!props.chatError && props.chatLoading}>
<text fg={theme.textMuted}>Connecting...</text>
</Show>
<Show when={!props.chatError && !props.chatLoading && 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.chatLoading && !props.connected}>
<text fg={theme.textMuted}>Unavailable</text>
</Show>
</box>
</Show>
{/* Context window usage */}
<Show when={props.connected && props.conversationStatus}>
{(s) => (
<box>
<text fg={theme.text}>
<b>Context</b>
</text>
<Show when={s().contextWindow > 0}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Used</text>
<text fg={theme.text}>
{Math.min(100, Math.round((s().contextTokens / s().contextWindow) * 100))}%
</text>
</box>
</Show>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Tokens</text>
<text fg={theme.text}>
{formatTokens(s().contextTokens)} / {formatTokens(s().contextWindow)}
</text>
</box>
<Show when={s().model}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Model</text>
<text fg={theme.text} wrapMode="none">
{s().model}
</text>
</box>
</Show>
<Show when={s().provider}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Provider</text>
<text fg={theme.text} wrapMode="none">
{s().provider}
</text>
</box>
</Show>
</box>
)}
</Show>
<Show when={props.loading}>
<text fg={theme.textMuted}>Loading...</text>
@@ -90,42 +175,23 @@ export function ClawSidebar(props: {
</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>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Region</text>
<text fg={theme.text}>{props.status!.flyRegion?.toUpperCase() ?? "—"}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Version</text>
<text fg={theme.text}>{props.status!.openclawVersion ?? "—"}</text>
</box>
<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>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Channels</text>
<text fg={theme.text}>{props.status!.channelCount}</text>
</box>
</Show>
</box>
</Show>
+155 -7
View File
@@ -1,9 +1,10 @@
// kilocode_change - new file
/**
* KiloClaw TUI types
* KiloClaw TUI types — Kilo Chat protocol.
*
* Types for the KiloClaw chat and dashboard feature in the TUI.
* Mirrors `@kilocode/kilo-chat` from the cloud monorepo. We carry our own
* copy here so the CLI can be built without an external dependency.
*/
export type ClawStatus = {
@@ -17,15 +18,162 @@ export type ClawStatus = {
channelCount?: number
secretCount?: number
userId?: string
// User-chosen name for the KiloClaw bot, set during onboarding via the
// `patchBotIdentity` mutation. May be null for fresh instances that
// skipped the bot-identity step. The chat UI should fall back to the
// literal string "KiloClaw" when null.
botName?: string | null
}
export type ChatCredentials = {
apiKey: string
userId: string
userToken: string
channelId: string
// ── Kilo Chat token envelope (gateway response) ─────────────────────
export type ChatToken = {
token: string
expiresAt: string // ISO timestamp
kiloChatUrl: string
eventServiceUrl: string
}
// ── Content blocks ──────────────────────────────────────────────────
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"
export type TextBlock = { type: "text"; text: string }
export type ActionItem = {
label: string
style: "primary" | "danger" | "secondary"
value: ExecApprovalDecision
}
export type ActionsBlock = {
type: "actions"
groupId: string
actions: ActionItem[]
resolved?: {
value: ExecApprovalDecision
resolvedBy: string
resolvedAt: number
}
}
export type ContentBlock = TextBlock | ActionsBlock
// ── Reactions ───────────────────────────────────────────────────────
export type ReactionSummary = {
emoji: string
count: number
memberIds: string[]
}
// ── Messages ────────────────────────────────────────────────────────
export type Message = {
id: string
senderId: string
content: ContentBlock[]
inReplyToMessageId: string | null
updatedAt: number | null
clientUpdatedAt: number | null
deleted: boolean
deliveryFailed: boolean
reactions: ReactionSummary[]
}
// ── Conversations ───────────────────────────────────────────────────
export type ConversationListItem = {
conversationId: string
title: string | null
lastActivityAt: number | null
lastReadAt: number | null
joinedAt: number
}
// ── Bot / conversation status ───────────────────────────────────────
export type BotStatusRecord = {
online: boolean
at: number
updatedAt: number
}
export type ConversationStatusRecord = {
conversationId: string
contextTokens: number
contextWindow: number
model: string | null
provider: string | null
at: number
updatedAt: number
}
// ── Events ──────────────────────────────────────────────────────────
export type MessageCreatedEvent = {
messageId: string
senderId: string
content: ContentBlock[]
inReplyToMessageId: string | null
clientId?: string
}
export type MessageUpdatedEvent = {
messageId: string
content: ContentBlock[]
clientUpdatedAt: number | null
}
export type MessageDeletedEvent = { messageId: string }
export type MessageDeliveryFailedEvent = { messageId: string }
export type TypingEvent = { memberId: string }
export type TypingMember = { memberId: string; at: number }
export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string }
export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string }
export type ConversationCreatedEvent = { conversationId: string }
export type ConversationRenamedEvent = { conversationId: string; title: string }
export type ConversationLeftEvent = { conversationId: string }
export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number }
export type ActionDeliveryFailedEvent = { conversationId: string; messageId: string; groupId: string }
export type BotStatusEvent = { sandboxId: string; online: boolean; at: number }
export type ConversationStatusEvent = {
conversationId: string
contextTokens: number
contextWindow: number
model: string | null
provider: string | null
at: number
}
export type KiloChatEventMap = {
"message.created": MessageCreatedEvent
"message.updated": MessageUpdatedEvent
"message.deleted": MessageDeletedEvent
"message.delivery_failed": MessageDeliveryFailedEvent
typing: TypingEvent
"typing.stop": TypingEvent
"reaction.added": ReactionAddedEvent
"reaction.removed": ReactionRemovedEvent
"conversation.created": ConversationCreatedEvent
"conversation.renamed": ConversationRenamedEvent
"conversation.left": ConversationLeftEvent
"conversation.activity": ConversationActivityEvent
"action.delivery_failed": ActionDeliveryFailedEvent
"bot.status": BotStatusEvent
"conversation.status": ConversationStatusEvent
}
export type KiloChatEventName = keyof KiloChatEventMap
// ── Legacy display message (for CLI rendering) ──────────────────────
/**
* Lightweight chat-message shape used by the existing CLI rendering layer.
* We keep this for backwards compatibility with components that already
* render text + bot flag. New code should prefer `Message`.
*/
export type ChatMessage = {
id: string
text: string
+80 -13
View File
@@ -12,16 +12,20 @@ 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 { useDialog } from "@tui/ui/dialog"
import { useCommandDialog, type CommandOption } from "@tui/component/dialog-command"
import { Toast } from "@tui/ui/toast"
import { ClawChat } from "./chat"
import { ClawSidebar } from "./sidebar"
import { createClawStatus, createClawChat } from "./hooks"
import { DialogConversationList } from "./dialog-conversation-list"
import type { ClawSlashOption } from "./autocomplete"
export function KiloClawView() {
const route = useRoute()
const sdk = useSDK()
const command = useCommandDialog()
const dialog = useDialog()
// Poll instance status
const { status, error: statusError, loading: statusLoading } = createClawStatus(sdk)
@@ -35,6 +39,12 @@ export function KiloClawView() {
return !s || s.status !== "running"
})
// Bot display name — sourced from the KiloClaw platform status (set by the
// user during onboarding via patchBotIdentity). Falls back to the literal
// "KiloClaw" while loading or for instances that skipped onboarding,
// matching the web UI's fallback chain.
const botName = createMemo(() => status()?.botName ?? "KiloClaw")
// Register escape to navigate back
useKeyboard((evt) => {
if (evt.name === "escape") {
@@ -44,19 +54,70 @@ export function KiloClawView() {
}
})
// Register view-specific commands
command.register(() => [
{
value: "kiloclaw.back",
title: "Back",
category: "KiloClaw",
hidden: true,
keybind: "escape" as any,
onSelect: () => {
route.back()
// KiloClaw view commands — single source of truth for both the global
// command palette / keybinds and the in-chat slash autocomplete.
// The list is reactive on `chat.connected` so the slash menu only
// exposes `/new` and `/conversations` once we're connected to kilo-chat.
const kiloCommands = createMemo<CommandOption[]>(() => {
const ready = chat.connected()
return [
{
value: "kiloclaw.back",
title: "Back",
description: "Return to the previous view",
category: "KiloClaw",
slash: { name: "back" },
keybind: "escape" as any,
onSelect: () => {
dialog.clear()
route.back()
},
},
},
])
{
value: "kiloclaw.new",
title: "New conversation",
description: "Start a new KiloClaw conversation",
category: "KiloClaw",
slash: { name: "new" },
enabled: ready,
hidden: !ready,
onSelect: async () => {
dialog.clear()
await chat.newConversation()
},
},
{
value: "kiloclaw.conversations",
title: "Conversations",
description: "Browse, rename, and delete KiloClaw conversations",
category: "KiloClaw",
slash: { name: "conversations", aliases: ["chats"] },
enabled: ready,
hidden: !ready,
onSelect: () => {
dialog.replace(() => <DialogConversationList chat={chat} />)
},
},
]
})
command.register(() => kiloCommands())
// Slashes for the in-chat autocomplete — derived from the same list so
// renames flow through automatically. We pad displays to a common width
// so the descriptions line up like in the main prompt's autocomplete.
const clawSlashes = createMemo<ClawSlashOption[]>(() => {
const visible = kiloCommands().filter((c) => c.enabled !== false && !c.hidden && c.slash)
const items = visible.map((c) => ({
display: "/" + c.slash!.name,
description: c.description ?? c.title,
aliases: c.slash!.aliases?.map((a) => "/" + a),
onSelect: () => c.onSelect?.(dialog),
}))
const max = items.reduce((m, i) => Math.max(m, i.display.length), 0)
if (!max) return items
return items.map((i) => ({ ...i, display: i.display.padEnd(max + 2) }))
})
return (
<box flexDirection="row" flexGrow={1} paddingLeft={2} gap={1}>
@@ -68,6 +129,9 @@ export function KiloClawView() {
loading={chat.loading()}
error={chat.error()}
disabled={disabled()}
typingMembers={chat.typingMembers()}
slashes={clawSlashes}
botName={botName()}
onSend={chat.send}
/>
<Toast />
@@ -80,6 +144,9 @@ export function KiloClawView() {
connected={chat.connected()}
chatLoading={chat.loading()}
chatError={chat.error()}
conversations={chat.conversations()}
activeConversationId={chat.activeConversationId()}
conversationStatus={chat.conversationStatus()}
/>
</box>
)
+4 -1
View File
@@ -227,7 +227,10 @@ export async function generateCommandTable(options?: { commands?: Cmd[] }) {
})
}
const lines = ["| Command | Description |", "| --- | --- |"]
// The repo enforces unpadded markdown table separators via
// script/check-md-table-padding.ts (see AGENTS.md). Padded `| --- | --- |`
// separators fail CI, so emit the compact form here.
const lines = ["| Command | Description |", "|---|---|"]
for (const row of rows) {
lines.push(`| \`${row.display}\` | ${row.description} |`)