diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts index 807d0b6053..a57988c58a 100644 --- a/packages/kilo-console/src/client.ts +++ b/packages/kilo-console/src/client.ts @@ -9,13 +9,18 @@ import type { ConfigSourcesResponse, FormatterStatusResponse, GlobalHealthResponse, + GlobalEvent, LspStatusResponse, McpStatusResponse, Pty as PtyInfo, + PermissionRequest, Project as KiloProject, + QuestionRequest, ProviderAuthAuthorization, ProviderAuthResponse, ProviderListResponse, + Session as KiloSession, + SessionStatus, ToolIdsResponse, ToolListResponse, TuiConfigGetResponse, @@ -55,7 +60,11 @@ export type ProjectDiffItem = WorktreeDiffItem export type ProjectPtyInfo = PtyInfo export type ProjectTerminalItem = ProjectPtyInfo & { directory: string + session?: KiloSession + sessionStatus?: SessionStatus + attention?: "permission" | "question" } +export type ProjectConsoleEvent = GlobalEvent export type Snapshot = { health: GlobalHealthResponse @@ -339,8 +348,46 @@ export async function resetProjectWorktree(input: Query, dir: string) { export async function loadProjectTerminals(input: ProjectQuery, dir: string): Promise { const sdk = client({ url: input.url, dir }) - const result = await sdk.pty.list({ directory: dir }) - return demand("Terminals", result).map((item) => ({ ...item, directory: dir })) + const [result, statusResult, permissionResult, questionResult] = await Promise.all([ + sdk.pty.list({ directory: dir }), + maybe("Session status", sdk.session.status({ directory: dir })), + maybe("Pending permissions", sdk.permission.list({ directory: dir })), + maybe("Pending questions", sdk.question.list({ directory: dir })), + ]) + const rows = demand("Terminals", result) + const status = statusResult ?? {} + const permissions = pending(permissionResult ?? []) + const questions = pending(questionResult ?? []) + const sessions = new Map() + + await Promise.all( + rows.map(async (item) => { + if (!item.sessionID || sessions.has(item.sessionID)) return + const session = await maybe("Session", sdk.session.get({ directory: dir, sessionID: item.sessionID })) + if (session) sessions.set(item.sessionID, session) + }), + ) + + return rows.map((item) => { + const session = item.sessionID ? sessions.get(item.sessionID) : undefined + return { + ...item, + directory: dir, + session, + sessionStatus: item.sessionID ? status[item.sessionID] : undefined, + attention: item.sessionID ? attention(item.sessionID, permissions, questions) : undefined, + } + }) +} + +function pending(items: Array) { + return new Set(items.map((item) => item.sessionID)) +} + +function attention(id: string, permissions: Set, questions: Set) { + if (permissions.has(id)) return "permission" + if (questions.has(id)) return "question" + return undefined } export async function loadProjectDiff(input: Query, dir: string): Promise { @@ -361,6 +408,33 @@ export async function createProjectPty(input: Query, dir: string, title = "Kilo return demand("Create terminal", result) } +export async function removeProjectPty(input: Query, pty: string) { + const sdk = client({ url: input.url, dir: input.dir }) + const result = await sdk.pty.remove({ directory: input.dir, ptyID: pty }) + return demand("Remove terminal", result) +} + +export async function viewProjectSessions(input: ProjectQuery, focused: string[], open: string[]) { + const sdk = client(input) + const result = await sdk.session.viewed({ directory: input.dir, focused, open }) + return demand("Viewed sessions", result) +} + +export function subscribeProjectEvents(input: ProjectQuery, handler: (event: ProjectConsoleEvent) => void) { + const sdk = client(input) + const ctl = new AbortController() + void (async () => { + const events = await sdk.global.event({ signal: ctl.signal, sseMaxRetryAttempts: 0 }) + for await (const event of events.stream) { + if (ctl.signal.aborted) return + handler(event) + } + })().catch((err) => { + if (!ctl.signal.aborted) console.warn(`Project events: ${message(err)}`) + }) + return () => ctl.abort() +} + export async function resizeProjectPty(input: Query, pty: string, cols: number, rows: number) { const sdk = client({ url: input.url, dir: input.dir }) const result = await sdk.pty.update({ directory: input.dir, ptyID: pty, size: { cols, rows } }) diff --git a/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx b/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx index cabff78480..7edb748a69 100644 --- a/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx +++ b/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx @@ -1,6 +1,7 @@ import { A, useLocation, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js" import { Card } from "@kilocode/kilo-web-ui/card" +import { Icon } from "@kilocode/kilo-web-ui/icon" import { createProjectPty, createProjectWorktree, @@ -11,9 +12,13 @@ import { loadProjectConsole, loadProjectDiff, loadProjectDiffFile, + removeProjectPty, removeProjectWorktree, resetProjectWorktree, saveCached, + subscribeProjectEvents, + viewProjectSessions, + type ProjectConsoleEvent, type ProjectConsoleQuery, type ProjectTerminalItem, type Query, @@ -53,6 +58,51 @@ function title(input: string) { return friendly(repo(input)) } +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null +} + +function nested(input: unknown, key: string) { + if (!record(input)) return undefined + const value = input[key] + if (!record(value)) return undefined + return value +} + +function eventSession(event: ProjectConsoleEvent) { + const payload = event.payload + const props: unknown = "properties" in payload ? payload.properties : "data" in payload ? payload.data : undefined + if (!record(props)) return undefined + const id = props["sessionID"] + if (typeof id === "string") return id + const info = nested(props, "info") + if (typeof info?.["sessionID"] === "string") return info["sessionID"] + if (typeof info?.["id"] === "string") return info["id"] + const part = nested(props, "part") + if (typeof part?.["sessionID"] === "string") return part["sessionID"] + return undefined +} + +function eventType(event: ProjectConsoleEvent) { + const payload = event.payload + if (payload.type !== "sync") return payload.type + return payload.name +} + +function messageEvent(event: ProjectConsoleEvent) { + return eventType(event).startsWith("message.") +} + +function refreshEvent(event: ProjectConsoleEvent) { + const type = eventType(event) + if (type.startsWith("pty.")) return true + if (type.startsWith("session.")) return true + if (type.startsWith("permission.")) return true + if (type.startsWith("question.")) return true + if (type.startsWith("message.")) return true + return false +} + export function ProjectConsoleRoute() { const loc = useLocation() const params = useParams() @@ -65,6 +115,9 @@ export function ProjectConsoleRoute() { const [file, setFile] = createSignal() const [saving, setSaving] = createSignal() const [failure, setFailure] = createSignal() + const [unread, setUnread] = createSignal(new Set()) + const [labelRev, setLabelRev] = createSignal(0) + const events = { timer: undefined as number | undefined } const project = () => params.project ?? "" const query = createMemo(() => { const target = clean(url()) || fallback() @@ -83,10 +136,10 @@ export function ProjectConsoleRoute() { }) const terminals = createMemo(() => { const items = new Map() - for (const item of snap()?.terminals ?? []) { + for (const item of local()) { if (item.status === "running") items.set(item.id, item) } - for (const item of local()) { + for (const item of snap()?.terminals ?? []) { if (item.status === "running") items.set(item.id, item) } return Array.from(items.values()) @@ -139,6 +192,74 @@ export function ProjectConsoleRoute() { return `/projects/${encodeURIComponent(project())}/settings${q ? `?${q}` : ""}` }) + function projectInput(): Query | undefined { + const base = query() + const data = snap() + if (!base || !data) return undefined + return { url: base.url, dir: data.project.worktree, scope: "project" } + } + + function labelKey(dir: string) { + return `kilo.console.${project()}.worktree.${encodeURIComponent(dir)}.label` + } + + function displayLabel(item: Context) { + labelRev() + return window.localStorage.getItem(labelKey(item.dir))?.trim() || item.label + } + + function currentLabel() { + const item = current() + if (!item) return "Project" + return displayLabel(item) + } + + function sessionID(item: ProjectTerminalItem | undefined) { + return item?.sessionID ?? item?.session?.id + } + + function activeSessionID() { + return sessionID(activeTerminal()) + } + + function terminalName(item: ProjectTerminalItem) { + return item.session?.title || item.title || `Terminal ${item.id.slice(-4)}` + } + + function terminalState(item: ProjectTerminalItem) { + if (item.attention) return "attention" + if (item.sessionStatus && item.sessionStatus.type !== "idle") return "busy" + const id = sessionID(item) + if (id && unread().has(id)) return "unread" + return "idle" + } + + function clearUnread(item: ProjectTerminalItem) { + const id = sessionID(item) + if (!id || !unread().has(id)) return + setUnread((old) => { + const next = new Set(old) + next.delete(id) + return next + }) + } + + function markUnread(id: string) { + if (id === activeSessionID()) return + setUnread((old) => { + if (old.has(id)) return old + return new Set([...old, id]) + }) + } + + function scheduleRefetch() { + if (events.timer) return + events.timer = window.setTimeout(() => { + events.timer = undefined + void refetch() + }, 150) + } + function terminalsFor(dir: string) { return grouped().get(dir) ?? [] } @@ -164,6 +285,7 @@ export function ProjectConsoleRoute() { setSelected(item.directory) setActive(item.id) setFile(undefined) + clearUnread(item) remember(item.directory, item.id) } @@ -177,21 +299,21 @@ export function ProjectConsoleRoute() { } function addWorktree() { - const input = target() + const input = projectInput() const data = snap() if (!input || !data) return const name = window.prompt("Worktree name") ?? undefined run("Creating worktree", async () => { - const next = await createProjectWorktree({ ...input, dir: data.project.worktree }, name) + const next = await createProjectWorktree(input, name) setSelected(next.directory) window.localStorage.setItem(`kilo.console.${project()}.dir`, next.directory) }) } - function addSession() { - const input = target() - const item = current() - if (!input || !item) return + function addSession(item = current()) { + const base = query() + if (!base || !item) return + const input = { url: base.url, dir: item.dir, scope: "project" as const } const label = `Kilo ${terminalsFor(item.dir).length + 1}` setSaving("Creating session") setFailure(undefined) @@ -208,34 +330,65 @@ export function ProjectConsoleRoute() { .finally(() => setSaving(undefined)) } - function dropTerminal(id: string) { + function forgetTerminal(id: string) { setLocal((rows) => rows.filter((row) => row.id !== id)) if (active() === id) { setActive("") window.localStorage.removeItem(`kilo.console.${project()}.pty`) } + } + + function dropTerminal(id: string) { + forgetTerminal(id) void refetch() } - function removeSelected() { - const input = target() - const item = current() - const data = snap() - if (!input || !item || !data || item.kind === "local") return - if (!window.confirm(`Remove worktree ${item.label}?`)) return - run("Removing worktree", async () => { - await removeProjectWorktree({ ...input, dir: data.project.worktree }, item.dir) - setSelected(data.project.worktree) + function closeTerminal(item: ProjectTerminalItem) { + const input = { url: query()?.url ?? "", dir: item.directory, scope: "project" as const } + if (!input.url) return + run("Closing terminal", async () => { + await removeProjectPty(input, item.id) + forgetTerminal(item.id) }) } - function resetSelected() { - const input = target() + function renameWorktree(item: Context) { + if (item.kind === "local") return + const input = window.prompt("Worktree label", displayLabel(item)) + if (input === null) return + const next = input.trim() + if (next) window.localStorage.setItem(labelKey(item.dir), next) + else window.localStorage.removeItem(labelKey(item.dir)) + setLabelRev((value) => value + 1) + } + + function removeWorktree(item: Context) { + const input = projectInput() + if (!input || item.kind === "local") return + if (!window.confirm(`Remove worktree ${displayLabel(item)}?`)) return + run("Removing worktree", async () => { + await removeProjectWorktree(input, item.dir) + window.localStorage.removeItem(labelKey(item.dir)) + setLabelRev((value) => value + 1) + if (selected() === item.dir) { + setSelected(input.dir) + remember(input.dir) + } + }) + } + + function removeSelected() { const item = current() - const data = snap() - if (!input || !item || !data || item.kind === "local") return - if (!window.confirm(`Reset worktree ${item.label}?`)) return - run("Resetting worktree", async () => resetProjectWorktree({ ...input, dir: data.project.worktree }, item.dir)) + if (!item) return + removeWorktree(item) + } + + function resetSelected() { + const input = projectInput() + const item = current() + if (!input || !item || item.kind === "local") return + if (!window.confirm(`Reset worktree ${displayLabel(item)}?`)) return + run("Resetting worktree", async () => resetProjectWorktree(input, item.dir)) } createEffect(() => { @@ -287,6 +440,41 @@ export function ProjectConsoleRoute() { remember(item.dir, pty?.id) }) + createEffect(() => { + const item = activeTerminal() + if (item) clearUnread(item) + }) + + createEffect(() => { + const base = query() + const data = snap() + if (!base || !data) return + const focused = activeSessionID() + const open = terminals().flatMap((item) => { + const id = sessionID(item) + return id ? [id] : [] + }) + void viewProjectSessions({ url: base.url, dir: data.project.worktree }, focused ? [focused] : [], open).catch(() => {}) + }) + + createEffect(() => { + const base = query() + const data = snap() + if (!base || !data) return + const dirs = new Set([data.project.worktree, ...data.worktrees]) + const stop = subscribeProjectEvents({ url: base.url, dir: data.project.worktree }, (event) => { + if (event.directory !== "global" && !dirs.has(event.directory)) return + const id = eventSession(event) + if (id && messageEvent(event)) markUnread(id) + if (refreshEvent(event)) scheduleRefetch() + }) + onCleanup(stop) + }) + + onCleanup(() => { + if (events.timer) window.clearTimeout(events.timer) + }) + createEffect(() => { if (!snap.error || !discoverable(search())) return const cached = loadCached() @@ -315,45 +503,116 @@ export function ProjectConsoleRoute() {
-
Actions
-
- -
-
-
-
Worktrees