smolcoder 0.4.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/dist/ui.js ADDED
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+ // Terminal UI: one readline interface for everything (prompt, pickers,
3
+ // approval), a spinner for the silent gap before the first streamed token,
4
+ // and small helpers for status lines. No dependencies.
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.UI = void 0;
40
+ exports.renderPlan = renderPlan;
41
+ exports.summarizeArgs = summarizeArgs;
42
+ const readline = __importStar(require("readline"));
43
+ const util_1 = require("./util");
44
+ /** Shared checklist renderer: ✔ done (dim) · ▶ current (cyan) · ○ pending. */
45
+ function renderPlan(plan, indent = " ") {
46
+ const cur = plan.currentIndex;
47
+ const header = `${indent}${util_1.c.bold("Plan")} ${util_1.c.dim(`${plan.doneCount}/${plan.steps.length}`)}`;
48
+ const rows = plan.steps.map((s, i) => {
49
+ if (s.done)
50
+ return `${indent}${util_1.c.dim("✔ " + s.text)}`;
51
+ if (i === cur)
52
+ return `${indent}${util_1.c.cyan("▶ " + s.text)}`;
53
+ return `${indent}${util_1.c.gray("○ " + s.text)}`;
54
+ });
55
+ return [header, ...rows].join("\n");
56
+ }
57
+ class UI {
58
+ rl;
59
+ spinnerTimer = null;
60
+ spinnerActive = false;
61
+ atLineStart = true;
62
+ onInterrupt = null;
63
+ constructor() {
64
+ this.rl = readline.createInterface({
65
+ input: process.stdin,
66
+ output: process.stdout,
67
+ historySize: 100,
68
+ });
69
+ this.rl.on("SIGINT", () => {
70
+ if (this.onInterrupt) {
71
+ this.onInterrupt();
72
+ }
73
+ else {
74
+ this.println("");
75
+ process.exit(0);
76
+ }
77
+ });
78
+ }
79
+ ask(prompt) {
80
+ return new Promise((resolve) => this.rl.question(prompt, (a) => resolve(a)));
81
+ }
82
+ /** y / n / a(lways allow this program for the session) */
83
+ async confirmCommand(command, reason) {
84
+ this.stopSpinner();
85
+ const answer = await this.ask(`${util_1.c.yellow("run?")} ${util_1.c.bold(command)}\n` +
86
+ (reason ? ` ${util_1.c.dim(reason)}\n` : "") +
87
+ ` ${util_1.c.dim("[y]es / [n]o / [a]lways allow this program this session:")} `);
88
+ const ch = answer.trim().toLowerCase();
89
+ if (ch === "a" || ch === "always")
90
+ return "always";
91
+ if (ch === "y" || ch === "yes" || ch === "")
92
+ return "yes";
93
+ return "no";
94
+ }
95
+ startSpinner(label) {
96
+ if (!process.stdout.isTTY) {
97
+ // Headless liveness heartbeat: a working local model can generate for
98
+ // minutes with nothing visible — tick on stderr so a log-follower can
99
+ // tell "working" from "hung".
100
+ this.stopSpinner();
101
+ const started = Date.now();
102
+ this.spinnerTimer = setInterval(() => {
103
+ const secs = Math.round((Date.now() - started) / 1000);
104
+ process.stderr.write(util_1.c.gray(`· ${label}… ${secs}s\n`));
105
+ }, 15000);
106
+ return;
107
+ }
108
+ this.stopSpinner();
109
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
110
+ let i = 0;
111
+ const started = Date.now();
112
+ this.spinnerActive = true;
113
+ this.spinnerTimer = setInterval(() => {
114
+ const secs = Math.floor((Date.now() - started) / 1000);
115
+ process.stdout.write(`\r${util_1.c.cyan(frames[i++ % frames.length])} ${util_1.c.dim(label + (secs > 2 ? ` ${secs}s` : ""))} `);
116
+ }, 100);
117
+ }
118
+ stopSpinner() {
119
+ if (this.spinnerTimer) {
120
+ clearInterval(this.spinnerTimer);
121
+ this.spinnerTimer = null;
122
+ }
123
+ if (this.spinnerActive) {
124
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
125
+ this.spinnerActive = false;
126
+ }
127
+ }
128
+ ensureLine() {
129
+ if (!this.atLineStart) {
130
+ process.stdout.write("\n");
131
+ this.atLineStart = true;
132
+ }
133
+ }
134
+ /** Meta output (tool lines, status, warnings) goes to stderr so headless
135
+ * consumers get a clean stdout with just the model's answer. */
136
+ meta(s) {
137
+ this.stopSpinner();
138
+ this.ensureLine();
139
+ process.stderr.write(s + "\n");
140
+ }
141
+ /** Streamed model text goes straight to stdout. */
142
+ token(text) {
143
+ this.stopSpinner();
144
+ process.stdout.write(text);
145
+ this.atLineStart = text.endsWith("\n");
146
+ }
147
+ /** Headless mode: reasoning noise is suppressed — automations want the answer. */
148
+ thinking(_text) { }
149
+ toolCall(name, args) {
150
+ const summary = summarizeArgs(name, args);
151
+ this.meta(`${util_1.c.cyan("→")} ${util_1.c.bold(name)} ${util_1.c.dim(summary)}`);
152
+ }
153
+ toolResult(result) {
154
+ const firstLine = result.split("\n")[0] ?? "";
155
+ const isError = firstLine.startsWith("Error");
156
+ const lines = result.split("\n").length;
157
+ const label = isError
158
+ ? util_1.c.red(firstLine.slice(0, 120))
159
+ : util_1.c.dim(firstLine.slice(0, 100) + (lines > 1 ? ` (+${lines - 1} lines)` : ""));
160
+ this.meta(` ${isError ? util_1.c.red("✗") : util_1.c.green("✓")} ${label}`);
161
+ }
162
+ println(s = "") {
163
+ this.stopSpinner();
164
+ this.ensureLine();
165
+ process.stdout.write(s + "\n");
166
+ this.atLineStart = true;
167
+ }
168
+ status(s) {
169
+ this.meta(util_1.c.gray(s));
170
+ }
171
+ turnEnd(label) {
172
+ this.meta(util_1.c.dim(`■ ${label}`));
173
+ }
174
+ planUpdated(plan) {
175
+ this.meta(renderPlan(plan));
176
+ }
177
+ warn(s) {
178
+ this.meta(util_1.c.yellow(s));
179
+ }
180
+ error(s) {
181
+ this.meta(util_1.c.red(s));
182
+ }
183
+ close() {
184
+ this.stopSpinner();
185
+ this.rl.close();
186
+ }
187
+ }
188
+ exports.UI = UI;
189
+ function summarizeArgs(name, args) {
190
+ try {
191
+ // Unparseable (usually cut-off) arguments: show what arrived, not "undefined".
192
+ if (args && typeof args.__raw === "string") {
193
+ return `(arguments could not be parsed: ${args.__raw.replace(/\s+/g, " ")}…)`;
194
+ }
195
+ switch (name) {
196
+ case "read_file":
197
+ return String(args.path ?? "") + (args.offset ? ` from line ${args.offset}` : "");
198
+ case "write_file":
199
+ return `${args.path} (${String(args.content ?? "").split("\n").length} lines)`;
200
+ case "edit_file":
201
+ return String(args.path ?? "");
202
+ case "list_files":
203
+ return String(args.path ?? ".");
204
+ case "search":
205
+ return `"${args.pattern}"${args.path ? ` in ${args.path}` : ""}`;
206
+ case "run_command":
207
+ return String(args.command ?? "");
208
+ case "task":
209
+ return [args.action, args.command ?? args.task_id ?? ""].filter(Boolean).join(" ");
210
+ case "plan": {
211
+ if (args.action === "set")
212
+ return `set (${String(args.steps ?? "").split("\n").filter(Boolean).length} steps)`;
213
+ if (args.action === "done")
214
+ return `done${args.step ? " " + args.step : ""}`;
215
+ if (args.action === "add")
216
+ return `add: ${String(args.text ?? "").slice(0, 60)}`;
217
+ return String(args.action ?? "");
218
+ }
219
+ default:
220
+ return JSON.stringify(args).slice(0, 80);
221
+ }
222
+ }
223
+ catch {
224
+ return "";
225
+ }
226
+ }
package/dist/util.js ADDED
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ // Small shared helpers. Zero dependencies: ANSI codes are written by hand.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.c = void 0;
5
+ exports.estimateTokens = estimateTokens;
6
+ exports.truncateMiddle = truncateMiddle;
7
+ exports.truncateEnd = truncateEnd;
8
+ exports.plural = plural;
9
+ exports.fmtDuration = fmtDuration;
10
+ exports.tryFetchJson = tryFetchJson;
11
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
12
+ function wrap(code, s) {
13
+ return useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
14
+ }
15
+ exports.c = {
16
+ dim: (s) => wrap("2", s),
17
+ bold: (s) => wrap("1", s),
18
+ cyan: (s) => wrap("36", s),
19
+ green: (s) => wrap("32", s),
20
+ yellow: (s) => wrap("33", s),
21
+ red: (s) => wrap("31", s),
22
+ magenta: (s) => wrap("35", s),
23
+ gray: (s) => wrap("90", s),
24
+ };
25
+ /** Rough token estimate: ~4 chars per token. Corrected by real usage after each request. */
26
+ function estimateTokens(text) {
27
+ return Math.ceil(text.length / 4);
28
+ }
29
+ /** Cap a string in the middle, keeping head and tail — best shape for command output. */
30
+ function truncateMiddle(s, maxChars) {
31
+ if (s.length <= maxChars)
32
+ return s;
33
+ const head = Math.floor(maxChars * 0.6);
34
+ const tail = maxChars - head;
35
+ const omitted = s.length - maxChars;
36
+ return (s.slice(0, head) +
37
+ `\n... [${omitted} characters omitted to save context] ...\n` +
38
+ s.slice(s.length - tail));
39
+ }
40
+ function truncateEnd(s, maxChars, note) {
41
+ if (s.length <= maxChars)
42
+ return s;
43
+ return s.slice(0, maxChars) + `\n... [truncated${note ? ": " + note : ""}]`;
44
+ }
45
+ function plural(n, word) {
46
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
47
+ }
48
+ function fmtDuration(ms) {
49
+ if (ms < 1000)
50
+ return `${ms}ms`;
51
+ const s = ms / 1000;
52
+ if (s < 60)
53
+ return `${s.toFixed(1)}s`;
54
+ return `${Math.floor(s / 60)}m${Math.round(s % 60)}s`;
55
+ }
56
+ /** fetch with a hard timeout; returns null on any failure (used for detection probes). */
57
+ async function tryFetchJson(url, init, timeoutMs = 1500) {
58
+ const ctrl = new AbortController();
59
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
60
+ try {
61
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
62
+ if (!res.ok)
63
+ return null;
64
+ return await res.json();
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ finally {
70
+ clearTimeout(t);
71
+ }
72
+ }
@@ -0,0 +1,381 @@
1
+ "use strict";
2
+ // The entire web client: one self-contained page, styled like the TUI.
3
+ // Served by webui.ts; talks SSE (/events) + JSON POSTs. No dependencies.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.PAGE_HTML = void 0;
6
+ exports.PAGE_HTML = `<!doctype html>
7
+ <html>
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>smolcoder</title>
12
+ <style>
13
+ :root {
14
+ --bg: #0b0d0e; --fg: #d6dbde; --dim: #6b7480; --gray: #4a525c;
15
+ --accent: #35bfd4; --yellow: #e0af68; --red: #f7768e; --green: #9ece6a;
16
+ --magenta: #bb9af7; --box: #14181a; --sel: #1a7f94;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body {
20
+ margin: 0; background: var(--bg); color: var(--fg);
21
+ font: 14px/1.5 ui-monospace, "Cascadia Code", Consolas, monospace;
22
+ }
23
+ #wrap { max-width: 920px; margin: 0 auto; padding: 20px 16px 170px; }
24
+ #logo { color: var(--accent); white-space: pre; font-size: 11px; line-height: 1.15; margin: 8px 0 2px; }
25
+ #logo .coder { color: var(--dim); }
26
+ #log { margin-top: 16px; }
27
+ .user { border-left: 3px solid var(--accent); background: var(--box); padding: 8px 12px; margin: 18px 0 10px; font-weight: 600; white-space: pre-wrap; }
28
+ .thought { color: var(--gray); white-space: nowrap; overflow: hidden; margin-top: 4px; }
29
+ .md { white-space: normal; }
30
+ .md p { margin: 6px 0; }
31
+ .md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { margin: 14px 0 6px; line-height: 1.3; color: #eef3f5; }
32
+ .md h1 { font-size: 1.3em; } .md h2 { font-size: 1.17em; } .md h3 { font-size: 1.06em; }
33
+ .md h4, .md h5, .md h6 { font-size: 1em; }
34
+ .md ul, .md ol { margin: 6px 0; padding-left: 22px; }
35
+ .md li { margin: 2px 0; }
36
+ .md strong { color: #eef3f5; }
37
+ .md code { background: #1b2124; padding: 1px 5px; border-radius: 3px; color: var(--yellow); }
38
+ .md pre { background: #11161a; border: 1px solid #232a2f; border-radius: 4px; padding: 10px 12px; overflow-x: auto; margin: 8px 0; }
39
+ .md pre code { background: none; padding: 0; color: var(--fg); }
40
+ .md table { border-collapse: collapse; margin: 8px 0; display: block; overflow-x: auto; max-width: 100%; }
41
+ .md th, .md td { border: 1px solid #232a2f; padding: 4px 10px; text-align: left; }
42
+ .md th { background: #171d20; color: #eef3f5; }
43
+ .md blockquote { border-left: 3px solid #2c343a; margin: 8px 0; padding-left: 12px; color: var(--dim); }
44
+ .md hr { border: 0; border-top: 1px solid #232a2f; margin: 12px 0; }
45
+ .md a { color: var(--accent); }
46
+ .tool { color: var(--dim); margin-top: 4px; }
47
+ .tool .name { color: var(--accent); font-weight: 600; }
48
+ .result { color: var(--dim); padding-left: 16px; }
49
+ .result.err { color: var(--red); }
50
+ .plan { background: var(--box); border-left: 3px solid var(--accent); padding: 8px 12px; margin: 10px 0; }
51
+ .plan .hdr { font-weight: 700; } .plan .hdr small { color: var(--dim); font-weight: 400; }
52
+ .plan .done { color: var(--gray); text-decoration: line-through; }
53
+ .plan .cur { color: var(--accent); font-weight: 600; }
54
+ .plan .todo { color: var(--dim); }
55
+ .turnend { color: var(--gray); margin: 8px 0 4px; }
56
+ .line-status { color: var(--gray); } .line-warn { color: var(--yellow); } .line-error { color: var(--red); }
57
+ #busy { color: var(--dim); display: none; }
58
+ #busy.on { display: block; }
59
+ #busy .spin { display: inline-block; color: var(--accent); animation: pulse 1s infinite; }
60
+ @keyframes pulse { 50% { opacity: .3; } }
61
+ .ask { background: var(--box); border-left: 3px solid var(--yellow); padding: 10px 12px; margin: 10px 0; }
62
+ .ask .cmd { font-weight: 700; }
63
+ .ask button, .ask .opt { margin: 6px 8px 0 0; background: #1e2428; color: var(--fg); border: 1px solid #2c343a; padding: 4px 12px; cursor: pointer; font: inherit; border-radius: 3px; }
64
+ .ask button:hover, .ask .opt:hover { border-color: var(--accent); }
65
+ .ask .opt.current { border-color: var(--green); }
66
+ .ask .opt .hint { color: var(--dim); font-size: 12px; margin-left: 8px; }
67
+ .ask > .hint { color: var(--dim); font-size: 12px; margin-top: 2px; }
68
+ #bottom { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg); padding: 8px 16px 14px; }
69
+ #bottom .inner { max-width: 920px; margin: 0 auto; position: relative; }
70
+ #menu { position: absolute; bottom: 100%; left: 0; right: 0; background: var(--box); border: 1px solid #232a2f; display: none; }
71
+ #menu .item { padding: 4px 10px; cursor: pointer; }
72
+ #menu .item .nm { font-weight: 700; } #menu .item .ds { color: var(--dim); margin-left: 10px; }
73
+ #menu .item.sel { background: var(--sel); color: #f2f7f8; }
74
+ #menu .item.sel .ds { color: #c8dde2; }
75
+ #inputbox { border-left: 3px solid var(--accent); background: var(--box); padding: 8px 12px; }
76
+ .inputrow { display: flex; align-items: flex-end; gap: 10px; }
77
+ #input { flex: 1; background: transparent; border: 0; outline: 0; color: var(--fg); font: inherit; resize: none; }
78
+ #actionbtn { flex: none; background: #1e2428; color: var(--dim); border: 1px solid #2c343a; padding: 3px 14px; cursor: pointer; font: inherit; font-size: 12px; border-radius: 3px; }
79
+ #actionbtn:hover { border-color: var(--accent); color: var(--accent); }
80
+ #actionbtn.stop { color: var(--red); border-color: #3d2d31; }
81
+ #actionbtn.stop:hover { border-color: var(--red); color: var(--red); }
82
+ #status { margin-top: 6px; font-size: 12.5px; color: var(--dim); }
83
+ #status .mode { font-weight: 700; }
84
+ #status .mode.edit { color: var(--accent); } #status .mode.bypass { color: var(--red); } #status .mode.ro { color: var(--magenta); }
85
+ #status .eff { color: var(--yellow); } #status .plan-chip { color: var(--accent); } #status .plan-chip.done { color: var(--green); }
86
+ #hint { font-size: 12px; color: var(--gray); margin-top: 4px; }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <div id="wrap">
91
+ <div id="logo">████████╗██╗███╗ ██╗██╗ ██╗
92
+ ╚══██╔══╝██║████╗ ██║╚██╗ ██╔╝
93
+ ██║ ██║██╔██╗ ██║ ╚████╔╝
94
+ ██║ ██║██║╚██╗██║ ╚██╔╝
95
+ ██║ ██║██║ ╚████║ ██║
96
+ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ <span class="coder">coder — web</span></div>
97
+ <div id="log"></div>
98
+ <div id="busy"><span class="spin">⠋</span> <span id="busylabel">thinking…</span> <span id="busysecs"></span></div>
99
+ </div>
100
+ <div id="bottom"><div class="inner">
101
+ <div id="menu"></div>
102
+ <div id="inputbox">
103
+ <div class="inputrow">
104
+ <textarea id="input" rows="1" placeholder='Ask anything… "/" for commands'></textarea>
105
+ <button id="actionbtn" title="send (enter)">send</button>
106
+ </div>
107
+ <div id="status">connecting…</div>
108
+ </div>
109
+ <div id="hint"><span id="ws"></span> &nbsp; / commands · shift+tab mode · enter send · esc cancel</div>
110
+ </div></div>
111
+ <script>
112
+ const k = new URLSearchParams(location.search).get("k") || "";
113
+ const log = document.getElementById("log");
114
+ const busyEl = document.getElementById("busy");
115
+ const actionBtn = document.getElementById("actionbtn");
116
+ let isBusy = false;
117
+ let state = { commands: [] };
118
+ let curText = null, curThought = null, busyTimer = null, busyStart = 0;
119
+ let thoughtBuf = "", thoughtStart = 0;
120
+ function endThought() {
121
+ if (curThought) {
122
+ curThought.textContent = "✦ thought for " + ((Date.now() - thoughtStart) / 1000).toFixed(1) + "s";
123
+ curThought = null; thoughtBuf = "";
124
+ }
125
+ }
126
+
127
+ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
128
+
129
+ // ---- markdown ----------------------------------------------------------
130
+ // Model output is untrusted: escape everything first, then build tags
131
+ // ourselves. Nothing from the model is ever inserted as raw HTML.
132
+ function esc(s) {
133
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
134
+ }
135
+ function inlineMd(s) {
136
+ const codes = [];
137
+ s = s.replace(/\`([^\`]+)\`/g, (m, c) => { codes.push(c); return "\\u0000" + (codes.length - 1) + "\\u0000"; });
138
+ s = s.replace(/\\*\\*\\*([^*]+)\\*\\*\\*/g, "<strong><em>$1</em></strong>");
139
+ s = s.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>");
140
+ s = s.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, "$1<em>$2</em>");
141
+ s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
142
+ s = s.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)"]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
143
+ s = s.replace(/\\u0000(\\d+)\\u0000/g, (m, i) => "<code>" + codes[i] + "</code>");
144
+ return s;
145
+ }
146
+ function renderMarkdown(src) {
147
+ const lines = esc(src).split("\\n");
148
+ let out = "", i = 0, listType = null;
149
+ const closeList = () => { if (listType) { out += "</" + listType + ">"; listType = null; } };
150
+ const cells = (row) => row.trim().replace(/^\\||\\|$/g, "").split("|").map((c) => c.trim());
151
+ while (i < lines.length) {
152
+ const line = lines[i];
153
+ const fence = /^\\s*\`\`\`(\\w*)\\s*$/.exec(line);
154
+ if (fence) {
155
+ closeList();
156
+ const body = []; i++;
157
+ while (i < lines.length && !/^\\s*\`\`\`/.test(lines[i])) { body.push(lines[i]); i++; }
158
+ i++;
159
+ out += "<pre><code>" + body.join("\\n") + "</code></pre>";
160
+ continue;
161
+ }
162
+ if (/^\\s*\\|/.test(line) && i + 1 < lines.length && /^\\s*\\|?[\\s:|-]+\\|[\\s:|-]*$/.test(lines[i + 1])) {
163
+ closeList();
164
+ const head = cells(line); i += 2;
165
+ const rows = [];
166
+ while (i < lines.length && /^\\s*\\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
167
+ out += "<table><thead><tr>" + head.map((h) => "<th>" + inlineMd(h) + "</th>").join("") + "</tr></thead><tbody>";
168
+ for (const r of rows) out += "<tr>" + r.map((c) => "<td>" + inlineMd(c) + "</td>").join("") + "</tr>";
169
+ out += "</tbody></table>";
170
+ continue;
171
+ }
172
+ const h = /^(#{1,6})\\s+(.*)$/.exec(line);
173
+ if (h) { closeList(); out += "<h" + h[1].length + ">" + inlineMd(h[2]) + "</h" + h[1].length + ">"; i++; continue; }
174
+ if (/^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$/.test(line)) { closeList(); out += "<hr>"; i++; continue; }
175
+ // NB: lines are already escaped, so the blockquote marker is "&gt;".
176
+ if (/^\\s*&gt;\\s?/.test(line)) {
177
+ closeList();
178
+ const body = [];
179
+ while (i < lines.length && /^\\s*&gt;\\s?/.test(lines[i])) { body.push(lines[i].replace(/^\\s*&gt;\\s?/, "")); i++; }
180
+ out += "<blockquote>" + inlineMd(body.join(" ")) + "</blockquote>";
181
+ continue;
182
+ }
183
+ const ul = /^\\s*[-*+]\\s+(.*)$/.exec(line);
184
+ const ol = /^\\s*\\d+[.)]\\s+(.*)$/.exec(line);
185
+ if (ul || ol) {
186
+ const want = ul ? "ul" : "ol";
187
+ if (listType !== want) { closeList(); out += "<" + want + ">"; listType = want; }
188
+ out += "<li>" + inlineMd((ul || ol)[1]) + "</li>";
189
+ i++; continue;
190
+ }
191
+ if (!line.trim()) { closeList(); i++; continue; }
192
+ closeList();
193
+ const para = [line]; i++;
194
+ while (i < lines.length && lines[i].trim() &&
195
+ !/^(\\s*#{1,6}\\s|\\s*\`\`\`|\\s*>\\s?|\\s*[-*+]\\s|\\s*\\d+[.)]\\s|\\s*\\|)/.test(lines[i])) { para.push(lines[i]); i++; }
196
+ out += "<p>" + inlineMd(para.join(" ")) + "</p>";
197
+ }
198
+ closeList();
199
+ return out;
200
+ }
201
+ // Streaming: accumulate raw markdown on the element, re-render on a short
202
+ // timer. NOT requestAnimationFrame — rAF never fires in background tabs, so a
203
+ // response streamed while the tab is hidden would never render.
204
+ function scheduleMd(target) {
205
+ if (target._pending) return;
206
+ target._pending = true;
207
+ setTimeout(() => {
208
+ target._pending = false;
209
+ target.innerHTML = renderMarkdown(target._raw || "");
210
+ window.scrollTo(0, document.body.scrollHeight);
211
+ }, 60);
212
+ }
213
+ function add(e) { log.appendChild(e); window.scrollTo(0, document.body.scrollHeight); return e; }
214
+ function post(path, body) { return fetch(path + "?k=" + k, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); }
215
+
216
+ function setBusy(label) {
217
+ // The one button right of the input is send when idle, stop while running.
218
+ isBusy = !!label;
219
+ actionBtn.textContent = isBusy ? "■ stop" : "send";
220
+ actionBtn.className = isBusy ? "stop" : "";
221
+ actionBtn.title = isBusy ? "interrupt the agent (esc)" : "send (enter)";
222
+ if (label) {
223
+ busyEl.classList.add("on"); busyStart = Date.now();
224
+ document.getElementById("busylabel").textContent = label + "…";
225
+ if (!busyTimer) busyTimer = setInterval(() => {
226
+ const s = Math.floor((Date.now() - busyStart) / 1000);
227
+ document.getElementById("busysecs").textContent = s > 2 ? s + "s" : "";
228
+ }, 500);
229
+ } else {
230
+ busyEl.classList.remove("on");
231
+ if (busyTimer) { clearInterval(busyTimer); busyTimer = null; }
232
+ document.getElementById("busysecs").textContent = "";
233
+ }
234
+ window.scrollTo(0, document.body.scrollHeight);
235
+ }
236
+
237
+ function renderState(s) {
238
+ state = Object.assign(state, s);
239
+ const st = document.getElementById("status");
240
+ st.innerHTML = "";
241
+ const mode = el("span", "mode " + s.mode, s.mode === "ro" ? "read-only" : s.mode === "bypass" ? "bypass permissions" : s.mode);
242
+ st.appendChild(mode);
243
+ st.appendChild(document.createTextNode(" · " + s.model + " (" + s.backend + ")"));
244
+ if (s.effort) { st.appendChild(document.createTextNode(" · ")); st.appendChild(el("span", "eff", s.effort)); }
245
+ const kt = s.ctxTokens < 1000 ? s.ctxTokens : (s.ctxTokens / 1000).toFixed(1) + "k";
246
+ st.appendChild(document.createTextNode(" · " + kt + " (" + s.ctxPct + "%)"));
247
+ if (s.plan) {
248
+ st.appendChild(document.createTextNode(" · "));
249
+ const done = s.plan.steps.filter(x => x.done).length;
250
+ const chip = el("span", "plan-chip" + (s.plan.current < 0 ? " done" : ""), "plan " + done + "/" + s.plan.steps.length);
251
+ st.appendChild(chip);
252
+ }
253
+ if (s.tasks) st.appendChild(document.createTextNode(" · " + s.tasks + " task" + (s.tasks > 1 ? "s" : "")));
254
+ document.getElementById("ws").textContent = s.workspace || "";
255
+ }
256
+
257
+ function renderPlan(p) {
258
+ const box = el("div", "plan");
259
+ const done = p.steps.filter(x => x.done).length;
260
+ const hdr = el("div", "hdr", "Plan ");
261
+ hdr.appendChild(el("small", "", done + "/" + p.steps.length));
262
+ box.appendChild(hdr);
263
+ p.steps.forEach((s, i) => {
264
+ const cls = s.done ? "done" : i === p.current ? "cur" : "todo";
265
+ const mark = s.done ? "✔ " : i === p.current ? "▶ " : "○ ";
266
+ box.appendChild(el("div", cls, mark + s.text));
267
+ });
268
+ add(box);
269
+ }
270
+
271
+ function handle(m) {
272
+ switch (m.t) {
273
+ case "state": renderState(m.s); break;
274
+ case "user": endThought(); curText = null; add(el("div", "user", m.s)); break;
275
+ case "token":
276
+ endThought();
277
+ if (!curText) { curText = add(el("div", "md")); curText._raw = ""; }
278
+ curText._raw += m.s; scheduleMd(curText); break;
279
+ case "thinking":
280
+ if (!curThought) { curThought = add(el("div", "thought")); thoughtStart = Date.now(); thoughtBuf = ""; curText = null; }
281
+ thoughtBuf += m.s;
282
+ if (thoughtBuf.length > 4000) thoughtBuf = thoughtBuf.slice(-2000);
283
+ var tt = thoughtBuf.replace(/\\s+/g, " ").trim();
284
+ curThought.textContent = "✦ " + (tt.length > 160 ? "…" + tt.slice(-160) : tt);
285
+ window.scrollTo(0, document.body.scrollHeight); break;
286
+ case "tool": {
287
+ endThought(); curText = null;
288
+ const d = el("div", "tool"); d.appendChild(el("span", "name", "→ " + m.name)); d.appendChild(document.createTextNode(" " + (m.summary || "")));
289
+ add(d); break;
290
+ }
291
+ case "result": endThought(); curText = null; add(el("div", "result" + (m.err ? " err" : ""), (m.err ? "✗ " : "✓ ") + m.line + (m.extra ? " (+" + m.extra + " lines)" : ""))); break;
292
+ case "plan": endThought(); curText = null; renderPlan(m); break;
293
+ case "line": endThought(); curText = null; add(el("div", "line-" + m.kind, m.s)); break;
294
+ case "turnend": endThought(); curText = null; add(el("div", "turnend", "■ " + m.label)); break;
295
+ case "busy": setBusy(m.label); break;
296
+ case "confirm": {
297
+ endThought(); curText = null;
298
+ const box = el("div", "ask");
299
+ box.appendChild(el("div", "", "run?")); box.appendChild(el("div", "cmd", m.command));
300
+ if (m.reason) box.appendChild(el("div", "hint", m.reason));
301
+ ["yes", "no", "always"].forEach(a => {
302
+ const b = el("button", "", a === "always" ? "always allow this program" : a);
303
+ b.onclick = () => { post("/confirm", { id: m.id, answer: a }); box.remove(); add(el("div", "line-status", a + " — " + m.command)); };
304
+ box.appendChild(b);
305
+ });
306
+ add(box); break;
307
+ }
308
+ case "select": {
309
+ endThought(); curText = null;
310
+ const box = el("div", "ask");
311
+ box.appendChild(el("div", "cmd", m.title));
312
+ m.options.forEach((o, i) => {
313
+ const b = el("button", "opt" + (o.current ? " current" : ""), (o.current ? "● " : "") + o.label);
314
+ if (o.hint) b.appendChild(el("span", "hint", o.hint));
315
+ b.onclick = () => { post("/select", { id: m.id, index: i }); box.remove(); };
316
+ box.appendChild(el("div")).appendChild(b);
317
+ });
318
+ const cancel = el("button", "", "cancel");
319
+ cancel.onclick = () => { post("/select", { id: m.id, index: null }); box.remove(); };
320
+ box.appendChild(cancel);
321
+ add(box); break;
322
+ }
323
+ }
324
+ }
325
+
326
+ const input = document.getElementById("input");
327
+ const menu = document.getElementById("menu");
328
+ let menuIdx = 0;
329
+ function menuItems() {
330
+ const v = input.value;
331
+ if (!v.startsWith("/") || v.includes(" ") || v.includes("\\n")) return [];
332
+ return (state.commands || []).filter(c => c.name.startsWith(v.slice(1)));
333
+ }
334
+ function renderMenu() {
335
+ const items = menuItems();
336
+ menu.style.display = items.length ? "block" : "none";
337
+ menu.innerHTML = "";
338
+ if (menuIdx >= items.length) menuIdx = 0;
339
+ items.forEach((c, i) => {
340
+ const d = el("div", "item" + (i === menuIdx ? " sel" : ""));
341
+ d.appendChild(el("span", "nm", "/" + c.name)); d.appendChild(el("span", "ds", c.desc));
342
+ d.onclick = () => { input.value = "/" + c.name; submit(); };
343
+ menu.appendChild(d);
344
+ });
345
+ }
346
+ function submit() {
347
+ let v = input.value;
348
+ const items = menuItems();
349
+ if (items.length) v = "/" + items[menuIdx].name;
350
+ v = v.trim();
351
+ if (!v) return;
352
+ input.value = ""; renderMenu();
353
+ post("/msg", { text: v });
354
+ }
355
+ input.addEventListener("input", () => { menuIdx = 0; renderMenu(); autoGrow(); });
356
+ function autoGrow() { input.rows = Math.min(6, Math.max(1, input.value.split("\\n").length)); }
357
+ input.addEventListener("keydown", (e) => {
358
+ const items = menuItems();
359
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
360
+ else if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); post("/cycle"); }
361
+ else if (e.key === "Tab" && items.length) { e.preventDefault(); input.value = "/" + items[menuIdx].name + " "; renderMenu(); }
362
+ else if (e.key === "ArrowUp" && items.length) { e.preventDefault(); menuIdx = (menuIdx - 1 + items.length) % items.length; renderMenu(); }
363
+ else if (e.key === "ArrowDown" && items.length) { e.preventDefault(); menuIdx = (menuIdx + 1) % items.length; renderMenu(); }
364
+ else if (e.key === "Escape") { if (input.value) { input.value = ""; renderMenu(); } else post("/cancel"); }
365
+ });
366
+
367
+ actionBtn.onclick = () => { if (isBusy) post("/cancel"); else submit(); };
368
+
369
+ // Escape interrupts from anywhere on the page, not only from the input box.
370
+ document.addEventListener("keydown", (e) => {
371
+ if (e.key === "Escape" && document.activeElement !== input) post("/cancel");
372
+ });
373
+
374
+ const es = new EventSource("/events?k=" + k);
375
+ es.onopen = () => { log.innerHTML = ""; };
376
+ es.onmessage = (e) => handle(JSON.parse(e.data));
377
+ es.onerror = () => { document.getElementById("status").textContent = "reconnecting…"; };
378
+ input.focus();
379
+ </script>
380
+ </body>
381
+ </html>`;