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,264 @@
1
+ // PermissionPopup -- raised above the input bar when the model wants to run a
2
+ // command or change a file, OR when the model is asking a question (the ask
3
+ // tool). Two modes:
4
+ // • permission: Allow / Always allow / Deny only — a permission is a yes/no
5
+ // verdict, so there is no "type your answer" row.
6
+ // • question: the model's question + its provided options, plus a "Type your
7
+ // answer…" row — questions may legitimately need an answer that is not one
8
+ // of the offered options. Typing opens the inline editor in this popup —
9
+ // no separate overlay ever opens for typing.
10
+ import { createSignal, Show } from "solid-js";
11
+ import { useKeyboard } from "@opentui/solid";
12
+ import { palette } from "../theme.ts";
13
+ import {
14
+ permission, getSession,
15
+ questionOpen, questionText, setQuestionText, openQuestion, closeQuestion,
16
+ setAutoPerm,
17
+ } from "../store.ts";
18
+
19
+ const ui = palette("loom");
20
+
21
+ const FIT = 54;
22
+ function fit(s: string, n = FIT) {
23
+ const flat = String(s || "").replace(/\s+/g, " ").trim();
24
+ return flat.length <= n ? flat : flat.slice(0, Math.max(1, n - 1)) + "\u2026";
25
+ }
26
+
27
+ export function PermissionPopup() {
28
+ // Keyed Show: every request is a NEW object, so the inner prompt remounts
29
+ // with fresh selection/custom state (a plain Show kept the previous answer).
30
+ return (
31
+ <Show when={permission()} keyed>
32
+ {() => <PermissionPrompt />}
33
+ </Show>
34
+ );
35
+ }
36
+
37
+ function PermissionPrompt() {
38
+ const pr = permission()!;
39
+ const isQ = !!pr.isQuestion;
40
+ const isStart = !!pr.sessionStart;
41
+ const opts = isQ && pr.options && pr.options.length ? pr.options : [];
42
+ // Row index: 0..opts.length-1 are the question options; the last row is
43
+ // "Type your answer…" (question mode) — or 0 Allow · 1 Always · 2 Deny ·
44
+ // 3 Allow-all-in-session (bash only). The session-start prompt is a
45
+ // question with exactly its two options and no free-answer row.
46
+ const maxSel = isStart ? opts.length - 1 : isQ ? opts.length : (pr.tool === "bash" ? 3 : 2);
47
+ const [sel, setSel] = createSignal(0);
48
+ const custom = () => questionOpen();
49
+ const customText = () => questionText();
50
+
51
+ // Note: the question state lives in the STORE (not in this component) on
52
+ // purpose — the reconciler remounts this subtree on signal changes, and a
53
+ // mount-time reset would wipe a typed answer mid-keystroke.
54
+
55
+ const answerWith = (text: string) => {
56
+ const p = permission();
57
+ if (!p) return;
58
+ closeQuestion();
59
+ const t = String(text || "").trim();
60
+ if (!t) return;
61
+ p.resolve(true, t); // questions: any text is the answer
62
+ };
63
+
64
+ const submitCustom = () => answerWith(customText());
65
+
66
+ // Mouse: rows are click-to-select + click-to-act (same action as Enter on
67
+ // the highlighted row), so the popup is fully reachable with the mouse.
68
+ const execSel = (i: number) => {
69
+ const p = permission()!;
70
+ if (isQ) {
71
+ if (isStart) {
72
+ if (i === 0) {
73
+ // "Allow all commands" — session-wide auto-approve (Shift+Tab
74
+ // toggles it off/on too).
75
+ try { getSession().permissions.setAuto(true); } catch {}
76
+ setAutoPerm(true);
77
+ p.resolve(true, "allow");
78
+ return;
79
+ }
80
+ p.resolve(false, "ask");
81
+ return;
82
+ }
83
+ if (i >= opts.length) { openQuestion(""); return; }
84
+ answerWith(opts[i]);
85
+ return;
86
+ }
87
+ if (i === 0) { p.resolve(true); return; }
88
+ if (i === 1) {
89
+ try { getSession().permissions.setRule(p.command, "allow", true); } catch {}
90
+ p.resolve(true);
91
+ return;
92
+ }
93
+ if (i === 3) {
94
+ // Session-wide auto-approve (bash only): the rest of THIS turn (and any
95
+ // later turn in this session) stops asking for command approval.
96
+ try { getSession().permissions.setAuto(true); } catch {}
97
+ p.resolve(true);
98
+ return;
99
+ }
100
+ p.resolve(false);
101
+ };
102
+ const row = (i: number) => ({
103
+ onMouseDown: () => setSel(i),
104
+ onMouseUp: () => execSel(i),
105
+ });
106
+
107
+ useKeyboard(function(key) {
108
+ const p = permission();
109
+ if (!p) return;
110
+ const k = key.name;
111
+
112
+ if (k === "escape") {
113
+ if (custom()) { closeQuestion(); return; }
114
+ p.resolve(false, "(esc)");
115
+ return;
116
+ }
117
+
118
+ if (custom()) {
119
+ if (k === "return") { submitCustom(); return; }
120
+ if (k === "backspace" || k === "delete") { setQuestionText(v => v.slice(0, -1)); return; }
121
+ const s = key.sequence;
122
+ if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n" && s !== "\t") {
123
+ setQuestionText(v => v + s);
124
+ }
125
+ return;
126
+ }
127
+
128
+ // Session-start prompt: only up/down + enter + esc, never the answer
129
+ // editor (there is no "type your own answer" for this one).
130
+ if (isStart) {
131
+ if (k === "up" || k === "down") {
132
+ setSel(i => (k === "up" ? Math.max(0, i - 1) : Math.min(maxSel, i + 1)));
133
+ return;
134
+ }
135
+ if (k === "return") { execSel(sel()); return; }
136
+ return;
137
+ }
138
+
139
+ if (k === "up" || k === "down") {
140
+ setSel(i => (k === "up" ? Math.max(0, i - 1) : Math.min(maxSel, i + 1)));
141
+ return;
142
+ }
143
+
144
+ // Question mode: typing opens the inline editor (the user's answer may
145
+ // not be one of the offered options). Permission mode: ignored — a
146
+ // permission is Allow / Always allow / Deny, nothing else.
147
+ if (isQ && !key.ctrl && !key.meta && key.sequence && key.sequence.length <= 10 && key.sequence !== "\r" && key.sequence !== "\n" && key.sequence !== "\t") {
148
+ openQuestion(key.sequence);
149
+ return;
150
+ }
151
+
152
+ if (k === "return") {
153
+ if (isQ) {
154
+ if (sel() >= opts.length) { openQuestion(""); return; }
155
+ answerWith(opts[sel()]);
156
+ return;
157
+ }
158
+ if (sel() === 0) { p.resolve(true); return; }
159
+ if (sel() === 1) {
160
+ try { getSession().permissions.setRule(p.command, "allow", true); } catch {}
161
+ p.resolve(true);
162
+ return;
163
+ }
164
+ if (sel() === 3) {
165
+ try { getSession().permissions.setAuto(true); } catch {}
166
+ p.resolve(true);
167
+ return;
168
+ }
169
+ p.resolve(false);
170
+ }
171
+ });
172
+
173
+ return (
174
+ <box
175
+ border borderStyle="rounded" borderColor={custom() ? ui.accent : isStart ? ui.accent : isQ ? ui.accent : ui.warning}
176
+ paddingX={2} paddingY={1}
177
+ flexDirection="column" marginBottom={0}
178
+ backgroundColor={ui.bgPanel}
179
+ >
180
+ <text fg={custom() ? ui.accent : isStart ? ui.accent : isQ ? ui.accent : ui.warning}>
181
+ {custom() ? "\u2753 Answer" : isStart ? "Session permissions" : isQ ? "\u2753 Question" : "\u26A0 Permission needed"}
182
+ </text>
183
+ <Show when={!custom()}>
184
+ <text fg={ui.fgMuted} marginTop={1}>
185
+ {isStart
186
+ ? "New session \u00B7 you can toggle anytime with Shift+Tab:"
187
+ : isQ
188
+ ? "The model wants to know:"
189
+ : "Model wants to " + (pr.tool === "bash" ? "run a command" : "change a file") + ":"}
190
+ </text>
191
+ <text fg={isQ ? ui.fg : ui.primary}>
192
+ {isQ ? pr.command : pr.tool + ": " + fit(pr.command)}
193
+ </text>
194
+ <Show when={pr.label && pr.label !== "dangerous command"}>
195
+ <text fg={ui.warning} marginTop={1}>
196
+ {"\u26A0 " + pr.label}
197
+ </text>
198
+ </Show>
199
+
200
+ <box flexDirection="column" marginTop={1} gap={0}>
201
+ {isQ ? (
202
+ opts.map((o, i) => (
203
+ <box {...row(i)} flexDirection="row" paddingY={0}>
204
+ <text fg={sel() === i ? ui.primary : ui.fgDim} paddingRight={1}>
205
+ {(sel() === i ? "\u25B6 " : " ") + fit(o, 40)}
206
+ </text>
207
+ </box>
208
+ ))
209
+ ) : (
210
+ <>
211
+ <box {...row(0)} flexDirection="row" paddingY={0}>
212
+ <text fg={sel() === 0 ? ui.primary : ui.fgDim} paddingRight={1}>
213
+ {(sel() === 0 ? "\u25B6 " : " ") + "Allow"}
214
+ </text>
215
+ <text fg={ui.success}>{"(recommended)"}</text>
216
+ </box>
217
+ <box {...row(1)} flexDirection="row" paddingY={0}>
218
+ <text fg={sel() === 1 ? ui.primary : ui.fgDim} paddingRight={1}>
219
+ {(sel() === 1 ? "\u25B6 " : " ") + "Always allow"}
220
+ </text>
221
+ <text fg={ui.fgMuted}>{"remember for this command"}</text>
222
+ </box>
223
+ <box {...row(2)}>
224
+ <text fg={sel() === 2 ? ui.primary : ui.fgDim} paddingY={0}>
225
+ {(sel() === 2 ? "\u25B6 " : " ") + "Deny"}
226
+ </text>
227
+ </box>
228
+ {pr.tool === "bash" ? (
229
+ <box {...row(3)}>
230
+ <text fg={sel() === 3 ? ui.primary : ui.fgDim} paddingY={0}>
231
+ {(sel() === 3 ? "\u25B6 " : " ") + "Allow all commands in this session"}
232
+ </text>
233
+ </box>
234
+ ) : null}
235
+ </>
236
+ )}
237
+ {isQ && !isStart ? (
238
+ <box {...row(opts.length)}>
239
+ <text fg={sel() === opts.length ? ui.primary : ui.fgDim} paddingY={0}>
240
+ {(sel() === opts.length ? "\u25B6 " : " ") + "Type your answer\u2026"}
241
+ </text>
242
+ </box>
243
+ ) : null}
244
+ </box>
245
+ </Show>
246
+
247
+ <Show when={custom()}>
248
+ <box border borderStyle="rounded" borderColor={ui.border} paddingX={1} marginTop={1}>
249
+ <text fg={ui.fg}>{customText() || " "}</text>
250
+ </box>
251
+ </Show>
252
+
253
+ <text fg={ui.fgMuted} marginTop={1}>
254
+ {custom()
255
+ ? "Enter send \u00B7 Esc back"
256
+ : isStart
257
+ ? "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 ESC skip"
258
+ : isQ
259
+ ? "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 type = your own answer \u00B7 ESC skip"
260
+ : "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 ESC deny"}
261
+ </text>
262
+ </box>
263
+ );
264
+ }
@@ -0,0 +1,182 @@
1
+ // Sidebar — right panel. All signal reads inside JSX handles SolidJS
2
+ // reactivity; agent todos live-update via the todos:changed event (wired in
3
+ // App onMount); todo rows toggle done on click, file rows click to open in the
4
+ // OS default app.
5
+ import { Show } from "solid-js";
6
+ import { palette, VERSION } from "../theme.ts";
7
+ import {
8
+ sidebarTab, setSidebarTab, todos, setTodos, messages, providerName, modelName,
9
+ cwdShort, getProjectFiles, speedStats, sessionUsage, budgetLevel, skillActive, autoPerm,
10
+ getSession, showToast, welcomeTipSeen, dismissWelcomeTips,
11
+ } from "../store.ts";
12
+ import { formatUsd } from "../../core/usage.js";
13
+ import path from "path";
14
+ import os from "os";
15
+ import { spawn } from "child_process";
16
+
17
+ const ui = palette("loom");
18
+ const TAB_NAMES = ["Info", "Todos", "Files"];
19
+
20
+ // Open a file/folder with the platform's default handler (explorer/open/xdg-open).
21
+ // openFileSpawn is a module seam so the interactive test suite can stub the
22
+ // real spawn (clicking a row must never pop windows on a dev machine).
23
+ export let openFileSpawn = spawn;
24
+ export function __stubOpenFileSpawn(fn: any) { openFileSpawn = fn || spawn; }
25
+ // Repo files that would EXECUTE on open (.bat/.cmd/.exe/.ps1/…) are never
26
+ // launched directly — their containing folder opens instead, so a file click
27
+ // can't run anything.
28
+ const EXEC_EXT = /\.(bat|cmd|com|exe|msi|ps1|psm1|reg|vbs|scr)$/i;
29
+ export function openWithDefault(rel: string) {
30
+ const abs = path.resolve(process.cwd(), rel);
31
+ const target = EXEC_EXT.test(abs) ? path.dirname(abs) : abs;
32
+ // explorer.exe takes a plain path argument — no cmd /c start, so filenames
33
+ // with shell metacharacters (& ^ | etc.) can never be parsed as commands.
34
+ const opener = process.platform === "win32" ? "explorer.exe" : process.platform === "darwin" ? "open" : "xdg-open";
35
+ const args = [target];
36
+ try { openFileSpawn(opener, args, { detached: true, stdio: "ignore", windowsHide: true }).unref(); } catch {}
37
+ return abs;
38
+ }
39
+
40
+ // Toggle the clicked todo between done (✓) and open ( ).
41
+ // Also updates the canonical session list (matched by text) so a later
42
+ // recomputeTodos() doesn't stomp the flip. `i` is the index into the FULL
43
+ // todos() list (the render slices the tail, so the row passes listStart + row).
44
+ function toggleTodoAt(i: number) {
45
+ const list = todos().slice();
46
+ const t = list[i];
47
+ if (!t) return;
48
+ list[i] = { ...t, done: !t.done, inProgress: false, cancelled: false };
49
+ setTodos(list);
50
+ try {
51
+ const sess = getSession();
52
+ const idx = (sess.todos || []).findIndex((x: any) => String(x.content || "").trim() === String(t.text || "").trim());
53
+ if (idx >= 0) {
54
+ sess.todos[idx] = { ...sess.todos[idx], status: !t.done ? "completed" : "pending" };
55
+ }
56
+ } catch {}
57
+ }
58
+
59
+ // Pure helper (no component boundary) — computed inline in JSX below so the
60
+ // signal read stays reactive: component wrappers were rendering stale values.
61
+ function speedInfo(sp: any): { label: string; color: string } {
62
+ const live = sp && sp.live ? sp.live : null;
63
+ const last = sp && sp.last ? sp.last : null;
64
+ const tps = live ? live.tokensPerSec : (last && last.tokensPerSec != null ? last.tokensPerSec : null);
65
+ const latency = live ? live.firstTokenMs : (last ? last.latencyMs : null);
66
+ let label: string;
67
+ if (live && live.firstTokenMs == null) label = "waiting\u2026";
68
+ else if (tps != null) label = tps + " tok/s \u00B7 " + (latency != null ? (latency / 1000).toFixed(1) + "s" : "\u2014") + " first";
69
+ else label = "\u2014";
70
+ let color: string;
71
+ if (tps == null) color = ui.fgMuted;
72
+ else if (tps >= 25 && (latency == null || latency <= 2500)) color = ui.success;
73
+ else if (tps >= 8 || (latency != null && latency <= 6000)) color = ui.warning;
74
+ else color = "#ff5555";
75
+ return { label, color };
76
+ }
77
+
78
+ export function Sidebar(props: { show: boolean }) {
79
+ return (
80
+ <Show when={props.show}>
81
+ <box flexDirection="column" width={38}
82
+ backgroundColor={ui.bgPanel} paddingX={1} paddingY={0} flexShrink={0}>
83
+
84
+ <box flexDirection="row" justifyContent="space-between" flexShrink={0}>
85
+ <text fg={ui.primary} height={1} flexShrink={0}>{" Loom Code"}</text>
86
+ <text fg={ui.fgMuted} height={1} flexShrink={0}>{"v" + VERSION}</text>
87
+ </box>
88
+
89
+ <box flexDirection="column" height={skillActive().length > 0 ? 8 : 7} flexShrink={0}>
90
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Provider: "}</text><text fg={ui.primary}>{providerName()}</text></box>
91
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Model: "}</text><text fg={ui.fg}>{modelName()}</text></box>
92
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Speed: "}</text>
93
+ <text fg={ui.fg}>{speedInfo(speedStats()).label}</text>
94
+ </box>
95
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Cost: "}</text>
96
+ <text fg={sessionUsage().cost > 0.02 ? ui.warning : ui.fg}>{formatUsd(sessionUsage().cost)}</text>
97
+ <text fg={budgetLevel() === "auto" ? ui.fgMuted : ui.primary}>{" [" + budgetLevel() + "]"}</text>
98
+ </box>
99
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Messages: "}</text><text fg={ui.fg}>{String(messages().length)}</text></box>
100
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Path: "}</text><text fg={ui.fgDim}>{cwdShort() || "~"}</text></box>
101
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Auto: "}</text>
102
+ <text fg={autoPerm() ? ui.success : ui.fgDim}>{autoPerm() ? "on — no asks" : "off — asks per command"}</text>
103
+ </box>
104
+ {skillActive().length > 0 ? (
105
+ <box flexDirection="row" height={1} flexShrink={0}><text fg={ui.fgMuted}>{"Skills: "}</text><text fg={ui.primary}>{skillActive().join(", ")}</text></box>
106
+ ) : null}
107
+ </box>
108
+
109
+ <box flexDirection="row" gap={1} flexShrink={0}>
110
+ {TAB_NAMES.map((name, i) => (
111
+ <text fg={sidebarTab() === i ? ui.primary : ui.fgMuted} height={1} flexShrink={0}
112
+ onMouseUp={() => setSidebarTab(i)}>
113
+ {" " + name + " "}
114
+ </text>
115
+ ))}
116
+ </box>
117
+
118
+ <Show when={!welcomeTipSeen()}>
119
+ <box flexDirection="column" marginTop={1} paddingX={1} paddingY={0} flexShrink={0}
120
+ border borderStyle="rounded" borderColor={ui.warning} backgroundColor={ui.bgInput}>
121
+ <box flexDirection="row" justifyContent="space-between" flexShrink={0}>
122
+ <text fg={ui.primary} height={1}>{"Welcome"}</text>
123
+ <text fg={ui.fgMuted} height={1} onMouseUp={dismissWelcomeTips}>{" \u2715 "}</text>
124
+ </box>
125
+ {(() => {
126
+ let n = 0;
127
+ try { n = Object.keys(require("../../providers/index.js").PROVIDERS).length; } catch {}
128
+ return (
129
+ <box flexDirection="column" flexShrink={0}>
130
+ <text fg={ui.fgDim} width={34}>{"Loom supports " + n + " providers \u2014 /connect to add a key"}</text>
131
+ <text fg={ui.fgDim} width={34}>{"\u00B7 /models lists them all once a key is set"}</text>
132
+ </box>
133
+ );
134
+ })()}
135
+ </box>
136
+ </Show>
137
+
138
+ <box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
139
+ <scrollbox flexGrow={1} stickyScroll>
140
+ <Show when={TAB_NAMES[sidebarTab()] === "Todos"}>
141
+ <Show
142
+ when={todos().length > 0}
143
+ fallback={<text fg={ui.fgMuted}>{"No tasks -- [ ] [x] [+] in replies"}</text>}
144
+ >
145
+ {todos().map((td, i) => {
146
+ if (i < todos().length - 10) return null;
147
+ // OpenTUI TextNode only accepts strings/StyledText — nested
148
+ // <text> elements inside <text> crash the renderer, so the
149
+ // marker+text is built as one string with a row color. width
150
+ // is explicit: without it the native yoga layout shrinks the
151
+ // text nodes and the rows wrap/overlap (garbled glyphs).
152
+ const mark = td.inProgress ? "[+] " : td.done ? "[x] " : td.cancelled ? "[-] " : "[ ] ";
153
+ const color = td.inProgress ? ui.warning : td.done ? ui.success : td.cancelled ? ui.fgMuted : ui.fgDim;
154
+ return (
155
+ <text width={34} fg={color} onMouseUp={() => toggleTodoAt(i)}>
156
+ {mark + td.text}
157
+ </text>
158
+ );
159
+ })}
160
+ </Show>
161
+ </Show>
162
+ <Show when={TAB_NAMES[sidebarTab()] === "Files"}>
163
+ {getProjectFiles().slice(0, 20).map(f => (
164
+ <text width={34} fg={ui.fgDim} onMouseUp={() => {
165
+ openWithDefault(f);
166
+ showToast("Opened " + f, "ok", 2200);
167
+ }}>{f}</text>
168
+ ))}
169
+ </Show>
170
+ <Show when={TAB_NAMES[sidebarTab()] === "Info"}>
171
+ <text fg={ui.fgDim}>{"ctrl+b toggle sidebar, esc interrupt"}</text>
172
+ </Show>
173
+ </scrollbox>
174
+ </box>
175
+
176
+ <box paddingTop={1} flexShrink={0}>
177
+ <text fg={ui.fgMuted} height={1} flexShrink={0}>{"~/" + cwdShort()}</text>
178
+ </box>
179
+ </box>
180
+ </Show>
181
+ );
182
+ }
@@ -0,0 +1,51 @@
1
+ // Splash screen -- logo + embedded InputBar when no messages yet. Signal reads in JSX.
2
+ import { palette, LOOM_LOGO } from "../theme.ts";
3
+ import { providerName, modelName, providerKeyOk } from "../store.ts";
4
+ import { InputBar } from "./InputBar.tsx";
5
+
6
+ export function SplashScreen() {
7
+ const ui = palette("loom");
8
+
9
+ return (
10
+ // Centered like opencode: the whole block (logo + status + chatbox)
11
+ // floats mid-screen and grows symmetrically around its center as the
12
+ // chatbox lines up.
13
+ <box flexDirection="column" flexGrow={1} alignItems="center" justifyContent="center" backgroundColor={ui.bg}>
14
+ <box flexGrow={1} />
15
+
16
+ <box flexDirection="column" alignItems="center" marginBottom={1}>
17
+ {LOOM_LOGO.map((line, i) => (
18
+ <text fg={ui.primary}>{line}</text>
19
+ ))}
20
+ </box>
21
+
22
+ <box marginTop={2} flexDirection="row">
23
+ <text fg={ui.primary}>{"Build"}</text>
24
+ <text fg={ui.fgDim}>{" "}</text>
25
+ <text fg={ui.fg}>{providerName()}</text>
26
+ <text fg={ui.fgDim}>{" "}</text>
27
+ <text fg={ui.fg}>{modelName()}</text>
28
+ <text fg={ui.fgDim}>{" "}</text>
29
+ <text fg={providerKeyOk() ? "green" : "yellow"}>{providerKeyOk() ? "connected" : "no key"}</text>
30
+ </box>
31
+
32
+ <box marginTop={0}>
33
+ <text fg={ui.fgMuted}>{"v1.1.0"}</text>
34
+ </box>
35
+
36
+ <box marginY={1} width={74}>
37
+ <InputBar />
38
+ </box>
39
+
40
+ <box flexDirection="column" alignItems="center">
41
+ <box flexDirection="row">
42
+ <text fg={ui.warning}>{"Tip: "}</text>
43
+ <text fg={ui.fgDim}>{"Type /help @ for files ! for shell tab to cycle mode"}</text>
44
+ </box>
45
+ <text fg={ui.fgMuted}>{"ctrl+p palette ctrl+b sidebar esc interrupt"}</text>
46
+ </box>
47
+
48
+ <box flexGrow={1} />
49
+ </box>
50
+ );
51
+ }