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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- 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
|
+
}
|