Merge pull request #234 from Kilo-Org/mark/phase3

feat: Markdown rendering, BasicTool for tools, Collapsible for reasoning (Phase 3)
This commit is contained in:
Mark IJbema
2026-02-11 14:01:14 +01:00
committed by GitHub
6 changed files with 117 additions and 215 deletions
@@ -794,7 +794,7 @@ Each phase has specific acceptance criteria (listed above). The general verifica
| Phase 1.4: Cards | ✅ Complete | 2026-02-11 | Inline-styled card divs in ProfileView (profile + balance) and DeviceAuthCard (5 state cards) replaced with `Card` component. Removed `cardStyle` constant. |
| Phase 1.5: Icons | ✅ Complete | 2026-02-11 | All 15 lucide-solid icons replaced with kilo-ui `Icon`. `lucide-solid` dep removed. Many icons use approximate matches (see deviation #7). |
| Phase 2: Settings UI | ✅ Complete | 2026-02-11 | Settings shell uses kilo-ui `Tabs` with vertical orientation and `variant="settings"`. ~80 lines of inline styles removed. Tab content (stubs) unchanged. Back button in header kept with minimal CSS. |
| Phase 3: Chat UI | ⬚ Not started | — | |
| Phase 3: Chat UI | ✅ Complete | 2026-02-11 | MarkedProvider added. Assistant text renders as Markdown. Tool calls use BasicTool. Reasoning uses Collapsible. Removed ~150 lines of tool/reasoning CSS. Code/Diff providers deferred (not needed yet). |
| Phase 4: Advanced | ⬚ Not started | — | |
### Deviations from plan
+30 -1
View File
@@ -1,9 +1,38 @@
const esbuild = require("esbuild")
const path = require("path")
const { solidPlugin } = require("esbuild-plugin-solid")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
/**
* Force all solid-js imports (from kilo-ui and the webview) to resolve to
* the **same** copy so SolidJS contexts are shared across packages.
* Without this, the monorepo hoists separate copies (pnpm vs bun) and
* createContext / useContext can't see each other.
*
* @type {import('esbuild').Plugin}
*/
const solidDedupePlugin = {
name: "solid-dedupe",
setup(build) {
// Resolve these bare specifiers to the kilo-vscode-local copy
const solidRoot = path.dirname(require.resolve("solid-js/package.json"))
const aliases = {
"solid-js": path.join(solidRoot, "dist", "solid.js"),
"solid-js/web": path.join(solidRoot, "web", "dist", "web.js"),
"solid-js/store": path.join(solidRoot, "store", "dist", "store.js"),
}
build.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => {
const key = args.path
if (aliases[key]) {
return { path: aliases[key] }
}
})
},
}
/**
* @type {import('esbuild').Plugin}
*/
@@ -72,7 +101,7 @@ async function main() {
".woff2": "file",
".ttf": "file",
},
plugins: [cssPackageResolvePlugin, solidPlugin(), esbuildProblemMatcherPlugin],
plugins: [solidDedupePlugin, cssPackageResolvePlugin, solidPlugin(), esbuildProblemMatcherPlugin],
})
if (watch) {
+1 -1
View File
@@ -829,7 +829,7 @@ export class KiloProvider implements vscode.WebviewViewProvider {
const csp = [
"default-src 'none'",
`style-src 'unsafe-inline' ${webview.cspSource}`,
`script-src 'nonce-${nonce}'`,
`script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`,
`font-src ${webview.cspSource}`,
"connect-src http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*",
`img-src ${webview.cspSource} data: https:`,
+12 -9
View File
@@ -2,6 +2,7 @@ import { Component, createSignal, Switch, Match, onMount, onCleanup } from "soli
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { I18nProvider } from "@kilocode/kilo-ui/context"
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import Settings from "./components/Settings"
import ProfileView from "./components/ProfileView"
import { VSCodeProvider } from "./context/vscode"
@@ -116,15 +117,17 @@ const App: Component = () => {
<ThemeProvider defaultTheme="kilo-vscode">
<I18nProvider value={{ locale: () => "en", t: (key) => key }}>
<DialogProvider>
<VSCodeProvider>
<ServerProvider>
<ProviderProvider>
<SessionProvider>
<AppContent />
</SessionProvider>
</ProviderProvider>
</ServerProvider>
</VSCodeProvider>
<MarkedProvider>
<VSCodeProvider>
<ServerProvider>
<ProviderProvider>
<SessionProvider>
<AppContent />
</SessionProvider>
</ProviderProvider>
</ServerProvider>
</VSCodeProvider>
</MarkedProvider>
</DialogProvider>
</I18nProvider>
</ThemeProvider>
@@ -1,10 +1,19 @@
/**
* Message component
* Displays a single chat message with its parts (text, tool calls, reasoning)
*
* Uses kilo-ui components:
* - Markdown for rendering assistant text (headings, lists, code blocks, links)
* - BasicTool for tool call display (collapsible with icon, title, subtitle)
* - Collapsible for reasoning blocks
* - Spinner for pending/running tool status
*/
import { Component, For, Show, createSignal, JSX } from "solid-js"
import { Component, For, Show, JSX } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Collapsible } from "@kilocode/kilo-ui/collapsible"
import { useSession } from "../../context/session"
import type { Message as MessageType, Part, TextPart, ToolPart, ReasoningPart } from "../../types/messages"
@@ -12,89 +21,97 @@ interface MessageProps {
message: MessageType
}
// Text part display
const TextPartView: Component<{ part: TextPart }> = (props) => {
return <div class="message-text">{props.part.text}</div>
}
// Reasoning part display
const ReasoningPartView: Component<{ part: ReasoningPart }> = (props) => {
const [expanded, setExpanded] = createSignal(false)
// Text part display — renders markdown for assistant, plain text for user
const TextPartView: Component<{ part: TextPart; role: string }> = (props) => {
return (
<div class="message-reasoning">
<button class="reasoning-header" onClick={() => setExpanded(!expanded())} aria-expanded={expanded()}>
<span class="reasoning-icon">💭</span>
<span class="reasoning-label">Thinking...</span>
<span class="reasoning-toggle">{expanded() ? "▼" : "▶"}</span>
</button>
<Show when={expanded()}>
<div class="reasoning-content">{props.part.text}</div>
</Show>
</div>
<Show when={props.role === "assistant"} fallback={<div class="message-text">{props.part.text}</div>}>
<Markdown text={props.part.text} />
</Show>
)
}
// Tool part display
const ToolPartView: Component<{ part: ToolPart }> = (props) => {
const [expanded, setExpanded] = createSignal(false)
// Reasoning part display — collapsible "Thinking..." block
const ReasoningPartView: Component<{ part: ReasoningPart }> = (props) => {
return (
<Collapsible variant="ghost">
<Collapsible.Trigger>
<span>💭 Thinking...</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div class="message-text" style={{ "font-style": "italic", opacity: 0.8 }}>
{props.part.text}
</div>
</Collapsible.Content>
</Collapsible>
)
}
const getStatusIcon = (): JSX.Element | string => {
// Tool part display — uses BasicTool for collapsible tool header
const ToolPartView: Component<{ part: ToolPart }> = (props) => {
const getStatusIcon = (): string => {
switch (props.part.state.status) {
case "pending":
case "running":
return <Spinner style={{ width: "14px", height: "14px" }} />
return "settings-gear"
case "completed":
return "✓"
return "check-small"
case "error":
return "✕"
return "circle-x"
}
}
const getStatusClass = () => {
return `tool-status-${props.part.state.status}`
}
const getTitle = () => {
if ("title" in props.part.state && props.part.state.title) {
return props.part.state.title
return props.part.state.title as string
}
return props.part.tool
}
const isActive = () => props.part.state.status === "pending" || props.part.state.status === "running"
return (
<div class={`message-tool ${getStatusClass()}`}>
<button class="tool-header" onClick={() => setExpanded(!expanded())} aria-expanded={expanded()}>
<span class="tool-icon">{getStatusIcon()}</span>
<span class="tool-name">{getTitle()}</span>
<span class="tool-toggle">{expanded() ? "▼" : "▶"}</span>
</button>
<Show when={expanded()}>
<div class="tool-details">
<div class="tool-input">
<div data-component="tool-part-wrapper">
<BasicTool
icon={getStatusIcon()}
defaultOpen={false}
trigger={{
title: getTitle(),
subtitle: props.part.tool !== getTitle() ? props.part.tool : undefined,
action: isActive() ? ((<Spinner style={{ width: "14px", height: "14px" }} />) as JSX.Element) : undefined,
}}
>
<div style={{ padding: "8px", "font-size": "12px" }}>
<div>
<strong>Input:</strong>
<pre>{JSON.stringify(props.part.state.input, null, 2)}</pre>
<pre style={{ margin: "4px 0 8px", "white-space": "pre-wrap", "word-break": "break-word" }}>
{JSON.stringify(props.part.state.input, null, 2)}
</pre>
</div>
<Show when={props.part.state.status === "completed" && "output" in props.part.state}>
<div class="tool-output">
<div>
<strong>Output:</strong>
<pre>{(props.part.state as { output: string }).output}</pre>
<pre style={{ margin: "4px 0 0", "white-space": "pre-wrap", "word-break": "break-word" }}>
{(props.part.state as { output: string }).output}
</pre>
</div>
</Show>
<Show when={props.part.state.status === "error" && "error" in props.part.state}>
<div class="tool-error">
<div style={{ color: "var(--vscode-errorForeground)" }}>
<strong>Error:</strong>
<pre>{(props.part.state as { error: string }).error}</pre>
<pre style={{ margin: "4px 0 0", "white-space": "pre-wrap", "word-break": "break-word" }}>
{(props.part.state as { error: string }).error}
</pre>
</div>
</Show>
</div>
</Show>
</BasicTool>
</div>
)
}
// Part renderer
const PartView: Component<{ part: Part }> = (props) => {
const PartView: Component<{ part: Part; role: string }> = (props) => {
// Skip step-start and step-finish parts - they're metadata, not displayable content
if (props.part.type === "step-start" || props.part.type === "step-finish") {
return null
@@ -103,7 +120,7 @@ const PartView: Component<{ part: Part }> = (props) => {
return (
<>
<Show when={props.part.type === "text"}>
<TextPartView part={props.part as TextPart} />
<TextPartView part={props.part as TextPart} role={props.role} />
</Show>
<Show when={props.part.type === "tool"}>
<ToolPartView part={props.part as ToolPart} />
@@ -134,11 +151,16 @@ export const Message: Component<MessageProps> = (props) => {
when={hasParts()}
fallback={
<Show when={props.message.content}>
<div class="message-text">{props.message.content}</div>
<Show
when={props.message.role === "assistant"}
fallback={<div class="message-text">{props.message.content}</div>}
>
<Markdown text={props.message.content!} />
</Show>
</Show>
}
>
<For each={parts()}>{(part) => <PartView part={part} />}</For>
<For each={parts()}>{(part) => <PartView part={part} role={props.message.role} />}</For>
</Show>
</div>
</div>
@@ -130,158 +130,6 @@
line-height: 1.5;
}
/* ============================================
Tool Parts
============================================ */
.message-tool {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: hidden;
}
.tool-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: var(--vscode-sideBar-background);
border: none;
cursor: pointer;
text-align: left;
color: var(--vscode-foreground);
font-size: 12px;
}
.tool-header:hover {
background: var(--vscode-list-hoverBackground);
}
.tool-icon {
font-size: 12px;
}
.tool-name {
flex: 1;
font-family: var(--vscode-editor-font-family);
}
.tool-toggle {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.tool-details {
padding: 8px 10px;
background: var(--vscode-editor-background);
font-size: 11px;
}
.tool-input,
.tool-output,
.tool-error {
margin-bottom: 8px;
}
.tool-input:last-child,
.tool-output:last-child,
.tool-error:last-child {
margin-bottom: 0;
}
.tool-details pre {
margin: 4px 0 0 0;
font-family: var(--vscode-editor-font-family);
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
/* Tool status styles */
.tool-status-pending .tool-icon {
color: var(--vscode-charts-yellow);
}
.tool-status-running .tool-icon {
color: var(--vscode-charts-blue);
animation: spin 1s linear infinite;
}
.tool-status-completed .tool-icon {
color: var(--vscode-charts-green);
}
.tool-status-error .tool-icon {
color: var(--vscode-errorForeground);
}
.tool-error {
color: var(--vscode-errorForeground);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ============================================
Reasoning Parts
============================================ */
.message-reasoning {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: hidden;
opacity: 0.8;
}
.reasoning-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: var(--vscode-sideBar-background);
border: none;
cursor: pointer;
text-align: left;
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-style: italic;
}
.reasoning-header:hover {
background: var(--vscode-list-hoverBackground);
}
.reasoning-icon {
font-size: 12px;
}
.reasoning-label {
flex: 1;
}
.reasoning-toggle {
font-size: 10px;
}
.reasoning-content {
padding: 8px 10px;
background: var(--vscode-editor-background);
font-size: 12px;
font-style: italic;
color: var(--vscode-descriptionForeground);
white-space: pre-wrap;
word-break: break-word;
}
/* ============================================
Prompt Input
============================================ */