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,824 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="loom">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark light">
7
+ <meta name="description" content="Loom Code — a terminal-native AI coding agent, in the browser.">
8
+ <title>Loom Code</title>
9
+ <style>
10
+ /* ── Design tokens (Loom brand: warm charcoal + terracotta) ─────────────────── */
11
+ :root {
12
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
13
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Inter, Arial, sans-serif;
14
+
15
+ --fs-xs: .6875rem; --fs-sm: .8125rem; --fs-base: .9375rem; --fs-md: 1rem;
16
+ --fs-lg: 1.125rem; --fs-xl: 1.375rem; --fs-2xl: 1.75rem;
17
+
18
+ --r-xs: 3px; --r-sm: 5px; --r-md: 8px; --r-lg: 12px; --r-pill: 999px;
19
+ --sp: 4px; /* 4px base scale */
20
+ --space-1: calc(var(--sp)*1); --space-2: calc(var(--sp)*2); --space-3: calc(var(--sp)*3);
21
+ --space-4: calc(var(--sp)*4); --space-5: calc(var(--sp)*5); --space-6: calc(var(--sp)*6);
22
+ --space-8: calc(var(--sp)*8); --space-10: calc(var(--sp)*10);
23
+
24
+ --ease: cubic-bezier(.2,.7,.2,1);
25
+ --dur: 160ms;
26
+ }
27
+ :root[data-theme="loom"] {
28
+ --bg:#191817; --bg-panel:#211f1e; --bg-panel2:#2a2725; --bg-input:#25221f; --bg-hover:#322e2a; --bg-msg:#131211; --bg-code:#131211;
29
+ --border: color-mix(in srgb, var(--bg-panel2) 72%, #000 0%); --border-strong:#5a534d; --rail:#4a4440;
30
+ --fg:#f0ebe4; --fg-dim:#a89e93; --fg-muted:#6e655c;
31
+ --primary:#e07856; --primary-soft:#f4a27f; --accent:#e8b06f; --secondary:#b8d0a8;
32
+ --success:#a3c084; --warning:#e0b356; --error:#e06c5a; --thinking:#d9a35f;
33
+ --glow: 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
34
+ --btn-fg:#1a1715;
35
+ }
36
+ :root[data-theme="light"] {
37
+ --bg:#faf7f2; --bg-panel:#f3eee6; --bg-panel2:#eae3d8; --bg-input:#ffffff; --bg-hover:#e7ddd0; --bg-msg:#efe9df; --bg-code:#efe9df;
38
+ --border:#c9bfae; --border-strong:#b6a994; --rail:#c9bfae;
39
+ --fg:#2b2620; --fg-dim:#6f675c; --fg-muted:#9a9082;
40
+ --primary:#c2593a; --primary-soft:#a34a2f; --accent:#b0742f; --secondary:#5c7a4a;
41
+ --success:#5f8a3d; --warning:#a97a1a; --error:#b33c2e; --thinking:#a5682a;
42
+ --glow: 0 0 0 1px color-mix(in srgb, var(--primary) 55%, transparent), 0 0 0 4px color-mix(in srgb, var(--primary) 16%, transparent);
43
+ --btn-fg:#fefdfb;
44
+ }
45
+
46
+ /* ── Reset & base ───────────────────────────────────────────────────────────── */
47
+ *, *::before, *::after { box-sizing: border-box; }
48
+ html, body { height: 100%; }
49
+ body {
50
+ margin: 0; color: var(--fg); background: var(--bg);
51
+ font-family: var(--sans); font-size: var(--fs-base); line-height: 1.55;
52
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
53
+ }
54
+ h1,h2,h3,h4 { line-height: 1.25; margin: 0 0 var(--space-2); font-weight: 650; }
55
+ p { margin: 0 0 var(--space-2); }
56
+ a { color: var(--primary-soft); text-decoration: none; }
57
+ a:hover { text-decoration: underline; text-underline-offset: 2px; }
58
+ button { font: inherit; color: inherit; }
59
+ :focus-visible { outline: none; box-shadow: var(--glow); border-radius: var(--r-sm); }
60
+ ::selection { background: color-mix(in srgb, var(--primary) 35%, transparent); }
61
+ @media (prefers-reduced-motion: reduce) {
62
+ *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
63
+ }
64
+
65
+ /* ── App shell ──────────────────────────────────────────────────────────────── */
66
+ .app-root { display: grid; grid-template-rows: auto 1fr; height: 100vh; height: 100dvh; overflow: hidden; }
67
+
68
+ /* Status row (thesis — a terminal status line, not a generic header) */
69
+ .statusbar {
70
+ display: flex; align-items: center; gap: var(--space-4);
71
+ padding: 0 var(--space-4); height: 44px; min-height: 44px;
72
+ background: var(--bg-panel); border-bottom: 1px solid var(--border);
73
+ font-family: var(--mono); font-size: var(--fs-xs);
74
+ -webkit-app-region: no-drag; position: relative; z-index: 30;
75
+ }
76
+ .wordmark { display: inline-flex; align-items: baseline; gap: 3px; font-weight: 700; letter-spacing: -.02em; font-size: var(--fs-sm); margin-right: var(--space-2); }
77
+ .wordmark .dot { color: var(--primary); }
78
+ .wordmark .ver { color: var(--fg-muted); font-weight: 500; font-size: var(--fs-xs); letter-spacing: 0; }
79
+ .status-mid { display: flex; align-items: center; gap: var(--space-3); color: var(--fg-dim); flex: 1; min-width: 0; overflow: hidden; }
80
+ .status-mid .pipe { color: var(--border-strong); }
81
+ .status-mid .prov { color: var(--primary); font-weight: 600; }
82
+ .status-mid .model { color: var(--fg); }
83
+ .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; border: 1px solid var(--border); border-radius: var(--r-pill); color: var(--fg-dim); font-size: var(--fs-xs); }
84
+ .chip.mode-build { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 40%, transparent); }
85
+ .chip.mode-plan { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
86
+ .chip.mode-chat { color: var(--secondary); border-color: color-mix(in srgb, var(--secondary) 40%, transparent); }
87
+ .live { display: none; align-items: center; gap: 6px; color: var(--fg-dim); }
88
+ .live.on { display: inline-flex; }
89
+ .live b { color: var(--primary); font-weight: 600; }
90
+ .live .pulse { width: 7px; height: 7px; border-radius: 999px; background: var(--primary); animation: pulse 1s var(--ease) infinite; }
91
+ @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.7); } }
92
+ .status-actions { display: flex; align-items: center; gap: var(--space-2); }
93
+
94
+ /* Buttons */
95
+ .btn { display: inline-flex; align-items: center; gap: 6px; justify-content: center;
96
+ border: 1px solid var(--border); background: var(--bg-panel2); color: var(--fg);
97
+ padding: 6px 11px; border-radius: var(--r-sm); cursor: pointer;
98
+ font-size: var(--fs-sm); font-family: var(--mono); transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease), color var(--dur) var(--ease); }
99
+ .btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
100
+ .btn.primary { background: var(--primary); border-color: color-mix(in srgb, var(--primary) 85%, #000); color: var(--btn-fg); font-weight: 600; }
101
+ .btn.primary:hover { background: color-mix(in srgb, var(--primary) 90%, #000); }
102
+ .btn.ghost { background: transparent; border-color: transparent; color: var(--fg-dim); }
103
+ .btn.ghost:hover { background: var(--bg-hover); color: var(--fg); }
104
+ .btn.icon { padding: 6px; width: 32px; height: 32px; }
105
+ .btn.danger { color: var(--error); border-color: color-mix(in srgb, var(--error) 45%, transparent); }
106
+ .btn.danger:hover { background: color-mix(in srgb, var(--error) 14%, transparent); }
107
+ .btn[disabled] { opacity: .45; cursor: not-allowed; }
108
+ .btn.icon svg { width: 16px; height: 16px; display: block; }
109
+ .menu-btn { display: none; }
110
+
111
+ /* Body split */
112
+ .split { display: grid; grid-template-columns: 288px 1fr; min-height: 0; }
113
+
114
+ /* Sessions rail */
115
+ .rail { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
116
+ .rail-head { padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3); border-bottom: 1px solid var(--border); }
117
+ .rail-search { position: relative; }
118
+ .rail-search input {
119
+ width: 100%; padding: 7px 10px 7px 30px; background: var(--bg-input); border: 1px solid var(--border); color: var(--fg);
120
+ border-radius: var(--r-sm); font: var(--fs-sm) var(--mono);
121
+ }
122
+ .rail-search input::placeholder { color: var(--fg-muted); }
123
+ .rail-search input:focus { outline: none; border-color: var(--primary); box-shadow: var(--glow); border-radius: var(--r-sm); }
124
+ .rail-search .s-ico { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--fg-muted); pointer-events: none; }
125
+ .rail-list { flex: 1; overflow-y: auto; padding: var(--space-2); }
126
+ .rail-section { font-family: var(--mono); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .08em; color: var(--fg-muted); margin: var(--space-3) var(--space-2) var(--space-2); }
127
+ .session-row { display: grid; grid-template-columns: 2px 1fr auto; gap: var(--space-3); align-items: start;
128
+ padding: var(--space-2) var(--space-2) var(--space-2) 0; border-radius: var(--r-sm); cursor: pointer; position: relative; }
129
+ .session-row:hover { background: var(--bg-hover); }
130
+ .session-row.active { background: var(--bg-hover); }
131
+ .session-row.active::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px; background: var(--primary); border-radius: var(--r-pill); }
132
+ .session-row .wrap { min-width: 0; }
133
+ .session-row .id { font-family: var(--mono); font-size: var(--fs-sm); color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
134
+ .session-row .meta { font-family: var(--mono); font-size: var(--fs-xs); color: var(--fg-muted); margin-top: 2px; }
135
+ .session-row .badge { font-family: var(--mono); font-size: 10px; color: var(--fg-dim); padding: 1px 5px; border: 1px solid var(--border); border-radius: var(--r-pill); white-space: nowrap; }
136
+ .session-row .badge.active { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, transparent); align-self: center; }
137
+ .rail-foot { padding: var(--space-2); border-top: 1px solid var(--border); display: flex; gap: var(--space-2); }
138
+ .rail-foot .btn { flex: 1; }
139
+
140
+ /* Session view */
141
+ .session-view { display: flex; flex-direction: column; min-width: 0; min-height: 0; position: relative; background: var(--bg); }
142
+ .session-bar { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-4); border-bottom: 1px solid var(--border); background: var(--bg-panel); font-family: var(--mono); font-size: var(--fs-xs); color: var(--fg-dim); }
143
+ .session-bar .sid { color: var(--fg); }
144
+ .session-bar .sp { color: var(--border-strong); }
145
+ .session-bar .ml { color: var(--fg-dim); }
146
+ .session-bar .close { margin-left: auto; }
147
+
148
+ /* Transcript — turns along a thread rail */
149
+ .transcript { flex: 1; overflow-y: auto; scroll-behavior: smooth; position: relative; padding: var(--space-6) var(--space-4) var(--space-8); }
150
+ .transcript-stream { max-width: 880px; margin: 0 auto; position: relative; padding-left: 34px; }
151
+ .transcript-stream::before { content: ""; position: absolute; left: 13px; top: 4px; bottom: 4px; width: 2px; background: var(--rail); opacity: .55; border-radius: var(--r-pill); }
152
+ .turn { position: relative; padding: var(--space-2) 0 var(--space-5); }
153
+ .turn::before { content: ""; position: absolute; left: -26px; top: 10px; width: 10px; height: 10px; border-radius: 999px; background: var(--bg); border: 2px solid var(--rail); }
154
+ .turn.user::before { border-color: var(--secondary); }
155
+ .turn.loom::before { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 25%, var(--bg)); }
156
+ .role { font-family: var(--mono); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em; color: var(--fg-muted); margin-bottom: var(--space-1); display: flex; align-items: center; gap: 6px; }
157
+ .role .glyph { color: var(--primary); }
158
+ .turn.user .role .glyph { color: var(--secondary); }
159
+ .turn .content { font-size: var(--fs-base); color: var(--fg); max-width: 76ch; }
160
+ .turn .content p:last-child { margin-bottom: 0; }
161
+
162
+ /* Markdown elements */
163
+ .turn .content h2 { font-size: var(--fs-lg); margin-top: var(--space-4); }
164
+ .turn .content h3 { font-size: var(--fs-md); margin-top: var(--space-3); }
165
+ .turn .content h4 { font-size: var(--fs-base); margin-top: var(--space-3); color: var(--fg-dim); }
166
+ .turn .content ul, .turn .content ol { margin: var(--space-2) 0 var(--space-2) var(--space-5); padding: 0; }
167
+ .turn .content li { margin: 3px 0; }
168
+ .turn .content blockquote { margin: var(--space-2) 0; padding: 2px 0 2px var(--space-3); border-left: 2px solid var(--border-strong); color: var(--fg-dim); }
169
+ .turn .content code { font-family: var(--mono); font-size: .85em; background: var(--bg-code); border: 1px solid var(--border); padding: 1px 5px; border-radius: var(--r-xs); }
170
+ pre.code { position: relative; margin: var(--space-3) 0; background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--r-sm); overflow: hidden; }
171
+ pre.code code { display: block; font-family: var(--mono); font-size: var(--fs-sm); padding: var(--space-3); overflow-x: auto; border: 0; background: transparent; }
172
+ pre.code .copy { position: absolute; top: 6px; right: 6px; font-family: var(--mono); font-size: var(--fs-xs); color: var(--fg-muted);
173
+ background: var(--bg-panel2); border: 1px solid var(--border); border-radius: var(--r-xs); padding: 2px 7px; cursor: pointer; opacity: 0; transition: opacity var(--dur) var(--ease); }
174
+ pre.code:hover .copy { opacity: 1; }
175
+ pre.code .copy:hover { color: var(--primary); border-color: var(--primary); }
176
+
177
+ /* Tool chips */
178
+ .tools { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-2) 0; }
179
+ .tool { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--r-sm); font-family: var(--mono); font-size: var(--fs-xs); }
180
+ .tool summary { cursor: pointer; list-style: none; padding: 6px 10px; display: flex; align-items: center; gap: 8px; color: var(--fg-dim); }
181
+ .tool summary::-webkit-details-marker { display: none; }
182
+ .tool summary .tn { color: var(--accent); font-weight: 600; }
183
+ .tool summary .tr { color: var(--fg-muted); margin-left: auto; font-size: 10px; }
184
+ .tool summary .arr { color: var(--fg-muted); transition: transform var(--dur) var(--ease); }
185
+ .tool[open] summary .arr { transform: rotate(90deg); }
186
+ .tool .tres { padding: 0 var(--space-3) var(--space-3); border-top: 1px solid var(--border); color: var(--fg-dim); white-space: pre-wrap; word-break: break-word; }
187
+
188
+ .reasoning { font-style: italic; color: var(--fg-muted); border-left: 2px solid color-mix(in srgb, var(--thinking) 50%, transparent); padding: var(--space-1) var(--space-3); margin: var(--space-2) 0; font-size: var(--fs-sm); }
189
+ .err-line { color: var(--error); border-left: 2px solid var(--error); padding: var(--space-1) var(--space-3); margin: var(--space-2) 0; font-size: var(--fs-sm); }
190
+ .caret::after { content: "▍"; color: var(--primary); animation: blink 1s step-start infinite; margin-left: 1px; }
191
+ @keyframes blink { 50% { opacity: 0; } }
192
+
193
+ /* Empty state (thesis first-run) */
194
+ .empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: var(--space-8); gap: var(--space-4); }
195
+ .empty .logo { font-family: var(--mono); color: var(--primary-soft); font-size: clamp(8px, 2.4vw, 12px); line-height: 1.15; white-space: pre; text-shadow: 0 0 18px color-mix(in srgb, var(--primary) 25%, transparent); }
196
+ .empty h1 { font-family: var(--sans); font-size: var(--fs-xl); font-weight: 650; letter-spacing: -.01em; }
197
+ .empty p { color: var(--fg-dim); max-width: 42ch; margin: 0; }
198
+ .empty .hints { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: center; max-width: 560px; }
199
+ .empty .hint { font-family: var(--mono); font-size: var(--fs-sm); color: var(--fg-dim); border: 1px solid var(--border); background: var(--bg-panel); padding: 8px 12px; border-radius: var(--r-md); cursor: pointer; transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), background var(--dur) var(--ease); }
200
+ .empty .hint:hover { border-color: var(--primary); color: var(--fg); background: var(--bg-hover); }
201
+ .empty .hint .sl { color: var(--primary); }
202
+
203
+ /* Composer */
204
+ .composer { border-top: 1px solid var(--border); background: var(--bg-panel); padding: var(--space-3) var(--space-4); }
205
+ .composer-inner { max-width: 880px; margin: 0 auto; padding-left: 34px; }
206
+ .composer-controls { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
207
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--r-pill); padding: 2px; background: var(--bg-input); }
208
+ .seg button { font-family: var(--mono); font-size: var(--fs-xs); border: 0; background: transparent; color: var(--fg-dim); padding: 4px 11px; border-radius: var(--r-pill); cursor: pointer; text-transform: uppercase; letter-spacing: .06em; }
209
+ .seg button.active.build { background: color-mix(in srgb, var(--primary) 88%, #000); color: var(--btn-fg); }
210
+ .seg button.active.plan { background: color-mix(in srgb, var(--accent) 85%, #000); color: #1a1715; }
211
+ .seg button.active.chat { background: color-mix(in srgb, var(--secondary) 80%, #000); color: #14140f; }
212
+ .seg button[disabled] { opacity: .4; cursor: not-allowed; }
213
+ .composer-hint { margin-left: auto; font-family: var(--mono); font-size: var(--fs-xs); color: var(--fg-muted); }
214
+ .composer-row { display: grid; grid-template-columns: 1fr auto; gap: var(--space-2); align-items: end; }
215
+ #input { width: 100%; resize: none; background: var(--bg-input); color: var(--fg); border: 1px solid var(--border); border-radius: var(--r-md); padding: 12px 14px; font: var(--fs-base) var(--sans); line-height: 1.5; max-height: 220px; overflow-y: auto; }
216
+ #input:focus { outline: none; border-color: var(--primary); box-shadow: var(--glow); }
217
+ #input::placeholder { color: var(--fg-muted); }
218
+ .send-col { display: flex; flex-direction: column; gap: var(--space-2); }
219
+ #sendBtn { height: 44px; }
220
+ #stopBtn { display: none; height: 40px; }
221
+
222
+ /* Auth overlay */
223
+ .overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; z-index: 100; }
224
+ .auth-card { width: min(360px, 92vw); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--r-lg); padding: var(--space-8) var(--space-6) var(--space-6); box-shadow: 0 10px 40px rgba(0,0,0,.35); }
225
+ .auth-card .wordmark { font-size: var(--fs-md); display: flex; justify-content: center; margin-bottom: var(--space-3); }
226
+ .auth-card p { text-align: center; color: var(--fg-dim); font-family: var(--mono); font-size: var(--fs-sm); margin-bottom: var(--space-4); }
227
+ .auth-card input { width: 100%; background: var(--bg-input); border: 1px solid var(--border); color: var(--fg); border-radius: var(--r-sm); padding: 10px 12px; margin-bottom: var(--space-3); font: var(--fs-sm) var(--mono); }
228
+ .auth-card input:focus { outline: none; border-color: var(--primary); box-shadow: var(--glow); }
229
+ #loginErr { color: var(--error); font-size: var(--fs-sm); min-height: 1.2em; text-align: center; margin-bottom: var(--space-2); }
230
+
231
+ /* Modal (servers) */
232
+ .modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 90; padding: var(--space-4); }
233
+ .modal { width: min(560px, 96vw); max-height: 80vh; display: flex; flex-direction: column; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: 0 12px 48px rgba(0,0,0,.4); }
234
+ .modal-h { display: flex; align-items: center; padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--border); }
235
+ .modal-h h2 { margin: 0; font-size: var(--fs-md); }
236
+ .modal-b { padding: var(--space-4) var(--space-5); overflow-y: auto; }
237
+ .srv { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--r-sm); margin-bottom: var(--space-2); font-family: var(--mono); font-size: var(--fs-sm); }
238
+ .srv .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--success); flex-shrink: 0; }
239
+ .srv.off .dot { background: var(--fg-muted); }
240
+ .srv .nm { color: var(--fg); font-weight: 600; }
241
+ .srv .cmd { color: var(--fg-muted); font-size: var(--fs-xs); margin-left: auto; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
242
+ .srv .cmd code { color: var(--fg-dim); }
243
+ .muted { color: var(--fg-muted); }
244
+ .skel { height: 42px; border-radius: var(--r-sm); background: var(--bg-panel2); position: relative; overflow: hidden; margin-bottom: var(--space-2); }
245
+ .skel::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--fg) 8%, transparent), transparent); animation: shimmer 1.4s infinite; }
246
+ @keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
247
+
248
+ /* Toast */
249
+ .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--bg-panel2); color: var(--fg); border: 1px solid var(--border-strong); border-radius: var(--r-md); padding: 8px 14px; font-family: var(--mono); font-size: var(--fs-sm); z-index: 120; box-shadow: 0 6px 24px rgba(0,0,0,.4); opacity: 0; transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); pointer-events: none; }
250
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
251
+ .hidden { display: none !important; }
252
+
253
+ /* Responsive */
254
+ @media (max-width: 1024px) {
255
+ .split { grid-template-columns: 1fr; }
256
+ .rail { position: fixed; top: 44px; bottom: 0; left: 0; width: 288px; transform: translateX(-100%); transition: transform var(--dur) var(--ease); z-index: 40; box-shadow: 8px 0 32px rgba(0,0,0,.3); }
257
+ .rail.open { transform: translateX(0); }
258
+ .menu-btn { display: inline-flex; }
259
+ .transcript-stream { padding-left: 26px; }
260
+ .transcript-stream::before { left: 9px; }
261
+ .composer-inner { padding-left: 26px; }
262
+ .turn::before { left: -22px; }
263
+ .status-mid .model { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
264
+ }
265
+ @media (max-width: 560px) {
266
+ .status-mid .pipe:not(:first-child), .status-mid .budget { display: none; }
267
+ .statusbar { padding: 0 var(--space-3); gap: var(--space-2); }
268
+ .empty .logo { font-size: 8px; }
269
+ }
270
+ </style>
271
+ </head>
272
+ <body>
273
+ <div class="app-root">
274
+
275
+ <!-- Status row -->
276
+ <header class="statusbar" role="banner">
277
+ <button class="btn ghost icon menu-btn" id="menuBtn" aria-label="Toggle sessions">
278
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
279
+ </button>
280
+ <span class="wordmark">loom<span class="dot">.</span><span class="ver" id="verBadge"></span></span>
281
+
282
+ <div class="status-mid" aria-live="off">
283
+ <span><span class="prov" id="cfgProv">—</span><span class="pipe"> · </span><span class="model" id="cfgModel">loading</span></span>
284
+ <span class="pipe" id="cfgPipe2"> · </span>
285
+ <span class="chip budget mode-build" id="cfgBudget">auto</span>
286
+ </div>
287
+
288
+ <span class="live" id="live" aria-hidden="true">
289
+ <span class="pulse"></span>
290
+ <span>working · <b id="liveRate">0c</b></span>
291
+ </span>
292
+
293
+ <div class="status-actions">
294
+ <button class="btn ghost icon" id="themeBtn" aria-label="Toggle theme" title="Switch theme">
295
+ <svg id="themeIco" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
296
+ </button>
297
+ <button class="btn" id="serversBtn">
298
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="3" y="4" width="18" height="6" rx="1"/><rect x="3" y="14" width="18" height="6" rx="1"/></svg>
299
+ Servers
300
+ </button>
301
+ <button class="btn primary" id="newBtn">
302
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="15" height="15"><path d="M12 5v14M5 12h14"/></svg>
303
+ New
304
+ </button>
305
+ </div>
306
+ </header>
307
+
308
+ <!-- Split -->
309
+ <div class="split" id="split">
310
+
311
+ <!-- Sessions rail -->
312
+ <aside class="rail" id="rail" aria-label="Saved sessions">
313
+ <div class="rail-head">
314
+ <div class="rail-search">
315
+ <span class="s-ico">
316
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
317
+ </span>
318
+ <input id="search" type="search" placeholder="Filter sessions…" aria-label="Filter sessions">
319
+ </div>
320
+ <div class="rail-section" style="margin:0">Threads</div>
321
+ </div>
322
+ <div class="rail-list" id="railList" aria-busy="false"></div>
323
+ <div class="rail-foot">
324
+ <button class="btn ghost" id="themeBtn2">Theme</button>
325
+ </div>
326
+ </aside>
327
+
328
+ <!-- Session view -->
329
+ <section class="session-view" id="sessionView" aria-live="off">
330
+ <!-- session bar -->
331
+ <div class="session-bar hidden" id="sessionBar">
332
+ <span class="sid" id="barId">—</span>
333
+ <span class="sp">·</span>
334
+ <span id="barWhen">—</span>
335
+ <span class="sp">·</span>
336
+ <span class="ml" id="barCount">0 messages</span>
337
+ <button class="btn ghost close" id="closeBtn" aria-label="Back to sessions" title="Back to sessions">
338
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M18 6 6 18M6 6l12 12"/></svg>
339
+ </button>
340
+ </div>
341
+
342
+ <!-- transcript -->
343
+ <div class="transcript" id="transcript" tabindex="0" aria-label="Conversation transcript">
344
+ <!-- empty state -->
345
+ <div class="empty" id="empty">
346
+ <pre class="logo"> ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
347
+ ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
348
+ ██║ ██║ ██║██║ ██║██╔████╔██║ ██║ ██║ ██║██║ ██║█████╗
349
+ ██║ ██║ ██║██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██╔══╝
350
+ ███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║ ╚██████╗██████╔╝██████╔╝███████╗
351
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═════╝ ╚═════╝ ╚══════╝</pre>
352
+ <h1>Start a thread</h1>
353
+ <p>Loom Code runs your tools, providers, and saved sessions — here in the browser. Send a message below, or resume a thread from the rail.</p>
354
+ <div class="hints" id="hints">
355
+ <button class="hint" data-q="Explain what this project does"><span class="sl">/</span>Explain this project</button>
356
+ <button class="hint" data-q="List the files in src/"><span class="sl">/</span>List files in src</button>
357
+ <button class="hint" data-q="Find and summarize any open TODOs"><span class="sl">/</span>Find TODOs</button>
358
+ </div>
359
+ </div>
360
+ <div class="transcript-stream hidden" id="stream"></div>
361
+ </div>
362
+
363
+ <!-- composer -->
364
+ <div class="composer" id="composer">
365
+ <div class="composer-inner">
366
+ <div class="composer-controls">
367
+ <div class="seg" id="modeSeg" role="group" aria-label="Mode for new chats">
368
+ <button data-mode="build" class="build active">Build</button>
369
+ <button data-mode="plan" class="plan">Plan</button>
370
+ <button data-mode="chat" class="chat">Chat</button>
371
+ </div>
372
+ <span class="composer-hint" id="composerHint">Enter to send · Shift+Enter newline · Esc to stop</span>
373
+ </div>
374
+ <div class="composer-row">
375
+ <textarea id="input" rows="1" placeholder="Message Loom…" aria-label="Message input"></textarea>
376
+ <div class="send-col">
377
+ <button class="btn primary" id="sendBtn" aria-label="Send message">Send</button>
378
+ <button class="btn danger" id="stopBtn" aria-label="Stop">Stop</button>
379
+ </div>
380
+ </div>
381
+ </div>
382
+ </div>
383
+ </section>
384
+ </div>
385
+ </div>
386
+
387
+ <!-- Auth overlay -->
388
+ <div class="overlay hidden" id="authOverlay">
389
+ <form class="auth-card" id="authForm">
390
+ <div class="wordmark">loom<span class="dot">.</span></div>
391
+ <p>This server is password-protected.</p>
392
+ <input id="loginUser" placeholder="Username" autocomplete="username" aria-label="Username">
393
+ <input id="loginPass" type="password" placeholder="Password" autocomplete="current-password" aria-label="Password">
394
+ <div id="loginErr"></div>
395
+ <button class="btn primary" type="submit" style="width:100%">Log in</button>
396
+ </form>
397
+ </div>
398
+
399
+ <!-- Servers modal -->
400
+ <div class="modal-bg hidden" id="serversModal">
401
+ <div class="modal" role="dialog" aria-label="Connected servers" aria-modal="true">
402
+ <div class="modal-h"><h2>Connected servers</h2>
403
+ <button class="btn ghost icon" id="serversClose" aria-label="Close" style="margin-left:auto">
404
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 6 6 18M6 6l12 12"/></svg>
405
+ </button>
406
+ </div>
407
+ <div class="modal-b" id="serversList"></div>
408
+ </div>
409
+ </div>
410
+
411
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
412
+
413
+ <script>
414
+ (function () {
415
+ "use strict";
416
+ const $ = (id) => document.getElementById(id);
417
+ const store = (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch {} return v; };
418
+ const load = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } };
419
+
420
+ const state = { view: "home", id: null, messages: [], busy: false, sessions: [], filter: "", mode: "build", cfg: null };
421
+
422
+ // ── HTTP ──────────────────────────────────────────────────────────────────
423
+ async function api(path, options) {
424
+ const res = await fetch(path, options);
425
+ if (res.status === 401) { showAuth(); throw Object.assign(new Error("unauthorized"), { unauthorized: true }); }
426
+ const ct = res.headers.get("content-type") || "";
427
+ const data = ct.includes("json") ? await res.json().catch(() => ({})) : await res.text();
428
+ if (!res.ok) throw new Error(data && data.error ? data.error : res.statusText);
429
+ return data;
430
+ }
431
+ const post = (p, body) => api(p, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
432
+
433
+ function toast(msg) {
434
+ const t = $("toast"); t.textContent = msg; t.classList.add("show");
435
+ clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove("show"), 1800);
436
+ }
437
+ function relTime(iso) {
438
+ if (!iso) return "—";
439
+ const d = new Date(iso), s = (Date.now() - d.getTime()) / 1000;
440
+ if (s < 60) return "just now";
441
+ if (s < 3600) return Math.floor(s / 60) + "m ago";
442
+ if (s < 86400) return Math.floor(s / 3600) + "h ago";
443
+ if (s < 604800) return Math.floor(s / 86400) + "d ago";
444
+ return d.toLocaleDateString();
445
+ }
446
+ // escape-first HTML; entities built by concatenation so they survive un-mangled
447
+ function esc(s) {
448
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
449
+ return c === "&" ? "&" + "amp;" : c === "<" ? "&" + "lt;" : c === ">" ? "&" + "gt;" : c === '"' ? "&" + "quot;" : "&" + "#39;";
450
+ });
451
+ }
452
+
453
+ // ── Markdown (escape-first, code-protected) ────────────────────────────────
454
+ function md(src) {
455
+ const slots = [];
456
+ const stash = (html) => { slots.push(html); return "\u0000S" + (slots.length - 1) + "\u0000"; };
457
+ src = String(src || "");
458
+ let s = src.replace(/```(\w+)?\n([\s\S]*?)```/g, (m, lang, code) => {
459
+ return stash(`<pre class="code"><button class="copy" data-copy aria-label="Copy code">copy</button><code>${esc(code.replace(/\n$/, ""))}</code></pre>`);
460
+ });
461
+ s = esc(s);
462
+ s = s.replace(/`([^`\n]+)`/g, (_, c) => stash(`<code>${c}</code>`));
463
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
464
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
465
+ s = s.replace(/_([^_\n]+)_/g, "<em>$1</em>");
466
+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>');
467
+ s = s.replace(/^###\s+(.*)$/gm, "<h4>$1</h4>");
468
+ s = s.replace(/^##\s+(.*)$/gm, "<h3>$1</h3>");
469
+ s = s.replace(/^#\s+(.*)$/gm, "<h2>$1</h2>");
470
+ s = s.replace(/^---$/gm, "<hr>");
471
+ // blockquote: esc() already turned ">" into ">" — match the escaped form
472
+ var GT = String.fromCharCode(38) + "gt;"; // ">"
473
+ s = s.replace(new RegExp("^" + GT + "\\s?(.*)$", "gm"), "<blockquote>$1</blockquote>");
474
+ s = s.replace(/(?:^|\n)((?:[-*]\s+.*(?:\n|$))+)/g, (m, list) => {
475
+ const items = list.trim().split(/\n/).map((l) => "<li>" + l.replace(/^[-*]\s+/, "") + "</li>").join("");
476
+ return stash(`<ul>${items}</ul>`);
477
+ });
478
+ s = s.replace(/(?:^|\n)((?:\d+\.\s+.*(?:\n|$))+)/g, (m, list) => {
479
+ const items = list.trim().split(/\n/).map((l) => "<li>" + l.replace(/^\d+\.\s+/, "") + "</li>").join("");
480
+ return stash(`<ol>${items}</ol>`);
481
+ });
482
+ s = s.split(/\n{2,}/).map((p) => {
483
+ if (/^\u0000S\d+\u0000$/.test(p.trim()) || /^\s*<(h\d|ul|ol|blockquote|hr|pre|table)/.test(p)) return p;
484
+ if (!p.trim()) return "";
485
+ return "<p>" + p.replace(/\n/g, "<br>") + "</p>";
486
+ }).join("");
487
+ s = s.replace(/\u0000S(\d+)\u0000/g, (_, i) => slots[+i]);
488
+ return s;
489
+ }
490
+
491
+ // ── Theme ─────────────────────────────────────────────────────────────────
492
+ function applyTheme(t) {
493
+ document.documentElement.setAttribute("data-theme", t === "light" ? "light" : "loom");
494
+ store("loom.web.theme", t);
495
+ const ico = $("themeIco");
496
+ if (ico) ico.innerHTML = t === "light"
497
+ ? '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'
498
+ : '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
499
+ }
500
+ function toggleTheme() { applyTheme((load("loom.web.theme") || initialTheme()) === "light" ? "loom" : "light"); }
501
+ function initialTheme() {
502
+ const saved = load("loom.web.theme");
503
+ if (saved) return saved;
504
+ return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "loom";
505
+ }
506
+ $("themeBtn").addEventListener("click", toggleTheme);
507
+ $("themeBtn2").addEventListener("click", toggleTheme);
508
+ applyTheme(initialTheme());
509
+
510
+ // ── Auth ──────────────────────────────────────────────────────────────────
511
+ function showAuth() {
512
+ $("authOverlay").classList.remove("hidden");
513
+ $("loginPass").focus();
514
+ }
515
+ async function checkAuth() {
516
+ try { const a = await api("/api/auth"); if (a.required) showAuth(); else boot(); }
517
+ catch (e) { if (e.unauthorized) { /* shown */ } else boot(); }
518
+ }
519
+ $("authForm").addEventListener("submit", async (e) => {
520
+ e.preventDefault(); $("loginErr").textContent = "";
521
+ try {
522
+ await post("/api/auth", { username: $("loginUser").value, password: $("loginPass").value });
523
+ $("authOverlay").classList.add("hidden"); boot();
524
+ } catch (err) { $("loginErr").textContent = err.message || "Login failed"; }
525
+ });
526
+
527
+ // ── Config → status bar ───────────────────────────────────────────────────
528
+ async function loadCfg() {
529
+ try {
530
+ const c = await api("/api/config"); state.cfg = c;
531
+ $("cfgProv").textContent = c.provider || "—";
532
+ $("cfgModel").textContent = c.model || "—";
533
+ $("cfgBudget").textContent = c.budget || "auto";
534
+ $("cfgBadge") || ($("verBadge").textContent = c.version ? "v" + c.version : "");
535
+ $("verBadge").textContent = c.version ? "v" + c.version : "";
536
+ const b = $("cfgBudget"); b.className = "chip budget mode-" + (c.budget && c.budget !== "auto" ? c.budget : "build");
537
+ if (c.budget === "auto") { b.classList.remove("mode-build"); b.textContent = "auto"; }
538
+ } catch {}
539
+ }
540
+
541
+ // ── Sessions rail ─────────────────────────────────────────────────────────
542
+ async function refreshSessions() {
543
+ const list = $("railList"); list.setAttribute("aria-busy", "true");
544
+ try {
545
+ const { sessions } = await api("/api/sessions");
546
+ state.sessions = sessions;
547
+ const f = state.filter.toLowerCase();
548
+ const rows = sessions.filter((s) => !f || (s.id + " " + (s.provider || "") + " " + (s.model || "")).toLowerCase().includes(f));
549
+ list.innerHTML = "";
550
+ if (!rows.length) {
551
+ list.innerHTML = '<div class="muted" style="padding:14px;text-align:center">' + (sessions.length ? "No threads match your filter." : "No threads yet. Start a New chat.") + "</div>";
552
+ } else {
553
+ for (const s of rows) {
554
+ const r = document.createElement("div");
555
+ r.className = "session-row" + (s.id === state.id ? " active" : "");
556
+ r.setAttribute("role", "button"); r.setAttribute("tabindex", "0"); r.dataset.id = s.id;
557
+ r.innerHTML =
558
+ '<span></span>' +
559
+ '<span class="wrap"><span class="id" title="' + esc(s.id) + '">' + esc(s.id) + "</span>" +
560
+ '<span class="meta">' + relTime(s.createdAt) + " · " + (s.messageCount || 0) + " msgs" + (s.model ? " · " + esc(s.model) : "") + "</span></span>" +
561
+ (s.active ? '<span class="badge active">live</span>' : '<span class="badge">' + (s.provider ? esc(s.provider) : "") + "</span>");
562
+ const open = () => openSession(s.id);
563
+ r.addEventListener("click", open);
564
+ r.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } });
565
+ list.appendChild(r);
566
+ }
567
+ }
568
+ } catch (e) { list.innerHTML = '<div class="muted" style="padding:14px">' + esc(e.message) + "</div>"; }
569
+ finally { list.setAttribute("aria-busy", "false"); }
570
+ }
571
+ $("search").addEventListener("input", (e) => { state.filter = e.target.value; refreshSessions(); });
572
+
573
+ // ── Session view ──────────────────────────────────────────────────────────
574
+ function showEmpty(show) {
575
+ $("empty").classList.toggle("hidden", !show);
576
+ $("stream").classList.toggle("hidden", show);
577
+ $("sessionBar").classList.toggle("hidden", show);
578
+ if (show) $("composerHint").textContent = "Enter to send · Shift+Enter newline · Esc to stop";
579
+ }
580
+ async function openSession(id) {
581
+ state.id = id; state.messages = []; state.view = "chat";
582
+ closeRail();
583
+ showEmpty(false);
584
+ $("stream").innerHTML = "";
585
+ $("barId").textContent = id; $("barCount").textContent = "0 messages"; $("barWhen").textContent = "loading";
586
+ try {
587
+ const { session } = await api("/api/sessions/" + id);
588
+ console.log("barWhen date:", new Date(session.createdAt).toLocaleString());
589
+ $("barWhen").textContent = relTime(session.createdAt);
590
+ for (const m of (session.messages || [])) renderMessage(m);
591
+ $("barCount").textContent = (session.messages || []).length + " messages";
592
+ } catch (e) { renderError("Could not load transcript: " + e.message); }
593
+ refreshSessions(); setModeForExisting();
594
+ $("input").focus();
595
+ }
596
+ function setModeForExisting() {
597
+ // mode applies to NEW chats only; disable the control when viewing an existing thread
598
+ document.querySelectorAll("#modeSeg button").forEach((b) => b.disabled = true);
599
+ }
600
+ function setModeForNew() { document.querySelectorAll("#modeSeg button").forEach((b) => b.disabled = false); }
601
+
602
+ async function newChat() {
603
+ history.replaceState && history.replaceState(null, "", "/");
604
+ state.messages = [];
605
+ try {
606
+ const { id } = await post("/api/sessions", { mode: state.mode });
607
+ state.id = id; state.view = "chat";
608
+ showEmpty(false); $("stream").innerHTML = "";
609
+ $("barId").textContent = id; $("barWhen").textContent = "just now"; $("barCount").textContent = "0 messages";
610
+ closeRail(); refreshSessions(); setModeForNew();
611
+ $("input").focus(); showEmpty(true); // start on the empty prompt for this fresh thread
612
+ showEmpty(false);
613
+ } catch (e) { toast("Could not start a new chat: " + e.message); }
614
+ }
615
+ $("newBtn").addEventListener("click", newChat);
616
+ $("closeBtn").addEventListener("click", () => { state.view = "home"; state.id = null; showEmpty(true); refreshSessions(); });
617
+
618
+ document.querySelectorAll("#modeSeg button").forEach((b) => {
619
+ b.addEventListener("click", () => {
620
+ state.mode = b.dataset.mode;
621
+ document.querySelectorAll("#modeSeg button").forEach((x) => x.classList.remove("active"));
622
+ b.classList.add("active");
623
+ });
624
+ });
625
+
626
+ // ── Render messages ───────────────────────────────────────────────────────
627
+ function renderMessage(m) {
628
+ if (m.role === "tool") return;
629
+ const turn = document.createElement("article");
630
+ turn.className = "turn " + (m.role === "user" ? "user" : "loom");
631
+ const role = document.createElement("div");
632
+ role.className = "role";
633
+ role.innerHTML = '<span class="glyph">' + (m.role === "user" ? "»" : "λ") + "</span> " + (m.role === "user" ? "you" : "loom");
634
+ const content = document.createElement("div");
635
+ content.className = "content";
636
+ content.innerHTML = md(m.content || "");
637
+ turn.append(role, content);
638
+ $("stream").appendChild(turn);
639
+ scrollDown();
640
+ }
641
+ function startAssistantTurn() {
642
+ const turn = document.createElement("article");
643
+ turn.className = "turn loom";
644
+ turn.innerHTML = '<div class="role"><span class="glyph">λ</span> loom</div>' +
645
+ '<div class="reasoning hidden" id="curReasoning"></div>' +
646
+ '<div class="tools" id="curTools"></div>' +
647
+ '<div class="content cur"><span class="caret"></span></div>';
648
+ $("stream").appendChild(turn);
649
+ scrollDown();
650
+ return {
651
+ turn,
652
+ content: turn.querySelector(".content"),
653
+ tools: turn.querySelector("#curTools"),
654
+ reason: turn.querySelector("#curReasoning"),
655
+ raw: "", raf: 0,
656
+ };
657
+ }
658
+ function updateContent(cur) {
659
+ cur.content.innerHTML = md(cur.raw) + '<span class="caret"></span>';
660
+ scrollDown();
661
+ }
662
+ function addTool(cur, name, input, result) {
663
+ const d = document.createElement("details");
664
+ d.className = "tool"; if (result == null) d.open = false; else d.open = true;
665
+ const tinput = (() => { try { return JSON.stringify(input); } catch { return ""; } })().slice(0, 160);
666
+ const summary = document.createElement("summary");
667
+ summary.innerHTML = '<span class="arr">▸</span><span class="tn">' + esc(name) + '</span> <span class="tr" style="overflow:hidden;text-overflow:ellipsis;max-width:40ch;white-space:nowrap">' + esc(tinput) + "</span>";
668
+ const res = document.createElement("div");
669
+ res.className = "tres";
670
+ if (result != null) res.textContent = typeof result === "string" ? result : JSON.stringify(result);
671
+ d.append(summary, res);
672
+ cur.tools.appendChild(d);
673
+ scrollDown();
674
+ return { d, res };
675
+ }
676
+ function renderError(msg) {
677
+ const turn = document.createElement("article");
678
+ turn.className = "turn loom";
679
+ turn.innerHTML = '<div class="role"><span class="glyph">λ</span> loom</div><div class="err-line">' + esc(msg) + "</div>";
680
+ $("stream").appendChild(turn); scrollDown();
681
+ }
682
+ function scrollDown() {
683
+ const t = $("transcript"); requestAnimationFrame(() => { t.scrollTop = t.scrollHeight; });
684
+ }
685
+
686
+ // ── Streaming send ───────────────────────────────────────────────────────
687
+ let liveTimer = null, liveStart = 0, liveChars = 0;
688
+ function liveStart_() { liveStart = Date.now(); liveChars = 0; $("live").classList.add("on"); }
689
+ function liveStop() { $("live").classList.remove("on"); $("liveRate").textContent = "0c"; clearInterval(liveTimer); liveTimer = null; }
690
+ function liveTick() {
691
+ const el = (Date.now() - liveStart) / 1000 | 0;
692
+ const rate = el > 0 ? Math.round(liveChars / el) : liveChars;
693
+ $("liveRate").textContent = liveChars + "c · " + el + "s · " + rate + "c/s";
694
+ }
695
+
696
+ async function send(text) {
697
+ if (state.busy || !text.trim()) return;
698
+ if (!state.id) { await newChat(); if (!state.id) return; }
699
+ state.busy = true; setBusy(true);
700
+ if (state.view === "home") showEmpty(false);
701
+ renderMessage({ role: "user", content: text });
702
+ $("input").value = ""; autosize();
703
+ cur = startAssistantTurn();
704
+ lastTurnText = "";
705
+ liveStart_(); liveTimer = setInterval(liveTick, 250);
706
+
707
+ try {
708
+ const res = await fetch("/api/chat", {
709
+ method: "POST", headers: { "Content-Type": "application/json" },
710
+ body: JSON.stringify({ id: state.id, message: text }),
711
+ });
712
+ if (res.status === 401) { showAuth(); return; }
713
+ if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || res.statusText); }
714
+ const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = "";
715
+ for (;;) {
716
+ const { done, value } = await reader.read(); if (done) break;
717
+ buf += dec.decode(value, { stream: true });
718
+ let idx;
719
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
720
+ const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
721
+ const line = raw.split("\n").find((l) => l.startsWith("data: "));
722
+ if (!line) continue;
723
+ let ev; try { ev = JSON.parse(line.slice(6)); } catch { continue; }
724
+ handleEvent(ev);
725
+ }
726
+ }
727
+ } catch (e) { finishTurn("error", e.message || String(e)); }
728
+ finally { cleanupSend(); refreshSessions(); }
729
+ }
730
+ let cur = null;
731
+ let lastTurnText = "";
732
+ function handleEvent(ev) {
733
+ if (ev.type === "delta") { liveChars += (ev.text || "").length; cur.raw += ev.text || ""; if (!cur.raf) cur.raf = requestAnimationFrame(() => { cur.raf = 0; updateContent(cur); }); }
734
+ else if (ev.type === "reasoning") { cur.reason.classList.remove("hidden"); cur.reason.textContent += (ev.text || ""); scrollDown(); }
735
+ else if (ev.type === "tool.use") { addTool(cur, ev.name, ev.input, null); }
736
+ else if (ev.type === "tool.result") { // append a result chip
737
+ const last = cur.tools.lastElementChild;
738
+ if (last && last.querySelector(".tn")?.textContent === ev.name) {
739
+ const res = last.querySelector(".tres"); if (res) res.textContent = String(ev.result != null ? (typeof ev.result === "string" ? ev.result : JSON.stringify(ev.result)) : "");
740
+ } else addTool(cur, ev.name, ev.input, ev.result);
741
+ scrollDown();
742
+ }
743
+ else if (ev.type === "request.completed") { finishTurn("ok", ev.response && ev.response.text); }
744
+ else if (ev.type === "request.error") { finishTurn("error", ev.message || "(error)"); }
745
+ }
746
+ function finishTurn(kind, text) {
747
+ if (!cur) return;
748
+ if (kind === "ok" && cur.raw === "" && text) cur.raw = text;
749
+ if (kind === "error") { renderError(text); cur.turn.remove(); }
750
+ else { cur.content.innerHTML = md(cur.raw); }
751
+ lastTurnText = cur.raw;
752
+ cur = null; liveStop();
753
+ }
754
+ function cleanupSend() {
755
+ state.busy = false; setBusy(false);
756
+ state.messages.push({ role: "assistant", content: lastTurnText });
757
+ lastTurnText = "";
758
+ }
759
+ function setBusy(b) {
760
+ $("sendBtn").style.display = b ? "none" : "";
761
+ $("stopBtn").style.display = b ? "" : "none";
762
+ $("input").setAttribute("disabled", b ? "true" : "false");
763
+ if (!b) $("input").removeAttribute("disabled");
764
+ }
765
+ async function stop() {
766
+ if (!state.busy) return;
767
+ try { await post("/api/cancel", { id: state.id }); } catch {}
768
+ }
769
+
770
+ // ── Composer wiring ───────────────────────────────────────────────────────
771
+ function autosize() { const el = $("input"); el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 220) + "px"; }
772
+ $("input").addEventListener("input", autosize);
773
+ $("input").addEventListener("keydown", (e) => {
774
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send($("input").value.trim()); }
775
+ else if (e.key === "Escape") stop();
776
+ });
777
+ $("sendBtn").addEventListener("click", () => send($("input").value.trim()));
778
+ $("stopBtn").addEventListener("click", stop);
779
+ $("hints").addEventListener("click", (e) => {
780
+ const b = e.target.closest(".hint"); if (!b) return;
781
+ $("input").value = b.dataset.q; autosize(); $("input").focus();
782
+ });
783
+
784
+ // event delegation: copy buttons
785
+ $("stream").addEventListener("click", (e) => {
786
+ const c = e.target.closest(".copy"); if (!c) return;
787
+ const code = c.parentElement && c.parentElement.querySelector("code");
788
+ if (!code) return;
789
+ navigator.clipboard.writeText(code.textContent || "").then(() => { c.textContent = "copied"; setTimeout(() => (c.textContent = "copy"), 1200); });
790
+ });
791
+
792
+ // ── Servers modal ─────────────────────────────────────────────────────────
793
+ async function openServers() {
794
+ $("serversModal").classList.remove("hidden");
795
+ $("serversList").innerHTML = '<div class="skel"></div><div class="skel"></div>';
796
+ try {
797
+ const { servers } = await api("/api/servers");
798
+ if (!servers.length) { $("serversList").innerHTML = '<div class="muted">No MCP servers configured. Add them from the TUI with <code>/mcp add</code>.</div>'; return; }
799
+ $("serversList").innerHTML = servers.map((s) =>
800
+ '<div class="srv' + (s.enabled ? "" : " off") + '"><span class="dot"></span><span class="nm">' + esc(s.name) + "</span><span class=\"cmd\"><code>" + esc((s.command || "") + " " + (s.args || []).join(" ")) + "</code></span></div>"
801
+ ).join("");
802
+ } catch (e) { $("serversList").innerHTML = '<div class="muted">' + esc(e.message) + "</div>"; }
803
+ }
804
+ $("serversBtn").addEventListener("click", openServers);
805
+ $("serversClose").addEventListener("click", () => $("serversModal").classList.add("hidden"));
806
+ $("serversModal").addEventListener("click", (e) => { if (e.target === $("serversModal")) $("serversModal").classList.add("hidden"); });
807
+
808
+ // ── Rail toggle (mobile) ─────────────────────────────────────────────────
809
+ function closeRail() { $("rail").classList.remove("open"); }
810
+ $("menuBtn").addEventListener("click", () => $("rail").classList.toggle("open"));
811
+
812
+ // ── Boot ──────────────────────────────────────────────────────────────────
813
+ function boot() {
814
+ showEmpty(true);
815
+ loadCfg(); refreshSessions(); setModeForNew(); autosize();
816
+ }
817
+ checkAuth();
818
+ document.addEventListener("keydown", (e) => {
819
+ if (e.key === "Escape" && !$("serversModal").classList.contains("hidden")) $("serversModal").classList.add("hidden");
820
+ });
821
+ })();
822
+ </script>
823
+ </body>
824
+ </html>