loom-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,34 @@
1
+ // Top breadcrumb bar — project path, session info, model.
2
+ import { palette } from "../theme.ts";
3
+ import { providerName, modelName, sessionId, cwdShort, username, inputMode } from "../store.ts";
4
+
5
+ const MODE_LABELS: Record<string, string> = { build: "B", plan: "P", chat: "C" };
6
+
7
+ export function BreadcrumbBar() {
8
+ const ui = palette("loom");
9
+ const modeColors: Record<string, string> = { build: ui.primary, plan: ui.primarySoft, chat: ui.warning };
10
+ return (
11
+ <box
12
+ flexDirection="row"
13
+ justifyContent="space-between"
14
+ alignItems="center"
15
+ paddingX={1}
16
+ backgroundColor={ui.bgPanel}
17
+ >
18
+ <box flexDirection="row">
19
+ <text fg={ui.primary}>{"loom"}</text>
20
+ <text fg={ui.fgMuted}>{" / "}</text>
21
+ <text fg={ui.fg}>{cwdShort() || "~"}</text>
22
+ <text fg={ui.fgMuted}>{" · "}</text>
23
+ <text fg={ui.fgDim}>{sessionId().slice(0, 8)}</text>
24
+ <text fg={ui.fgMuted}>{" · "}</text>
25
+ <text fg={modeColors[inputMode()] || ui.primary}>{"[" + (MODE_LABELS[inputMode()] || "B") + "]"}</text>
26
+ </box>
27
+ <box flexDirection="row">
28
+ <text fg={ui.secondary}>{providerName()}</text>
29
+ <text fg={ui.fgMuted}>{" / "}</text>
30
+ <text fg={ui.fg}>{modelName()}</text>
31
+ </box>
32
+ </box>
33
+ );
34
+ }
@@ -0,0 +1,518 @@
1
+ // ChatArea — message list with proper role styling + tool log + think time.
2
+ import { createSignal, createMemo, onMount, onCleanup, Show } from "solid-js";
3
+ import { palette } from "../theme.ts";
4
+ import {
5
+ showThinking, userExpandedIdx, setUserExpandedIdx, sidebarVisible,
6
+ thoughtExpanded, setThoughtExpanded, thoughtClosed, setThoughtClosed,
7
+ } from "../store.ts";
8
+ import { toolDisplay } from "../tool-display.ts";
9
+ import { formatDiffCount } from "../../core/file-diffs.js";
10
+ import { MdText } from "./MdText.tsx";
11
+ import os from "os";
12
+
13
+ const ui = palette("loom");
14
+ function uname() { return os.userInfo().username || "you"; }
15
+ function fmtMs(ms) { if (!ms) return ""; return ms < 1000 ? ms + "ms" : (ms / 1000).toFixed(1) + "s"; }
16
+
17
+ // Rotating square-of-dots thinking animation — small, quiet, distracting-free.
18
+ // Renders as the reasoning part's header while the model streams reasoning,
19
+ // opencode-style: "⠋ Thinking", and the body markdown streams live below it.
20
+ const THINK_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
+ function ThinkingLabel() {
22
+ const [tick, setTick] = createSignal(0);
23
+ let t: any;
24
+ onMount(() => { t = setInterval(() => setTick(i => i + 1), 120); });
25
+ onCleanup(() => t && clearInterval(t));
26
+ return (
27
+ <span>
28
+ {THINK_FRAMES[tick() % THINK_FRAMES.length] + " Thinking"}
29
+ </span>
30
+ );
31
+ }
32
+
33
+ // Running tool rows that opencode spins (read/task/execute) use the braille
34
+ // spinner; everything else shows the quiet "~ pending" text instead — a long
35
+ // command never looks frozen on screen.
36
+ function ToolSpinner(props: { text: string; fg?: string }) {
37
+ const [tick, setTick] = createSignal(0);
38
+ let t: any;
39
+ onMount(() => { t = setInterval(() => setTick(i => i + 1), 120); });
40
+ onCleanup(() => t && clearInterval(t));
41
+ return <text fg={props.fg || ui.fg}>{THINK_FRAMES[tick() % THINK_FRAMES.length] + " " + props.text}</text>;
42
+ }
43
+
44
+ // Rough visual line count (logical lines + width wrapping) — drives the
45
+ // "~N lines" badge on big pasted user messages.
46
+ export const PER_LINE = 88;
47
+ export function estVisualLines(text) {
48
+ if (!text) return 1;
49
+ let n = 0;
50
+ for (const ln of String(text).split("\n")) n += Math.max(1, Math.ceil((ln.length + 1) / PER_LINE));
51
+ return n;
52
+ }
53
+
54
+ // Long pasted messages are collapsed to a preview so the chat keeps breathing
55
+ // room; the "~N lines" badge shows the real size. Click (or Ctrl+E on the most
56
+ // recent one) to expand/collapse. The FULL content is always kept — expansion
57
+ // shows everything, only the collapsed preview is truncated.
58
+ export const USER_PREVIEW_LINES = 10;
59
+ const USER_PREVIEW_CHARS = 2000;
60
+
61
+ function UserBubble(props) {
62
+ const m = props.m;
63
+ const idx = props.idx;
64
+ const content = String(m.content || "");
65
+ const lines = estVisualLines(content);
66
+ const big = lines > USER_PREVIEW_LINES;
67
+ const expanded = () => userExpandedIdx() === idx;
68
+ const toggle = () => setUserExpandedIdx(expanded() ? null : idx);
69
+ const shown = () => {
70
+ if (!big || expanded()) return content;
71
+ return content.split("\n").slice(0, USER_PREVIEW_LINES).join("\n").slice(0, USER_PREVIEW_CHARS);
72
+ };
73
+
74
+ return (
75
+ <box flexDirection="column" marginBottom={1} paddingRight={1}>
76
+ <box flexDirection="row" justifyContent="space-between" paddingX={1}>
77
+ <box flexDirection="row">
78
+ <text fg={ui.secondary}>{"\u25E4 " + uname()}</text>
79
+ <Show when={lines > 1}>
80
+ <text fg={ui.fgMuted}>{" ~" + lines + " lines"}</text>
81
+ </Show>
82
+ </box>
83
+ <text fg={ui.fgMuted}>{m.time || ""}</text>
84
+ </box>
85
+ <box
86
+ paddingX={2}
87
+ onMouseUp={big ? toggle : undefined}
88
+ onKeyDown={big ? (e: any) => { if (e.name === "return" || e.name === "space") toggle(); } : undefined}
89
+ >
90
+ <text fg={ui.fg}>{shown()}</text>
91
+ <Show when={big}>
92
+ <text fg={ui.fgMuted}>
93
+ {" \u2026 (" + lines + " lines \u2014 click to " + (expanded() ? "collapse" : "expand") + ", or Ctrl+E)"}
94
+ </text>
95
+ </Show>
96
+ </box>
97
+ </box>
98
+ );
99
+ }
100
+
101
+ // opencode's BlockTool: a flat panel with a subtle LEFT border (drawn in the
102
+ // main background color so it reads as a faint divider, not a frame) on the
103
+ // panel background. Blocks always separate from the rows around them.
104
+ function Block(props: { children: any }) {
105
+ return (
106
+ <box
107
+ flexDirection="column"
108
+ marginTop={1}
109
+ paddingLeft={2}
110
+ paddingTop={1}
111
+ paddingBottom={1}
112
+ border={["left"]}
113
+ borderColor={ui.bg}
114
+ backgroundColor={ui.bgPanelAlt}
115
+ >
116
+ {props.children}
117
+ </box>
118
+ );
119
+ }
120
+
121
+ // opencode's generic/bash output block: "$ {command}" (bash) or
122
+ // "# {tool} {args}" (ANY tool — MCP, custom — through the generic fallback)
123
+ // with the tool's result beneath, collapsed to a few lines with a
124
+ // click-to-expand hint. The agent's actual work product shows up in the chat
125
+ // no matter which tool produced it.
126
+ function OutputBlock(props: { title: string; body: string; maxLines: number; mt?: number; live?: boolean }) {
127
+ const [expanded, setExpanded] = createSignal(false);
128
+ const lines = () => String(props.body || "").split("\n");
129
+ const overflow = () => lines().length > props.maxLines;
130
+ const toggle = () => setExpanded((v) => !v);
131
+ return (
132
+ <box
133
+ flexDirection="column"
134
+ marginTop={props.mt ? 1 : 0}
135
+ paddingLeft={2}
136
+ paddingTop={1}
137
+ paddingBottom={1}
138
+ border={["left"]}
139
+ borderColor={ui.bg}
140
+ backgroundColor={ui.bgPanelAlt}
141
+ onMouseUp={overflow() ? toggle : undefined}
142
+ onKeyDown={overflow() ? (e: any) => { if (e.name === "return" || e.name === "space") toggle(); } : undefined}
143
+ >
144
+ <box flexDirection="row">
145
+ <text paddingLeft={3} fg={ui.fgMuted}>{props.title}</text>
146
+ <Show when={props.live}>
147
+ <text paddingLeft={1} fg={ui.warning}>{"\u25CF streaming"}</text>
148
+ </Show>
149
+ </box>
150
+ {lines().slice(0, expanded() ? undefined : props.maxLines).map((l: string) => (
151
+ <text paddingLeft={4} fg={ui.fg}>{l || " "}</text>
152
+ ))}
153
+ <Show when={overflow()}>
154
+ <text paddingLeft={4} fg={ui.fgMuted}>
155
+ {props.live
156
+ ? "\u2026 streaming \u2014 " + (expanded() ? "click to collapse" : "click to expand")
157
+ : "\u2026 " + (expanded() ? "Click to collapse" : "Click to expand")}
158
+ </text>
159
+ </Show>
160
+ </box>
161
+ );
162
+ }
163
+
164
+ // Inline file-change block, opencode-style: a finished write/edit/bash shows
165
+ // its diff right where the tool ran ("← Edit src/b.ts" + +/- hunks), so the
166
+ // patch only appears when the agent actually changed a file — then the message
167
+ // continues normally. A brand-new file renders as "# Wrote src/new.ts" with
168
+ // its fresh content marked "+".
169
+ function DiffBlock(props: { d: any; toolName?: string }) {
170
+ const d = props.d;
171
+ if (!d) return null;
172
+ function colWidth() {
173
+ const term = Math.max(40, (process.stdout?.columns || 100) | 0);
174
+ const sidebarW = sidebarVisible() ? 40 : 0; // 38 sidebar + 1 margin + 1 gap
175
+ return Math.max(20, term - sidebarW - 12);
176
+ }
177
+ const W = colWidth();
178
+ // Long lines truncate with an ellipsis instead of spilling past the width.
179
+ const clip = (t: string) => (t.length > W ? t.slice(0, W - 1) + "…" : t);
180
+ const prefix = (k: string) => (k === "del" ? "-" : k === "add" ? "+" : " ");
181
+ // opencode's block titles: "← Edit src/b.ts" for edits, "# Wrote ..." for
182
+ // writes (and any brand-new file), and the bare arrow for bash-changed files.
183
+ const verb = props.toolName === "edit" && !d.isNew ? "\u2190 Edit " : (props.toolName === "write" || d.isNew) ? "# Wrote " : "\u2190 ";
184
+ return (
185
+ <Block>
186
+ <box flexDirection="row" justifyContent="space-between">
187
+ <text paddingLeft={3} fg={ui.fgMuted}>{verb + d.path}</text>
188
+ <text fg={d.added ? ui.success : ui.fgMuted}>{" " + formatDiffCount(d)}</text>
189
+ </box>
190
+ <box paddingLeft={1}>
191
+ {(d.lines || []).map((ln: any) => (
192
+ <text fg={ln.kind === "del" ? ui.error : ln.kind === "add" ? ui.success : ui.fgMuted}>
193
+ {prefix(ln.kind) + clip(String(ln.text || "").replace(/\n$/, ""))}
194
+ </text>
195
+ ))}
196
+ </box>
197
+ </Block>
198
+ );
199
+ }
200
+
201
+ // Todos block, opencode TodoWrite-style: "# Todos" with [✓]/[•]/[ ] marks.
202
+ // The in-progress row uses the warning color, like opencode's TodoItem.
203
+ function TodosBlock(props: { todos: any[] }) {
204
+ const todoMark = (t: any) => "[" + (t.done ? "\u2713" : t.inProgress ? "\u2022" : " ") + "] ";
205
+ return (
206
+ <Block>
207
+ <text paddingLeft={3} fg={ui.fgMuted}># Todos</text>
208
+ {props.todos.map((t: any) => (
209
+ <text fg={t.inProgress ? ui.warning : ui.fgMuted}>{todoMark(t) + String(t.text || "")}</text>
210
+ ))}
211
+ </Block>
212
+ );
213
+ }
214
+
215
+ // One tool call = its own part, opencode-style. While running, most tools show
216
+ // the quiet "~ Preparing edit..." pending text (deeper indent, no spinner, no
217
+ // accent — opencode only spins read/task/execute rows). Once done the row
218
+ // flips to a muted "→ Read src/a.ts" / "← Edit src/b.ts" line that STAYS in
219
+ // the transcript — each command gets its own entry, never one merged patch.
220
+ // A finished write/edit/bash that actually changed a file REPLACES its row
221
+ // with the diff block (opencode's Edit renders only its BlockTool once the
222
+ // diff exists) — the patch shows right where the edit happened, then the
223
+ // message continues normally. Errors render in the error color. Completed
224
+ // rows vanish when "tool details" are off (only running rows and errors
225
+ // stay) — App filters done tool parts out of messages() reactively. Every
226
+ // decision below comes from the tool's DISPLAY DATA (tool-display.ts), never
227
+ // a tool name in the renderer — anything unregistered renders via the
228
+ // GenericTool fallback.
229
+ function ToolPart(props: { t: any; todos?: any[]; blockBefore: boolean }) {
230
+ const t = props.t;
231
+ const d = toolDisplay(t.name);
232
+ const diffs = t.fileDiffs || [];
233
+ const mt = props.blockBefore ? 1 : 0;
234
+ // todowrite collapses into the "# Todos" block once done (opencode
235
+ // TodoWrite swaps the inline row for the block), but its pending row
236
+ // still shows.
237
+ if (d.todos && t.status !== "running" && (props.todos?.length)) {
238
+ return <TodosBlock todos={props.todos} />;
239
+ }
240
+ // Finished write/edit/bash WITH a diff: only the patch renders — opencode
241
+ // swaps the inline row for its BlockTool once the diff is available.
242
+ if (d.diff && t.status !== "running" && diffs.length) {
243
+ return (
244
+ <box flexDirection="column" marginTop={mt}>
245
+ {diffs.map((d2: any) => <DiffBlock d={d2} toolName={t.name} />)}
246
+ </box>
247
+ );
248
+ }
249
+ // Finished tools with output that define a block swap the row for it — the
250
+ // "$ command" result (bash, opencode's Shell) or the generic "# {tool} {args}"
251
+ // preview (ANY other tool with output).
252
+ if (t.status === "done" && t.output && d.block) {
253
+ return <OutputBlock title={d.block.title(t)} body={t.output} maxLines={d.block.maxLines || 10} mt={mt} />;
254
+ }
255
+ if (t.status === "running") {
256
+ // Bash (live tools) with streamed output swaps the quiet pending row for
257
+ // a growing, collapsible terminal block — the command's output appears
258
+ // as it runs, then the finished block takes over with the full result.
259
+ if (d.live && t.liveOutput) {
260
+ return (
261
+ <OutputBlock
262
+ title={d.block ? d.block.title(t) : "$ " + (t.label || t.name)}
263
+ body={t.liveOutput}
264
+ maxLines={(d.block && d.block.maxLines) || 8}
265
+ mt={mt}
266
+ live={true}
267
+ />
268
+ );
269
+ }
270
+ // read/task/execute spin their label (opencode passes spinner=true for
271
+ // exactly these); everything else gets the quiet "~ pending" text.
272
+ if (d.spinner) {
273
+ return (
274
+ <box flexDirection="column" paddingLeft={3} marginTop={mt}>
275
+ <ToolSpinner text={t.label || t.pending || "Working..."} />
276
+ </box>
277
+ );
278
+ }
279
+ return (
280
+ <box flexDirection="column" paddingLeft={3} marginTop={mt}>
281
+ <text paddingLeft={3} fg={ui.fg}>{"~ " + (t.pending || "Working...")}</text>
282
+ </box>
283
+ );
284
+ }
285
+ const icon =
286
+ t.status === "error" && d.check ? "\u2717"
287
+ : t.status === "done" && d.check ? "\u2713"
288
+ : (t.icon || d.icon);
289
+ const fg = t.status === "error" ? ui.error : ui.fgMuted;
290
+ return (
291
+ <box flexDirection="column" paddingLeft={3} marginTop={mt}>
292
+ <text fg={fg}>{icon + " " + (t.label || d.label({}) || t.name)}</text>
293
+ </box>
294
+ );
295
+ }
296
+
297
+ // opencode streams each part WHERE it arrived: reasoning → tool → reasoning →
298
+ // tool → text, in exact chronological order, so the chat reads as a flow —
299
+ // "thinks, reads, thinks again, edits, replies" — never a fixed block layout.
300
+ // Single-line parts (settled "+ Thought" rows, running/done tool rows) pack
301
+ // tightly; anything multi-line (streaming/open thinking, patch blocks, text)
302
+ // separates with a gap.
303
+ function PartList(props: { m: any; idx: number }) {
304
+ const m = props.m;
305
+ const parts = () => (m.parts && m.parts.length ? m.parts : deriveParts(m));
306
+ // The part streaming right now is the LAST reasoning part while the turn is
307
+ // live; earlier reasoning parts have already settled (opencode's minimal
308
+ // mode collapses each one the moment the model moves on to tools/text).
309
+ // Memoized: streamed reasoning parts arrive asynchronously, so the index
310
+ // must recompute from parts() (not a one-time const) for the ThinkingLabel
311
+ // to follow the stream.
312
+ const lastReasonIdx = createMemo(() => {
313
+ let last = -1;
314
+ const ps = parts();
315
+ for (let k = 0; k < ps.length; k++) if (ps[k].type === "reasoning") last = k;
316
+ return last;
317
+ });
318
+ let blockPrev = false;
319
+ return (
320
+ <>
321
+ {parts().map((p: any, i: number) => {
322
+ if (p.type === "reasoning") {
323
+ if (!showThinking()) return null;
324
+ const live = !!m.thinking && i === lastReasonIdx();
325
+ const open = live ? !thoughtClosed().has(props.idx) : (thoughtExpanded().get(props.idx)?.has(i) ?? false);
326
+ blockPrev = open;
327
+ return <ThoughtPart p={p} i={i} idx={props.idx} live={live} open={open} />;
328
+ }
329
+ if (p.type === "tool") {
330
+ const t = p.tool;
331
+ const d = toolDisplay(t.name);
332
+ const isBlock = !!(
333
+ (d.todos && t.status !== "running" && (m.todos?.length)) ||
334
+ (d.diff && t.status !== "running" && (t.fileDiffs || []).length > 0) ||
335
+ (t.status === "done" && t.output && d.block) ||
336
+ (t.status === "running" && t.liveOutput)
337
+ );
338
+ const mt = blockPrev;
339
+ blockPrev = isBlock;
340
+ return <ToolPart t={t} todos={m.todos} blockBefore={mt} />;
341
+ }
342
+ blockPrev = true;
343
+ return (
344
+ <box paddingLeft={3} marginTop={1} flexShrink={0}>
345
+ <Show
346
+ when={!m.isError}
347
+ fallback={<text fg={ui.error}>{String(p.text || "").slice(0, 30000)}</text>}
348
+ >
349
+ <MdText md={String(p.text || "").slice(0, 30000)} />
350
+ </Show>
351
+ </box>
352
+ );
353
+ })}
354
+ </>
355
+ );
356
+ }
357
+
358
+ // Fallback for restored sessions / test fixtures in the old shape: derive the
359
+ // parts list from the legacy fields (thinking on top, then tools, then text).
360
+ function deriveParts(m: any): any[] {
361
+ const out: any[] = [];
362
+ if (m.thinkingContent || m.thinking) out.push({ type: "reasoning", text: String(m.thinkingContent || ""), thinkMs: m.thinkTime || 0 });
363
+ for (const t of (m.tools || [])) out.push({ type: "tool", tool: t });
364
+ if (m.content) out.push({ type: "text", text: String(m.content) });
365
+ return out;
366
+ }
367
+
368
+ // One reasoning part = its own block, opencode-style. While the turn runs and
369
+ // it is the latest reasoning it streams as "⠋ Thinking" with the body LIVE
370
+ // below (click collapses it mid-turn); once the model moves on (a tool or text
371
+ // part arrives, or the turn ends) it settles to a clickable "+ Thought · Ns"
372
+ // line — opencode's minimal mode. Clicking toggles the body in either state.
373
+ function ThoughtPart(props: { p: any; i: number; idx: number; live: boolean; open: boolean }) {
374
+ const toggle = () => {
375
+ if (props.live) {
376
+ const closed = new Set(thoughtClosed());
377
+ if (closed.has(props.idx)) closed.delete(props.idx); else closed.add(props.idx);
378
+ setThoughtClosed(closed);
379
+ } else {
380
+ const map = new Map(thoughtExpanded());
381
+ const set = new Set(map.get(props.idx) || []);
382
+ if (set.has(props.i)) set.delete(props.i); else set.add(props.i);
383
+ if (set.size) map.set(props.idx, set); else map.delete(props.idx);
384
+ setThoughtExpanded(map);
385
+ }
386
+ };
387
+ const ms = () => (props.p.thinkMs && props.p.thinkMs >= 1000 ? fmtMs(props.p.thinkMs) : "");
388
+ return (
389
+ <box flexDirection="column" paddingLeft={3} marginTop={1}>
390
+ <text
391
+ fg={ui.thinking}
392
+ onMouseUp={() => toggle()}
393
+ onKeyDown={(e: any) => { if (e.name === "return" || e.name === "space") toggle(); }}
394
+ >
395
+ {props.live ? <ThinkingLabel /> : (props.open ? "- " : "+ ") + "Thought" + (ms() ? " \u00B7 " + ms() : "")}
396
+ </text>
397
+ <Show when={props.open}>
398
+ <box paddingLeft={2}>
399
+ <MdText md={String(props.p.text || "").slice(0, 20000)} />
400
+ </box>
401
+ </Show>
402
+ </box>
403
+ );
404
+ }
405
+
406
+ // Subagent delegation (task tool), opencode-style: one InlineTool row —
407
+ // "│ @explore Task" spinning while the child runs, flipping to a muted
408
+ // "✓ @explore Task" once done — with the "↳ tool log" and streamed body
409
+ // lines beneath, and a "↳ finished · <status>" line at the end.
410
+ function SubagentPanel(props: { s: any }) {
411
+ const s = props.s;
412
+ const done = !!s.done;
413
+ const agent = String(s.agent || "");
414
+ return (
415
+ <box flexDirection="column" marginTop={1} paddingLeft={3}>
416
+ {done ? (
417
+ <text fg={ui.fgMuted}>{"\u2713 @" + agent + " Task"}</text>
418
+ ) : (
419
+ <ToolSpinner text={"@" + agent + " Task"} />
420
+ )}
421
+ {s.log ? <text fg={ui.fgMuted}>{"\u21B3 " + s.log}</text> : null}
422
+ {s.text ? <text fg={ui.fg}>{String(s.text)}</text> : null}
423
+ {done ? <text fg={ui.fgMuted}>{"\u21B3 finished \u00B7 " + (s.status || "")}</text> : null}
424
+ </box>
425
+ );
426
+ }
427
+
428
+ // Assistant message, opencode-style: every part streams as its OWN flat block
429
+ // at the same left padding (paddingLeft 3) in exact arrival order — reasoning
430
+ // ("⠋ Thinking" streaming live, settling to a clickable "+ Thought" once the
431
+ // model moves on), tool rows (one per call, "~ Preparing edit..." while
432
+ // running, the patch block right where the edit happened when done), and the
433
+ // markdown text wherever the model actually said it — so a turn reads like
434
+ // "thinks, reads, thinks again, edits, replies" instead of a fixed layout.
435
+ // NO bubble; the answer renders as the last text part. After the parts come
436
+ // the subagent panel (task tool), leftover diff blocks, "# Todos" block and
437
+ // the footer "▣ Loom · 4.2s" once the turn finishes.
438
+ function MsgBubble(props: { m: any; idx: number }) {
439
+ const m = props.m;
440
+
441
+ if (m.role === "system") {
442
+ return (
443
+ <box marginBottom={1} paddingX={2}>
444
+ <text fg={ui.fgMuted}>{m.content}</text>
445
+ </box>
446
+ );
447
+ }
448
+
449
+ if (m.role === "user") {
450
+ return <UserBubble m={m} idx={props.idx} />;
451
+ }
452
+
453
+ if (m.role === "tool") {
454
+ const first = String(m.content || "").split("\n")[0].slice(0, 140);
455
+ return (
456
+ <box marginBottom={1} paddingLeft={4}>
457
+ <text fg={ui.accent}>{"\u2192 " + first}</text>
458
+ </box>
459
+ );
460
+ }
461
+
462
+ const isErr = m.isError;
463
+ const isThink = m.thinking;
464
+ const isStop = m.interrupted;
465
+ const agentTag = m.agentLabel ? "@" + m.agentLabel : null;
466
+ const parts = () => (m.parts && m.parts.length ? m.parts : deriveParts(m));
467
+ const tools = () => parts().filter((p: any) => p.type === "tool").map((p: any) => p.tool)
468
+ .filter((t: any) => !(toolDisplay(t.name).subagent && m.subagent));
469
+ // Diffs already attached to a tool part render inline in that part; anything
470
+ // left over (restored sessions, tests) renders after the parts. Dedupe by
471
+ // stable abs path (the same key App uses to merge diffs), not object
472
+ // identity — copies of the same diff would render twice.
473
+ const partPaths = new Set<string>();
474
+ for (const t of tools()) {
475
+ for (const d of (t.fileDiffs || [])) if (d && d.abs) partPaths.add(String(d.abs));
476
+ }
477
+ const diffless = (m.fileDiffs || []).filter((d: any) => !d || !d.abs || !partPaths.has(String(d.abs)));
478
+ const todoShown = tools().some((t: any) =>
479
+ toolDisplay(t.name).todos && t.status !== "running" && (m.todos?.length));
480
+ return (
481
+ <box flexDirection="column" marginBottom={1} paddingRight={1}>
482
+ <PartList m={m} idx={props.idx} />
483
+ {m.subagent ? <SubagentPanel s={m.subagent} /> : null}
484
+ {diffless.map((d: any) => <DiffBlock d={d} />)}
485
+ {!todoShown && m.todos?.length ? <TodosBlock todos={m.todos} /> : null}
486
+ {!isThink ? (
487
+ <box flexDirection="row" paddingLeft={3} marginTop={1}>
488
+ <text fg={isErr ? ui.error : ui.secondary}>{"\u25A3 " + (agentTag || "Loom")}</text>
489
+ <text fg={ui.fgMuted}>
490
+ {(m.thinkTime ? " \u00B7 " + fmtMs(m.thinkTime) : "") + (isStop ? " \u00B7 interrupted" : "")}
491
+ </text>
492
+ </box>
493
+ ) : null}
494
+ </box>
495
+ );
496
+ }
497
+
498
+ export function ChatArea(props: { messages?: () => any[]; thinking?: boolean }) {
499
+ const visible = () => (props.messages?.() || []).slice(-40);
500
+ // Indices must match the store's messages() so Ctrl+E (which walks the full
501
+ // list) lands on the same bubble the chat renders.
502
+ const baseIdx = () => Math.max(0, (props.messages?.() || []).length - 40);
503
+ return (
504
+ <box flexDirection="column" flexGrow={1} overflow="hidden" paddingX={0}>
505
+ <scrollbox
506
+ flexGrow={1}
507
+ stickyScroll
508
+ stickyStart="bottom"
509
+ scrollbarOptions={{ trackOptions: {} }}
510
+ viewportOptions={{ flexGrow: 1 }}
511
+ >
512
+ {visible().map((m, i) => (
513
+ <MsgBubble m={m} idx={baseIdx() + i} />
514
+ ))}
515
+ </scrollbox>
516
+ </box>
517
+ );
518
+ }