// ChatArea — message list with proper role styling + tool log + think time.
import { createSignal, createMemo, onMount, onCleanup, Show } from "solid-js";
import { palette } from "../theme.ts";
import {
showThinking, userExpandedIdx, setUserExpandedIdx, sidebarVisible,
thoughtExpanded, setThoughtExpanded, thoughtClosed, setThoughtClosed,
thinking,
} from "../store.ts";
import { toolDisplay } from "../tool-display.ts";
import { formatDiffCount } from "../../core/file-diffs.js";
import { MdText } from "./MdText.tsx";
import os from "os";
const ui = palette("loom");
function uname() { return os.userInfo().username || "you"; }
function fmtMs(ms) { if (!ms) return ""; return ms < 1000 ? ms + "ms" : (ms / 1000).toFixed(1) + "s"; }
// Rotating square-of-dots thinking animation — small, quiet, distracting-free.
// Renders as the reasoning part's header while the model streams reasoning,
// opencode-style: "⠋ Thinking", and the body markdown streams live below it.
const THINK_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
function ThinkingLabel() {
const [tick, setTick] = createSignal(0);
let t: any;
onMount(() => { t = setInterval(() => setTick(i => i + 1), 120); });
onCleanup(() => t && clearInterval(t));
return (
{THINK_FRAMES[tick() % THINK_FRAMES.length] + " Thinking"}
);
}
// Running tool rows that opencode spins (read/task/execute) use the braille
// spinner; everything else shows the quiet "~ pending" text instead — a long
// command never looks frozen on screen.
function ToolSpinner(props: { text: string; fg?: string }) {
const [tick, setTick] = createSignal(0);
let t: any;
onMount(() => { t = setInterval(() => setTick(i => i + 1), 120); });
onCleanup(() => t && clearInterval(t));
return {THINK_FRAMES[tick() % THINK_FRAMES.length] + " " + props.text};
}
// Rough visual line count (logical lines + width wrapping) — drives the
// "~N lines" badge on big pasted user messages.
export const PER_LINE = 88;
export function estVisualLines(text) {
if (!text) return 1;
let n = 0;
for (const ln of String(text).split("\n")) n += Math.max(1, Math.ceil((ln.length + 1) / PER_LINE));
return n;
}
// Long pasted messages are collapsed to a preview so the chat keeps breathing
// room; the "~N lines" badge shows the real size. Click (or Ctrl+E on the most
// recent one) to expand/collapse. The FULL content is always kept — expansion
// shows everything, only the collapsed preview is truncated.
export const USER_PREVIEW_LINES = 10;
const USER_PREVIEW_CHARS = 2000;
function UserBubble(props) {
const m = props.m;
const idx = props.idx;
const content = String(m.content || "");
const lines = estVisualLines(content);
const big = lines > USER_PREVIEW_LINES;
const expanded = () => userExpandedIdx() === idx;
const toggle = () => setUserExpandedIdx(expanded() ? null : idx);
const shown = () => {
if (!big || expanded()) return content;
return content.split("\n").slice(0, USER_PREVIEW_LINES).join("\n").slice(0, USER_PREVIEW_CHARS);
};
return (
{"\u25E4 " + uname()}
1}>
{" ~" + lines + " lines"}
{m.time || ""}
{ if (e.name === "return" || e.name === "space") toggle(); } : undefined}
>
{shown()}
{" \u2026 (" + lines + " lines \u2014 click to " + (expanded() ? "collapse" : "expand") + ", or Ctrl+E)"}
);
}
// opencode's BlockTool: a flat panel with a subtle LEFT border (drawn in the
// main background color so it reads as a faint divider, not a frame) on the
// panel background. Blocks always separate from the rows around them.
function Block(props: { children: any }) {
return (
{props.children}
);
}
// opencode's generic/bash output block: "$ {command}" (bash) or
// "# {tool} {args}" (ANY tool — MCP, custom — through the generic fallback)
// with the tool's result beneath, collapsed to a few lines with a
// click-to-expand hint. The agent's actual work product shows up in the chat
// no matter which tool produced it.
function OutputBlock(props: { title: string; body: string; maxLines: number; mt?: number; live?: boolean }) {
const [expanded, setExpanded] = createSignal(false);
const lines = () => String(props.body || "").split("\n");
const overflow = () => lines().length > props.maxLines;
const toggle = () => setExpanded((v) => !v);
return (
{ if (e.name === "return" || e.name === "space") toggle(); } : undefined}
>
{props.title}
{"\u25CF streaming"}
{lines().slice(0, expanded() ? undefined : props.maxLines).map((l: string) => (
{l || " "}
))}
{props.live
? "\u2026 streaming \u2014 " + (expanded() ? "click to collapse" : "click to expand")
: "\u2026 " + (expanded() ? "Click to collapse" : "Click to expand")}
);
}
// Inline file-change block, opencode-style: a finished write/edit/bash shows
// its diff right where the tool ran ("← Edit src/b.ts" + +/- hunks), so the
// patch only appears when the agent actually changed a file — then the message
// continues normally. A brand-new file renders as "# Wrote src/new.ts" with
// its fresh content marked "+".
function DiffBlock(props: { d: any; toolName?: string }) {
const d = props.d;
if (!d) return null;
function colWidth() {
const term = Math.max(40, (process.stdout?.columns || 100) | 0);
const sidebarW = sidebarVisible() ? 40 : 0; // 38 sidebar + 1 margin + 1 gap
return Math.max(20, term - sidebarW - 12);
}
const W = colWidth();
// Long lines truncate with an ellipsis instead of spilling past the width.
const clip = (t: string) => (t.length > W ? t.slice(0, W - 1) + "…" : t);
const prefix = (k: string) => (k === "del" ? "-" : k === "add" ? "+" : " ");
// opencode's block titles: "← Edit src/b.ts" for edits, "# Wrote ..." for
// writes (and any brand-new file), and the bare arrow for bash-changed files.
const verb = props.toolName === "edit" && !d.isNew ? "\u2190 Edit " : (props.toolName === "write" || d.isNew) ? "# Wrote " : "\u2190 ";
return (
{verb + d.path}
{" " + formatDiffCount(d)}
{(d.lines || []).map((ln: any) => (
{prefix(ln.kind) + clip(String(ln.text || "").replace(/\n$/, ""))}
))}
);
}
// Todos block, opencode TodoWrite-style: "# Todos" with [✓]/[•]/[ ] marks.
// The in-progress row uses the warning color, like opencode's TodoItem.
function TodosBlock(props: { todos: any[] }) {
const todoMark = (t: any) => "[" + (t.done ? "\u2713" : t.inProgress ? "\u2022" : " ") + "] ";
return (
# Todos
{props.todos.map((t: any) => (
{todoMark(t) + String(t.text || "")}
))}
);
}
// One tool call = its own part, opencode-style. While running, most tools show
// the quiet "~ Preparing edit..." pending text (deeper indent, no spinner, no
// accent — opencode only spins read/task/execute rows). Once done the row
// flips to a muted "→ Read src/a.ts" / "← Edit src/b.ts" line that STAYS in
// the transcript — each command gets its own entry, never one merged patch.
// A finished write/edit/bash that actually changed a file REPLACES its row
// with the diff block (opencode's Edit renders only its BlockTool once the
// diff exists) — the patch shows right where the edit happened, then the
// message continues normally. Errors render in the error color. Completed
// rows vanish when "tool details" are off (only running rows and errors
// stay) — App filters done tool parts out of messages() reactively. Every
// decision below comes from the tool's DISPLAY DATA (tool-display.ts), never
// a tool name in the renderer — anything unregistered renders via the
// GenericTool fallback.
function ToolPart(props: { t: any; todos?: any[]; blockBefore: boolean }) {
const t = props.t;
const d = toolDisplay(t.name);
const diffs = t.fileDiffs || [];
const mt = props.blockBefore ? 1 : 0;
// todowrite collapses into the "# Todos" block once done (opencode
// TodoWrite swaps the inline row for the block), but its pending row
// still shows.
if (d.todos && t.status !== "running" && (props.todos?.length)) {
return ;
}
// Finished write/edit/bash WITH a diff: only the patch renders — opencode
// swaps the inline row for its BlockTool once the diff is available.
if (d.diff && t.status !== "running" && diffs.length) {
return (
{diffs.map((d2: any) => )}
);
}
// Finished tools with output that define a block swap the row for it — the
// "$ command" result (bash, opencode's Shell) or the generic "# {tool} {args}"
// preview (ANY other tool with output).
if (t.status === "done" && t.output && d.block) {
return ;
}
if (t.status === "running") {
// Bash (live tools) with streamed output swaps the quiet pending row for
// a growing, collapsible terminal block — the command's output appears
// as it runs, then the finished block takes over with the full result.
if (d.live && t.liveOutput) {
return (
);
}
// read/task/execute spin their label (opencode passes spinner=true for
// exactly these); everything else gets the quiet "~ pending" text.
if (d.spinner) {
return (
);
}
return (
{"~ " + (t.pending || "Working...")}
);
}
const icon =
t.status === "error" && d.check ? "\u2717"
: t.status === "done" && d.check ? "\u2713"
: (t.icon || d.icon);
const fg = t.status === "error" ? ui.error : ui.fgMuted;
return (
{icon + " " + (t.label || d.label({}) || t.name)}
);
}
// opencode streams each part WHERE it arrived: reasoning → tool → reasoning →
// tool → text, in exact chronological order, so the chat reads as a flow —
// "thinks, reads, thinks again, edits, replies" — never a fixed block layout.
// Single-line parts (settled "+ Thought" rows, running/done tool rows) pack
// tightly; anything multi-line (streaming/open thinking, patch blocks, text)
// separates with a gap.
function PartList(props: { m: any; idx: number }) {
const m = props.m;
const parts = () => (m.parts && m.parts.length ? m.parts : deriveParts(m));
// The part streaming right now is the LAST reasoning part while the turn is
// live; earlier reasoning parts have already settled (opencode's minimal
// mode collapses each one the moment the model moves on to tools/text).
// Memoized: streamed reasoning parts arrive asynchronously, so the index
// must recompute from parts() (not a one-time const) for the ThinkingLabel
// to follow the stream.
const lastReasonIdx = createMemo(() => {
let last = -1;
const ps = parts();
for (let k = 0; k < ps.length; k++) if (ps[k].type === "reasoning") last = k;
return last;
});
let blockPrev = false;
return (
<>
{parts().map((p: any, i: number) => {
if (p.type === "reasoning") {
if (!showThinking()) return null;
const live = () => !!m.thinking && thinking() && i === lastReasonIdx();
const open = () => live() ? !thoughtClosed().has(props.idx) : (thoughtExpanded().get(props.idx)?.has(i) ?? false);
blockPrev = open();
return ;
}
if (p.type === "tool") {
const t = p.tool;
const d = toolDisplay(t.name);
const isBlock = !!(
(d.todos && t.status !== "running" && (m.todos?.length)) ||
(d.diff && t.status !== "running" && (t.fileDiffs || []).length > 0) ||
(t.status === "done" && t.output && d.block) ||
(t.status === "running" && t.liveOutput)
);
const mt = blockPrev;
blockPrev = isBlock;
return ;
}
blockPrev = true;
return (
{String(p.text || "").slice(0, 30000)}}
>
);
})}
>
);
}
// Fallback for restored sessions / test fixtures in the old shape: derive the
// parts list from the legacy fields (thinking on top, then tools, then text).
function deriveParts(m: any): any[] {
const out: any[] = [];
if (m.thinkingContent || m.thinking) out.push({ type: "reasoning", text: String(m.thinkingContent || ""), thinkMs: m.thinkTime || 0 });
for (const t of (m.tools || [])) out.push({ type: "tool", tool: t });
if (m.content) out.push({ type: "text", text: String(m.content) });
return out;
}
// One reasoning part = its own block, opencode-style. While the turn runs and
// it is the latest reasoning it streams as "⠋ Thinking" with the body LIVE
// below (click collapses it mid-turn); once the model moves on (a tool or text
// part arrives, or the turn ends) it settles to a clickable "+ Thought · Ns"
// line — opencode's minimal mode. Clicking toggles the body in either state.
function ThoughtPart(props: { p: any; i: number; idx: number; live: () => boolean; open: () => boolean }) {
const toggle = () => {
if (props.live()) {
const closed = new Set(thoughtClosed());
if (closed.has(props.idx)) closed.delete(props.idx); else closed.add(props.idx);
setThoughtClosed(closed);
} else {
const map = new Map(thoughtExpanded());
const set = new Set(map.get(props.idx) || []);
if (set.has(props.i)) set.delete(props.i); else set.add(props.i);
if (set.size) map.set(props.idx, set); else map.delete(props.idx);
setThoughtExpanded(map);
}
};
const ms = () => (props.p.thinkMs && props.p.thinkMs >= 1000 ? fmtMs(props.p.thinkMs) : "");
return (
toggle()}
onKeyDown={(e: any) => { if (e.name === "return" || e.name === "space") toggle(); }}
>
{props.live() ? : (props.open() ? "- " : "+ ") + "Thought" + (ms() ? " \u00B7 " + ms() : "")}
);
}
// Subagent delegation (task tool), opencode-style: one InlineTool row —
// "│ @explore Task" spinning while the child runs, flipping to a muted
// "✓ @explore Task" once done — with the "↳ tool log" and streamed body
// lines beneath, and a "↳ finished · " line at the end.
function SubagentPanel(props: { s: any }) {
const s = props.s;
const done = !!s.done;
const agent = String(s.agent || "");
return (
{done ? (
{"\u2713 @" + agent + " Task"}
) : (
)}
{s.log ? {"\u21B3 " + s.log} : null}
{s.text ? {String(s.text)} : null}
{done ? {"\u21B3 finished \u00B7 " + (s.status || "")} : null}
);
}
// Assistant message, opencode-style: every part streams as its OWN flat block
// at the same left padding (paddingLeft 3) in exact arrival order — reasoning
// ("⠋ Thinking" streaming live, settling to a clickable "+ Thought" once the
// model moves on), tool rows (one per call, "~ Preparing edit..." while
// running, the patch block right where the edit happened when done), and the
// markdown text wherever the model actually said it — so a turn reads like
// "thinks, reads, thinks again, edits, replies" instead of a fixed layout.
// NO bubble; the answer renders as the last text part. After the parts come
// the subagent panel (task tool), leftover diff blocks, "# Todos" block and
// the footer "▣ Loom · 4.2s" once the turn finishes.
function MsgBubble(props: { m: any; idx: number }) {
const m = props.m;
if (m.role === "system") {
return (
{m.content}
);
}
if (m.role === "user") {
return ;
}
if (m.role === "tool") {
const first = String(m.content || "").split("\n")[0].slice(0, 140);
return (
{"\u2192 " + first}
);
}
const isErr = m.isError;
const isThink = m.thinking;
const isStop = m.interrupted;
const agentTag = m.agentLabel ? "@" + m.agentLabel : null;
const parts = () => (m.parts && m.parts.length ? m.parts : deriveParts(m));
const tools = () => parts().filter((p: any) => p.type === "tool").map((p: any) => p.tool)
.filter((t: any) => !(toolDisplay(t.name).subagent && m.subagent));
// Diffs already attached to a tool part render inline in that part; anything
// left over (restored sessions, tests) renders after the parts. Dedupe by
// stable abs path (the same key App uses to merge diffs), not object
// identity — copies of the same diff would render twice.
const partPaths = new Set();
for (const t of tools()) {
for (const d of (t.fileDiffs || [])) if (d && d.abs) partPaths.add(String(d.abs));
}
const diffless = (m.fileDiffs || []).filter((d: any) => !d || !d.abs || !partPaths.has(String(d.abs)));
const todoShown = tools().some((t: any) =>
toolDisplay(t.name).todos && t.status !== "running" && (m.todos?.length));
return (
{m.subagent ? : null}
{diffless.map((d: any) => )}
{!todoShown && m.todos?.length ? : null}
{!isThink ? (
{"\u25A3 " + (agentTag || "Loom")}
{(m.thinkTime ? " \u00B7 " + fmtMs(m.thinkTime) : "") + (isStop ? " \u00B7 interrupted" : "")}
) : null}
);
}
export function ChatArea(props: { messages?: () => any[]; thinking?: boolean }) {
const visible = () => (props.messages?.() || []).slice(-40);
// Indices must match the store's messages() so Ctrl+E (which walks the full
// list) lands on the same bubble the chat renders.
const baseIdx = () => Math.max(0, (props.messages?.() || []).length - 40);
return (
{visible().map((m, i) => (
))}
);
}