smolcoder 0.4.2 → 0.5.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/index.js CHANGED
@@ -5,6 +5,8 @@
5
5
  // Interactive: an opencode-style inline TUI. No upfront questions — the last
6
6
  // (or first) detected model is picked automatically; switch with /models,
7
7
  // cycle modes with shift+tab, set reasoning effort with /effort.
8
+ // Web: smol --web serves a browser UI with a workspace sidebar — many
9
+ // projects and sessions side by side, started from anywhere.
8
10
  // Headless: smol -p "prompt" for people and automations.
9
11
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
12
  if (k2 === undefined) k2 = k;
@@ -44,22 +46,20 @@ const fs = __importStar(require("fs"));
44
46
  const os = __importStar(require("os"));
45
47
  const path = __importStar(require("path"));
46
48
  const agent_1 = require("./agent");
49
+ const config_1 = require("./config");
47
50
  const context_1 = require("./context");
48
- const detect_1 = require("./detect");
49
51
  const events_1 = require("./events");
50
52
  const plan_1 = require("./plan");
51
53
  const prompt_1 = require("./prompt");
52
- const lmstudio_1 = require("./providers/lmstudio");
53
- const ollama_1 = require("./providers/ollama");
54
- const index_1 = require("./tools/index");
54
+ const session_1 = require("./session");
55
55
  const shell_1 = require("./tools/shell");
56
56
  const tasks_1 = require("./tools/tasks");
57
57
  const tui_1 = require("./tui/tui");
58
58
  const ui_1 = require("./ui");
59
- const webui_1 = require("./web/webui");
60
59
  const util_1 = require("./util");
60
+ const hub_1 = require("./web/hub");
61
61
  const VERSION = require("../package.json").version;
62
- const CONFIG_PATH = path.join(os.homedir(), ".smolcoder.json");
62
+ const DEFAULT_WEB_PORT = 7433;
63
63
  function parseArgs(argv) {
64
64
  const args = { workspace: process.cwd() };
65
65
  for (let i = 0; i < argv.length; i++) {
@@ -105,8 +105,10 @@ function parseArgs(argv) {
105
105
  if (argv[i + 1] && /^\d+$/.test(argv[i + 1]))
106
106
  args.webPort = Number(argv[++i]);
107
107
  }
108
- else if (!a.startsWith("-"))
108
+ else if (!a.startsWith("-")) {
109
109
  args.workspace = path.resolve(a);
110
+ args.workspaceGiven = true;
111
+ }
110
112
  else {
111
113
  console.error(`Unknown option "${a}". Try smol --help.`);
112
114
  process.exit(1);
@@ -129,7 +131,10 @@ ${util_1.c.bold("Options:")}
129
131
  --model <name> pick a model by (partial) name
130
132
  --ctx <tokens> force a context window (Ollama: sends num_ctx)
131
133
  --effort <level> reasoning effort: off, low, medium, high, default
132
- --web [port] serve the session as a local web UI (default port 7433)
134
+ --web [port] browser UI (default port ${DEFAULT_WEB_PORT}): a sidebar of your
135
+ workspaces and sessions, an embedded browser and
136
+ terminal panel. Run it from anywhere; a second
137
+ smol --web adds its folder to the running UI.
133
138
  -p, --print "<prompt>" headless: run a single prompt and exit
134
139
  -h, --help this help
135
140
  -v, --version version
@@ -147,69 +152,6 @@ ${util_1.c.bold("Slash commands:")}
147
152
  /context context usage /clear reset conversation
148
153
  /compact compact now /exit quit
149
154
  `;
150
- function loadConfig() {
151
- try {
152
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
153
- // Never let bypass be inherited implicitly from a past session — a single
154
- // shift+tab into it would otherwise silently persist unattended, unchecked
155
- // command execution into every later run, including headless -p in CI.
156
- // Requires an explicit flag (-m bypass / --bypass) each time. Old configs
157
- // saved "write"/"yolo" under the previous mode names.
158
- if (cfg.lastMode === "write")
159
- cfg.lastMode = "edit";
160
- if (cfg.lastMode === "yolo" || cfg.lastMode === "bypass")
161
- cfg.lastMode = "edit";
162
- return cfg;
163
- }
164
- catch {
165
- return {};
166
- }
167
- }
168
- function saveConfig(cfg) {
169
- try {
170
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
171
- }
172
- catch {
173
- /* non-fatal */
174
- }
175
- }
176
- /** Output budget scales with the window: big windows can afford whole-file
177
- * writes (a single write_file's JSON must fit in the output), tiny windows
178
- * must stay conservative. */
179
- function outputBudget(window) {
180
- return Math.max(1024, Math.min(16384, Math.floor(window / 4)));
181
- }
182
- function makeProvider(m) {
183
- const maxOut = outputBudget(m.contextWindow);
184
- return m.backend === "ollama"
185
- ? new ollama_1.OllamaProvider(m.baseUrl, m.id, m.contextWindow, m.numCtx, maxOut)
186
- : new lmstudio_1.LmStudioProvider(m.baseUrl, m.id, m.contextWindow, maxOut, m.reasoning);
187
- }
188
- /** One-line advice when the effective reasoning setting will be slow: LM
189
- * Studio applies the model's own default level when none is chosen, and for
190
- * current qwen builds that default is the maximum. */
191
- function effortAdvice(m, effort) {
192
- if (m.backend !== "lmstudio" || !m.reasoning?.default)
193
- return null;
194
- const d = m.reasoning.default;
195
- if (effort === null && /^(high|xhigh)$/.test(d)) {
196
- return `this model thinks at "${d}" by default on LM Studio — expect long pauses before each tool call. /effort off (or --effort off) is many times faster; /effort low or medium keeps some reasoning.`;
197
- }
198
- return null;
199
- }
200
- /** Tell the user what context management just did (both UIs; headless logs
201
- * it to stderr so a long run's log shows when and how hard compaction hit). */
202
- function reportCompactions(bus, ui) {
203
- bus.on("post_compact", (report) => {
204
- const delta = `${report?.before} → ${report?.after} tokens est.`;
205
- if (report?.action === "evicted")
206
- ui.status(`· freed context by dropping old tool output (${delta})`);
207
- else if (report?.action === "compacted")
208
- ui.status(`· compacted the conversation into hand-over notes (${delta})`);
209
- else if (report?.action === "floor")
210
- ui.warn(`· context is at its floor: system prompt + tools + the working tail no longer fit comfortably (${delta}). Consider a bigger context window.`);
211
- });
212
- }
213
155
  /** Node fires 'exit' on normal termination but NOT on a killing signal, so
214
156
  * background tasks (dev servers) survive a closed terminal (SIGHUP) or `kill`
215
157
  * (SIGTERM) unless we handle those explicitly. Runs synchronous cleanup then
@@ -232,36 +174,13 @@ function installSignalCleanup(cleanup) {
232
174
  process.on("SIGHUP", () => run(129));
233
175
  process.on("SIGINT", () => run(130));
234
176
  }
235
- function autoPickModel(models, wanted, remembered) {
236
- if (wanted) {
237
- const hit = models.find((m) => m.id === wanted) ??
238
- models.find((m) => m.id.toLowerCase().includes(wanted.toLowerCase()));
239
- if (hit)
240
- return hit;
241
- }
242
- return (models.find((m) => m.id === remembered) ??
243
- models.find((m) => m.backend === "ollama") ??
244
- models.find((m) => m.loaded) ??
245
- models[0]);
246
- }
247
- function noBackendsMessage() {
248
- return (util_1.c.red("No local model backend found.") +
249
- `\n\nsmolcoder looks for:\n` +
250
- ` · ${util_1.c.bold("Ollama")} at http://127.0.0.1:11434 ${util_1.c.dim("(or $OLLAMA_HOST)")} — install: https://ollama.com, then: ollama pull qwen3\n` +
251
- ` · ${util_1.c.bold("LM Studio")} at http://127.0.0.1:1234 — start its local server (Developer tab → Start Server)\n\n` +
252
- `Start one of them and run smol again. No configuration needed.`);
253
- }
254
- function sessionLine(m, mode) {
255
- return `${util_1.c.green("●")} ${m.backend} · ${util_1.c.bold(m.id)} · ctx ${m.contextWindow.toLocaleString()} · ${index_1.MODE_LABELS[mode]} mode`;
256
- }
257
- const MODE_ORDER = ["ro", "edit", "bypass"];
258
177
  const LOGO_ROWS = [
259
- "████████╗ ██╗ ███╗ ██╗ ██╗ ██╗",
260
- "╚══██╔══╝ ██║ ████╗ ██║ ╚██╗ ██╔╝",
261
- " ██║ ██║ ██╔██╗ ██║ ╚████╔╝ ",
262
- " ██║ ██║ ██║╚██╗██║ ╚██╔╝ ",
263
- " ██║ ██║ ██║ ╚████║ ██║ ",
264
- " ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝ ",
178
+ "███████╗ ███╗ ███╗ ██████╗ ██╗ ",
179
+ "██╔════╝ ████╗ ████║ ██╔═══██╗ ██║ ",
180
+ "███████╗ ██╔████╔██║ ██║ ██║ ██║ ",
181
+ "╚════██║ ██║╚██╔╝██║ ██║ ██║ ██║ ",
182
+ "███████║ ██║ ╚═╝ ██║ ╚██████╔╝ ███████╗",
183
+ "╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
265
184
  ];
266
185
  function printLogo() {
267
186
  const cols = process.stdout.columns || 80;
@@ -277,16 +196,8 @@ function printLogo() {
277
196
  console.log(`${util_1.c.bold("smol")}${util_1.c.dim(util_1.c.bold("coder"))} ${util_1.c.dim("v" + VERSION)}`);
278
197
  }
279
198
  }
280
- function fmtTokens(n) {
281
- return n < 1000 ? String(n) : (n / 1000).toFixed(1) + "k";
282
- }
283
- function modeColored(mode) {
284
- const label = index_1.MODE_LABELS[mode];
285
- if (mode === "bypass")
286
- return util_1.c.red(util_1.c.bold(label));
287
- if (mode === "ro")
288
- return util_1.c.magenta(util_1.c.bold(label));
289
- return util_1.c.cyan(util_1.c.bold(label));
199
+ function prefsOf(args) {
200
+ return { mode: args.mode, model: args.model, ctx: args.ctx, effort: args.effort };
290
201
  }
291
202
  // ---------------------------------------------------------------------------
292
203
  async function main() {
@@ -303,29 +214,27 @@ async function main() {
303
214
  console.error(`Workspace folder does not exist: ${args.workspace}`);
304
215
  process.exit(1);
305
216
  }
306
- if (args.print !== undefined) {
217
+ if (args.print !== undefined)
307
218
  await runHeadless(args);
308
- }
309
- else {
219
+ else if (args.web)
220
+ await runWeb(args);
221
+ else
310
222
  await runInteractive(args);
311
- }
312
223
  }
313
224
  // ---- headless (-p) ---------------------------------------------------------
314
225
  async function runHeadless(args) {
315
226
  const ui = new ui_1.UI();
316
227
  const bus = new events_1.EventBus();
317
- const models = await (0, detect_1.detectAll)();
318
- if (models.length === 0) {
319
- ui.println(noBackendsMessage());
228
+ const cfg = (0, config_1.loadConfig)();
229
+ const chosen = await (0, session_1.prepareModel)(prefsOf(args), cfg);
230
+ if (!chosen) {
231
+ ui.println((0, session_1.noBackendsMessage)());
320
232
  ui.close();
321
233
  process.exit(1);
322
234
  }
323
- const cfg = loadConfig();
324
- let chosen = autoPickModel(models, args.model, cfg.lastModel);
325
- chosen = await (0, detect_1.resolveContextWindow)(chosen, args.ctx);
326
235
  const mode = args.mode ?? cfg.lastMode ?? "edit";
327
236
  const shell = (0, shell_1.pickShell)();
328
- const provider = makeProvider(chosen);
237
+ const provider = (0, session_1.makeProvider)(chosen);
329
238
  provider.setEffort(args.effort !== undefined ? args.effort : (cfg.effort ?? null));
330
239
  const taskManager = new tasks_1.TaskManager(args.workspace);
331
240
  const toolCtx = {
@@ -341,15 +250,15 @@ async function runHeadless(args) {
341
250
  ui.status(`· AGENTS.md loaded (${agentsMd.split("\n").length} lines)`);
342
251
  const systemPrompt = (0, prompt_1.buildSystemPrompt)({ workspace: args.workspace, mode, shellLabel: shell.label, agentsMd });
343
252
  const agent = new agent_1.Agent(provider, mode, systemPrompt, toolCtx, ctxMgr, bus, ui, false, 1000);
344
- reportCompactions(bus, ui);
253
+ (0, session_1.reportCompactions)(bus, ui);
345
254
  process.on("exit", () => taskManager.killAll());
346
255
  installSignalCleanup(() => taskManager.killAll());
347
- ui.println(sessionLine(chosen, mode));
256
+ ui.println((0, session_1.sessionLine)(chosen, mode));
348
257
  if (chosen.note)
349
258
  ui.warn(` ${chosen.note}`);
350
259
  const effortSetting = args.effort !== undefined ? args.effort : (cfg.effort ?? null);
351
260
  ui.status(` effort ${provider.effortLabel() ?? effortSetting ?? "default"}`);
352
- const advice = effortAdvice(chosen, effortSetting);
261
+ const advice = (0, session_1.effortAdvice)(chosen, effortSetting);
353
262
  if (advice)
354
263
  ui.warn(` ${advice}`);
355
264
  try {
@@ -380,96 +289,28 @@ async function runHeadless(args) {
380
289
  ui.close();
381
290
  }
382
291
  // ---- interactive TUI -------------------------------------------------------
383
- const SLASH_COMMANDS = [
384
- { name: "models", desc: "Switch model" },
385
- { name: "mode", desc: "Set mode (ro / edit / bypass)" },
386
- { name: "effort", desc: "Set reasoning effort" },
387
- { name: "plan", desc: "Show the agent's plan" },
388
- { name: "context", desc: "Show context usage" },
389
- { name: "compact", desc: "Compact the conversation now" },
390
- { name: "tasks", desc: "List background tasks" },
391
- { name: "logs", desc: "Show task output — /logs t1" },
392
- { name: "stop", desc: "Stop a background task — /stop t1" },
393
- { name: "clear", desc: "Reset the conversation" },
394
- { name: "help", desc: "Show help" },
395
- { name: "exit", desc: "Quit smolcoder" },
396
- ];
397
292
  async function runInteractive(args) {
398
- const isWeb = !!args.web;
399
- if (!isWeb && (!process.stdout.isTTY || !process.stdin.isTTY)) {
293
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
400
294
  console.error('Interactive mode needs a terminal. For headless use, run: smol -p "your prompt" — or serve a browser UI with --web');
401
295
  process.exit(1);
402
296
  }
403
- if (isWeb)
404
- console.log(`${util_1.c.bold("smol")}${util_1.c.dim(util_1.c.bold("coder"))} ${util_1.c.dim("v" + VERSION + " · web")}`);
405
- else
406
- printLogo();
297
+ printLogo();
298
+ const cfg = (0, config_1.loadConfig)();
407
299
  process.stdout.write(util_1.c.dim("· looking for Ollama and LM Studio…"));
408
- const models = await (0, detect_1.detectAll)();
300
+ const chosen = await (0, session_1.prepareModel)(prefsOf(args), cfg, (label) => {
301
+ process.stdout.write("\r\x1b[2K" + util_1.c.dim(`· ${label}…`));
302
+ });
409
303
  process.stdout.write("\r\x1b[2K");
410
- if (models.length === 0) {
411
- console.log(noBackendsMessage());
304
+ if (!chosen) {
305
+ console.log((0, session_1.noBackendsMessage)());
412
306
  process.exit(1);
413
307
  }
414
- const cfg = loadConfig();
415
- let chosen = autoPickModel(models, args.model, cfg.lastModel);
416
- process.stdout.write(util_1.c.dim(`· loading ${chosen.id}…`));
417
- chosen = await (0, detect_1.resolveContextWindow)(chosen, args.ctx);
418
- process.stdout.write("\r\x1b[2K");
419
- const mode0 = args.mode ?? cfg.lastMode ?? "edit";
420
- let effort = args.effort !== undefined ? args.effort : (cfg.effort ?? null);
421
- const shell = (0, shell_1.pickShell)();
422
- const bus = new events_1.EventBus();
423
- const provider = makeProvider(chosen);
424
- provider.setEffort(effort);
425
- const taskManager = new tasks_1.TaskManager(args.workspace);
426
- const toolCtx = {
427
- workspace: args.workspace,
428
- taskManager,
429
- plan: new plan_1.Plan(),
430
- filesTouched: new Set(),
431
- commandsRun: [],
432
- };
433
- const ctxMgr = new context_1.ContextManager(chosen.contextWindow, provider.maxOutputTokens);
434
- const agentsMd = (0, prompt_1.loadAgentsMd)(args.workspace);
435
- const sysPrompt = (m) => (0, prompt_1.buildSystemPrompt)({ workspace: args.workspace, mode: m, shellLabel: shell.label, agentsMd });
436
- // The step cap is a runaway-loop backstop, not a work limit — esc/ctrl+c is
437
- // the user's real kill switch, so set it far above any legitimate task.
438
- const tui = isWeb ? new webui_1.WebUI(args.webPort ?? 7433) : new tui_1.Tui();
439
- const agent = new agent_1.Agent(provider, mode0, sysPrompt(mode0), toolCtx, ctxMgr, bus, tui, true, 1000);
440
- const persist = () => saveConfig({ lastModel: chosen.id, lastMode: agent.mode, effort });
441
- tui.slashCommands = SLASH_COMMANDS;
442
- tui.hintLeft = args.workspace.replace(os.homedir(), "~");
443
- tui.getStatus = () => {
444
- const tasks = taskManager.runningSummary().length;
445
- return (`${modeColored(agent.mode)} ${util_1.c.dim("·")} ${chosen.id} ${util_1.c.dim(chosen.backend)}` +
446
- (effort || agent.provider.effortLabel()
447
- ? ` ${util_1.c.dim("·")} ${util_1.c.yellow(agent.provider.effortLabel() ?? effort ?? "")}`
448
- : "") +
449
- ` ${util_1.c.dim("·")} ${util_1.c.dim(`${fmtTokens(agent.contextTokens())} (${agent.contextPercent()}%)`)}` +
450
- (toolCtx.plan.exists
451
- ? ` ${util_1.c.dim("·")} ${toolCtx.plan.currentIndex < 0
452
- ? util_1.c.green(`plan ${toolCtx.plan.doneCount}/${toolCtx.plan.steps.length}`)
453
- : util_1.c.cyan(`plan ${toolCtx.plan.doneCount}/${toolCtx.plan.steps.length}`)}`
454
- : "") +
455
- (tasks ? ` ${util_1.c.dim("·")} ${util_1.c.green(`${tasks} task${tasks > 1 ? "s" : ""}`)}` : ""));
456
- };
457
- tui.onModeCycle = () => {
458
- const next = MODE_ORDER[(MODE_ORDER.indexOf(agent.mode) + 1) % MODE_ORDER.length];
459
- agent.setMode(next, sysPrompt(next));
460
- persist();
461
- };
462
- tui.onCancel = () => agent.cancel();
463
- const shutdown = async () => {
464
- await bus.emit("session_end");
465
- taskManager.killAll();
466
- tui.close();
467
- process.exit(0);
468
- };
469
- tui.onExit = () => void shutdown();
470
- process.on("exit", () => taskManager.killAll());
308
+ const tui = new tui_1.Tui();
309
+ const session = new session_1.Session(tui, { workspace: args.workspace, chosen, prefs: prefsOf(args), cfg, help: HELP });
310
+ session.onExit = () => process.exit(0);
311
+ process.on("exit", () => session.taskManager.killAll());
471
312
  installSignalCleanup(() => {
472
- taskManager.killAll();
313
+ session.taskManager.killAll();
473
314
  try {
474
315
  tui.close(); // restore the raw-mode terminal on signal death
475
316
  }
@@ -477,202 +318,50 @@ async function runInteractive(args) {
477
318
  /* best effort */
478
319
  }
479
320
  });
480
- reportCompactions(bus, tui);
481
- if (tui instanceof webui_1.WebUI) {
482
- tui.getState = () => ({
483
- mode: agent.mode,
484
- model: chosen.id,
485
- backend: chosen.backend,
486
- effort: agent.provider.effortLabel() ?? effort,
487
- ctxTokens: agent.contextTokens(),
488
- ctxPct: agent.contextPercent(),
489
- plan: toolCtx.plan.exists
490
- ? { steps: toolCtx.plan.steps, current: toolCtx.plan.currentIndex }
491
- : null,
492
- tasks: taskManager.runningSummary().length,
493
- workspace: args.workspace,
494
- commands: SLASH_COMMANDS,
495
- });
496
- }
497
321
  tui.start();
498
- tui.println(sessionLine(chosen, agent.mode));
499
- if (chosen.note)
500
- tui.warn(` ${chosen.note}`);
501
- tui.status(` workspace ${args.workspace} · shell ${shell.label}`);
502
- if (agentsMd)
503
- tui.status(` AGENTS.md loaded (${agentsMd.split("\n").length} lines)`);
504
- {
505
- const advice = effortAdvice(chosen, effort);
506
- if (advice)
507
- tui.warn(` ${advice}`);
508
- }
509
- if (!isWeb)
510
- tui.println("");
511
- persist();
512
- await bus.emit("session_start");
513
- const switchModel = async (filter) => {
514
- const fresh = await (0, detect_1.detectAll)();
515
- if (!fresh.length) {
516
- tui.error("No backends reachable right now.");
517
- return;
518
- }
519
- const options = fresh.map((m) => ({
520
- label: m.id,
521
- hint: m.backend === "ollama"
522
- ? "ollama"
523
- : `lm studio${m.loaded ? ` · ctx ${m.contextWindow.toLocaleString()}` : " · not loaded"}`,
524
- current: m.id === chosen.id && m.backend === chosen.backend,
525
- }));
526
- const idx = await tui.select("Select model", options);
527
- if (idx === null)
322
+ session.announce();
323
+ tui.println("");
324
+ await session.run();
325
+ }
326
+ // ---- web hub (--web) -------------------------------------------------------
327
+ /** The home folder or a drive root is not a project: launching there opens
328
+ * the hub with the sidebar and lets the user pick a workspace. */
329
+ function isHomeOrRoot(p) {
330
+ const norm = (s) => (process.platform === "win32" ? s.toLowerCase() : s);
331
+ const r = path.resolve(p);
332
+ return norm(r) === norm(os.homedir()) || path.dirname(r) === r;
333
+ }
334
+ async function runWeb(args) {
335
+ console.log(`${util_1.c.bold("smol")}${util_1.c.dim(util_1.c.bold("coder"))} ${util_1.c.dim("v" + VERSION + " · web")}`);
336
+ const port = args.webPort ?? DEFAULT_WEB_PORT;
337
+ const workspace = args.workspace;
338
+ const autoStart = !!args.workspaceGiven || !isHomeOrRoot(workspace);
339
+ // A hub is already running: hand it this folder instead of starting another.
340
+ const rec = (0, hub_1.readHubRecord)();
341
+ if (rec && (args.webPort === undefined || rec.port === port) && (await (0, hub_1.pingHub)(rec))) {
342
+ const r = await (0, hub_1.askHubToOpen)(rec, workspace, autoStart);
343
+ if (r) {
344
+ const url = `http://127.0.0.1:${rec.port}/?k=${rec.token}${r.id ? "#" + r.id : ""}`;
345
+ console.log(`\n ${autoStart ? "started a session for" : "added"} ${workspace} in the running web UI:\n ${url}\n`);
528
346
  return;
529
- tui.startSpinner(`loading ${fresh[idx].id}`);
530
- const next = await (0, detect_1.resolveContextWindow)(fresh[idx], args.ctx);
531
- tui.stopSpinner();
532
- chosen = next;
533
- const p = makeProvider(next);
534
- p.setEffort(effort);
535
- agent.setProvider(p);
536
- ctxMgr.setWindow(next.contextWindow, p.maxOutputTokens);
537
- persist();
538
- tui.println(sessionLine(next, agent.mode));
539
- if (next.note)
540
- tui.warn(` ${next.note}`);
541
- {
542
- const advice = effortAdvice(next, effort);
543
- if (advice)
544
- tui.warn(` ${advice}`);
545
- }
546
- void filter;
547
- };
548
- const setMode = async (arg) => {
549
- let next = arg === "ro"
550
- ? "ro"
551
- : arg === "edit" || arg === "write"
552
- ? "edit"
553
- : arg === "bypass" || arg === "yolo"
554
- ? "bypass"
555
- : undefined;
556
- if (!next) {
557
- const idx = await tui.select("Select mode", [
558
- { label: "read-only", hint: "read and search files only", current: agent.mode === "ro" },
559
- {
560
- label: "edit",
561
- hint: "edit files; run commands inside the workspace, ask y/n for anything outside it",
562
- current: agent.mode === "edit",
563
- },
564
- {
565
- label: "bypass permissions",
566
- hint: "full access, never asks for approval",
567
- current: agent.mode === "bypass",
568
- },
569
- ]);
570
- if (idx === null)
571
- return;
572
- next = MODE_ORDER[idx];
573
- }
574
- agent.setMode(next, sysPrompt(next));
575
- persist();
576
- };
577
- const setEffort = async (arg) => {
578
- const levels = ["default", "off", "low", "medium", "high"];
579
- let next;
580
- if (arg && levels.includes(arg)) {
581
- next = arg === "default" ? null : arg;
582
- }
583
- else {
584
- const idx = await tui.select("Reasoning effort", [
585
- {
586
- label: "default",
587
- hint: chosen.reasoning?.default ? `the model's own default (${chosen.reasoning.default})` : "leave it to the model",
588
- current: effort === null,
589
- },
590
- { label: "off", hint: "no thinking — fastest, best for long tool loops", current: effort === "off" },
591
- { label: "low", hint: "brief reasoning", current: effort === "low" },
592
- { label: "medium", hint: "", current: effort === "medium" },
593
- { label: "high", hint: "most thorough — slow on local models", current: effort === "high" },
594
- ]);
595
- if (idx === null)
596
- return;
597
- next = idx === 0 ? null : levels[idx];
598
- }
599
- effort = next;
600
- agent.provider.setEffort(effort);
601
- persist();
602
- const label = agent.provider.effortLabel();
603
- tui.status(`· effort ${label ?? effort ?? "default"}`);
604
- const advice = effortAdvice(chosen, effort);
605
- if (advice)
606
- tui.warn(` ${advice}`);
607
- };
608
- for (;;) {
609
- const input = await tui.readInput();
610
- if (input.startsWith("/")) {
611
- const [cmd, ...rest] = input.slice(1).split(/\s+/);
612
- const arg = rest[0];
613
- switch (cmd) {
614
- case "exit":
615
- case "quit":
616
- case "q":
617
- await shutdown();
618
- return;
619
- case "help":
620
- tui.println(HELP);
621
- break;
622
- case "models":
623
- case "model":
624
- await switchModel(arg);
625
- break;
626
- case "mode":
627
- await setMode(arg);
628
- break;
629
- case "effort":
630
- await setEffort(arg);
631
- break;
632
- case "plan":
633
- if (toolCtx.plan.exists)
634
- tui.planUpdated(toolCtx.plan);
635
- else
636
- tui.status("· no plan yet — the agent creates one when it starts a multi-step task");
637
- break;
638
- case "tasks":
639
- tui.println(taskManager.list());
640
- break;
641
- case "logs":
642
- tui.println(taskManager.logs(arg ?? "", Number(rest[1]) || 50));
643
- break;
644
- case "stop":
645
- tui.println(taskManager.stop(arg ?? ""));
646
- break;
647
- case "compact":
648
- tui.startSpinner("compacting");
649
- await agent.compactNow();
650
- tui.stopSpinner();
651
- tui.status(`· compacted — ctx now ${agent.contextPercent()}%`);
652
- break;
653
- case "context":
654
- tui.status(`· ctx ${agent.contextPercent()}% of ${chosen.contextWindow.toLocaleString()} tokens · ${agent.messages.length} messages`);
655
- break;
656
- case "clear":
657
- agent.resetTranscript();
658
- toolCtx.plan.reset();
659
- tui.status("· conversation cleared");
660
- break;
661
- default:
662
- tui.warn(`Unknown command /${cmd} — try /help`);
663
- }
664
- continue;
665
- }
666
- try {
667
- await agent.runTurn(input);
668
347
  }
669
- catch (err) {
670
- tui.error(`\n${err?.message ?? err}`);
671
- if (String(err?.message ?? "").toLowerCase().includes("does not support tools")) {
672
- tui.warn("This model does not support tool calling. Pick a tool-capable model with /models (e.g. qwen3, llama3.1, mistral-nemo).");
673
- }
348
+ }
349
+ const hub = new hub_1.WebHub({ port, prefs: prefsOf(args), help: HELP, version: VERSION });
350
+ try {
351
+ await hub.start();
352
+ }
353
+ catch (err) {
354
+ if (err?.code === "EADDRINUSE") {
355
+ console.error(`\nPort ${port} is already in use. Pick another with: smol --web ${port + 1}`);
356
+ process.exit(1);
674
357
  }
358
+ throw err;
675
359
  }
360
+ process.on("exit", () => hub.shutdownSync());
361
+ installSignalCleanup(() => hub.shutdownSync());
362
+ if (autoStart)
363
+ hub.openSession(workspace);
364
+ console.log(`\n smolcoder web UI: ${hub.url()}\n ${autoStart ? `workspace ${workspace}` : "pick a workspace in the sidebar"} · ctrl+c stops the server\n`);
676
365
  }
677
366
  main().catch((err) => {
678
367
  console.error(err);