mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat: kiloclaw chat rework
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} |`)
|
||||
|
||||
Reference in New Issue
Block a user