@alisio/alisio-code 0.1.0-alpha.20 → 0.1.0-alpha.22

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/main.js CHANGED
@@ -200,7 +200,7 @@ async function run(cmd, prompt, sessionId) {
200
200
  process.stderr.write(`[startup] ${JSON.stringify(d)}\n`);
201
201
  }
202
202
  if (!opts.quiet)
203
- console.log("Alisio · /exit /new /skill:name /command plugin.id:name args");
203
+ console.log("Alisio · /exit /new /btw question /skill:name /command plugin.id:name args");
204
204
  process.stdout.write("\nalisio › ");
205
205
  try {
206
206
  for await (const rawLine of rl) {
@@ -217,6 +217,22 @@ async function run(cmd, prompt, sessionId) {
217
217
  process.stdout.write("\nalisio › ");
218
218
  continue;
219
219
  }
220
+ if (line === "/btw" || line.startsWith("/btw ")) {
221
+ // Side question: printed here only, never added to the conversation.
222
+ try {
223
+ const { CommandCatalog } = await import("@alisio/core");
224
+ const result = await new CommandCatalog(app).execute("btw", line.slice(4), {
225
+ sessionId: session,
226
+ signal: controller.signal,
227
+ });
228
+ console.log(result.text ?? "");
229
+ }
230
+ catch (e) {
231
+ console.error(e instanceof Error ? e.message : String(e));
232
+ }
233
+ process.stdout.write("\nalisio › ");
234
+ continue;
235
+ }
220
236
  if (line.startsWith("/command ")) {
221
237
  const [name, ...args] = line.slice(9).split(" ");
222
238
  const handler = app.plugins.commands.get(name ?? "");
@@ -260,6 +276,19 @@ program
260
276
  .argument("[prompt]")
261
277
  .action((id, prompt, _opts, cmd) => run(cmd, prompt, id));
262
278
  program.action((_opts, cmd) => run(cmd));
279
+ program
280
+ .command("serve")
281
+ .description("Start the local web UI server (HTTP + SSE)")
282
+ .option("--port <port>", "Port to listen on (0 picks a free one)", "4317")
283
+ .option("--host <address>", "Address to bind; non-loopback requires --allow-remote", "127.0.0.1")
284
+ .option("--allow-remote", "Allow a non-loopback --host (no TLS; prefer an SSH tunnel)")
285
+ .option("--no-open", "Do not open the browser")
286
+ .option("--max-workspaces <n>", "Maximum workspaces open at once", "4")
287
+ .option("--max-runs <n>", "Maximum concurrent runs across all sessions", "4")
288
+ .action(async (flags, cmd) => {
289
+ const { serve } = await import("./serve.js");
290
+ await serve(flags, { ...(await cliDefaults()), ...options(cmd) }, VERSION);
291
+ });
263
292
  program
264
293
  .command("setup")
265
294
  .description("Write an example configuration without secrets (for AGENTS.md use /init)")
@@ -0,0 +1,15 @@
1
+ import type { AppOptions } from "@alisio/core";
2
+ export interface ServeFlags {
3
+ port: string;
4
+ host: string;
5
+ allowRemote?: boolean;
6
+ open: boolean;
7
+ maxWorkspaces: string;
8
+ maxRuns: string;
9
+ }
10
+ /** The operating system's URL opener for `platform`, as `[command, ...args]`. */
11
+ export declare function browserCommand(platform: NodeJS.Platform, url: string): string[];
12
+ /** Opens `url` in the default browser; failures are reported, never fatal. */
13
+ export declare function openBrowser(url: string): void;
14
+ /** Starts the server, prints the launch URL and stops cleanly on SIGINT/SIGTERM. */
15
+ export declare function serve(flags: ServeFlags, app: AppOptions & Record<string, unknown>, version: string): Promise<void>;
package/dist/serve.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `alisio serve`: loaded only by the serve action, and it loads `@alisio/server` only then, so
3
+ * `alisio`, `alisio run` and the TUI never load the HTTP server (spec §3.4, T-12).
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ /** The operating system's URL opener for `platform`, as `[command, ...args]`. */
7
+ export function browserCommand(platform, url) {
8
+ if (platform === "darwin")
9
+ return ["open", url];
10
+ if (platform === "win32")
11
+ return ["cmd", "/c", "start", '""', url];
12
+ return ["xdg-open", url];
13
+ }
14
+ /** Opens `url` in the default browser; failures are reported, never fatal. */
15
+ export function openBrowser(url) {
16
+ const [command = "", ...args] = browserCommand(process.platform, url);
17
+ try {
18
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
19
+ child.on("error", () => process.stderr.write("Could not open a browser; open the URL above manually.\n"));
20
+ child.unref();
21
+ }
22
+ catch {
23
+ process.stderr.write("Could not open a browser; open the URL above manually.\n");
24
+ }
25
+ }
26
+ /** Parsed option keys that are not `AppOptions` (serve flags and UI-only globals). */
27
+ const SERVE_ONLY_KEYS = new Set([
28
+ "port",
29
+ "host",
30
+ "allowRemote",
31
+ "open",
32
+ "maxWorkspaces",
33
+ "maxRuns",
34
+ "json",
35
+ "quiet",
36
+ "banner",
37
+ "tui",
38
+ "baseUrl",
39
+ "herdr",
40
+ "disablePlugin",
41
+ "addDir",
42
+ "agents",
43
+ ]);
44
+ function positiveInt(value, flag, min = 1) {
45
+ const parsed = Number(value);
46
+ if (!Number.isInteger(parsed) || parsed < min)
47
+ throw new Error(`${flag} expects an integer >= ${min}, got "${value}"`);
48
+ return parsed;
49
+ }
50
+ /** Starts the server, prints the launch URL and stops cleanly on SIGINT/SIGTERM. */
51
+ export async function serve(flags, app, version) {
52
+ const port = positiveInt(flags.port, "--port", 0);
53
+ if (port > 65_535)
54
+ throw new Error(`--port expects a value up to 65535, got "${flags.port}"`);
55
+ const { startServer, isLoopbackHost } = await import("@alisio/server");
56
+ if (!isLoopbackHost(flags.host) && !flags.allowRemote)
57
+ throw new Error(`--host ${flags.host} is not a loopback address: add --allow-remote to expose the server on the network (no TLS; prefer an SSH tunnel).`);
58
+ // Global CLI options minus the serve flags and the UI-only ones: the rest are app options.
59
+ const { cwd, onEvent: _onEvent, approve: _approve, approveExternalDirectory: _approveDirectory, ...rest } = app;
60
+ const base = Object.fromEntries(Object.entries(rest).filter(([key]) => !SERVE_ONLY_KEYS.has(key)));
61
+ let server;
62
+ try {
63
+ server = await startServer({
64
+ host: flags.host,
65
+ port,
66
+ allowRemote: !!flags.allowRemote,
67
+ version,
68
+ maxOpenWorkspaces: positiveInt(flags.maxWorkspaces, "--max-workspaces"),
69
+ maxConcurrentRuns: positiveInt(flags.maxRuns, "--max-runs"),
70
+ defaultWorkspace: cwd ?? process.cwd(),
71
+ app: base,
72
+ });
73
+ }
74
+ catch (error) {
75
+ if (error.code === "EADDRINUSE")
76
+ throw new Error(`Port ${port} is already in use: choose another with --port <n> (0 picks a free one).`);
77
+ throw error;
78
+ }
79
+ process.stdout.write(`Alisio web UI: ${server.launchUrl}\n`);
80
+ if (!isLoopbackHost(flags.host))
81
+ process.stderr.write(`Warning: listening on ${flags.host} (--allow-remote). Anyone who can reach this address and obtains the token can drive the agent; traffic is not encrypted.\n`);
82
+ if (!server.webInstalled)
83
+ process.stderr.write("The web UI assets are not installed in this build; only the HTTP API is served.\n");
84
+ process.stderr.write("Press Ctrl+C to stop.\n");
85
+ if (flags.open)
86
+ openBrowser(server.launchUrl);
87
+ let stopping = false;
88
+ const stop = () => {
89
+ if (stopping)
90
+ process.exit(130);
91
+ stopping = true;
92
+ process.stderr.write("Stopping Alisio server…\n");
93
+ // Safety net: shutdown is bounded internally; never hang the terminal.
94
+ setTimeout(() => process.exit(1), 8_000).unref();
95
+ void server.close().finally(() => process.exit(0));
96
+ };
97
+ process.on("SIGINT", stop);
98
+ process.on("SIGTERM", stop);
99
+ }
@@ -1,66 +1,5 @@
1
1
  /**
2
- * Pure logic for the ACTIVE (main-session) agent: the built-in catalog, user/plugin
3
- * contributions, resolution and the per-run options an agent implies. No terminal or pi-tui
4
- * imports here — the Picker and status-line rendering live in the TUI shell.
5
- *
6
- * The active agent drives the MAIN session (its `instructions` are appended per run and an
7
- * agent-specific `model` is offered on selection). This is deliberately separate from the
8
- * delegated child-session agents of the subagents plugin: a subagent definition becomes a main
9
- * agent only when it is marked main-capable (`mode: primary|all`), which the subagents plugin
10
- * publishes for the host to read.
2
+ * The ACTIVE (main-session) agent logic lives in `@alisio/core` (`agents/active.ts`) so every
3
+ * surface (TUI, headless, server) resolves agents the same way. Re-exported for compatibility.
11
4
  */
12
- export interface ActiveAgent {
13
- /** Stable id, also the persisted `agents.active` value. */
14
- id: string;
15
- name: string;
16
- description: string;
17
- /** System prompt appended to the main session's instructions on every run. */
18
- instructions?: string;
19
- /** Optional default model selector (`provider/model` or an unambiguous model id). */
20
- model?: string;
21
- /** Read-only agents run with write/process/external tools disabled and approvals off. */
22
- readOnly?: boolean;
23
- /** Product built-ins are shown as built-in and never shadowed by contributed definitions. */
24
- source: "builtin" | "user" | "plugin";
25
- }
26
- export declare const DEFAULT_AGENT_ID = "build";
27
- /** Product built-ins. `build` keeps the CURRENT behavior exactly: no added persona, full power. */
28
- export declare const BUILTIN_AGENTS: ActiveAgent[];
29
- export interface MainCapableAgentRecord {
30
- name: string;
31
- description: string;
32
- prompt: string;
33
- model?: string;
34
- readOnly?: boolean;
35
- source: string;
36
- path?: string;
37
- }
38
- /** Maps a main-capable definition published by the subagents plugin to an active agent. */
39
- export declare function mainAgentFromRecord(record: MainCapableAgentRecord): ActiveAgent;
40
- /** Builds the selectable catalog from the subagents plugin's published state (best-effort). */
41
- export declare function agentCatalogFromState(untrusted: unknown): ActiveAgent[];
42
- /**
43
- * The full selectable catalog: built-ins first, then contributed main-capable definitions.
44
- * A contributed definition may never shadow a built-in id; later duplicates of a contributed id
45
- * are dropped (the subagents plugin already resolved precedence by its own discovery order).
46
- */
47
- export declare function activeAgentCatalog(contributions?: ActiveAgent[]): ActiveAgent[];
48
- /** Resolves the persisted agent id; unknown ids fall back to the built-in default (`build`). */
49
- export declare function resolveActiveAgent(agents: ActiveAgent[], id: string | undefined): ActiveAgent;
50
- /** The run options an active agent implies. An undefined agent yields the current behavior. */
51
- export declare function agentRunOptions(agent: ActiveAgent | undefined): {
52
- instructions?: string;
53
- policy?: {
54
- write: false;
55
- process: false;
56
- external: false;
57
- };
58
- approvals?: false;
59
- };
60
- export interface AgentPickerItem {
61
- value: string;
62
- label: string;
63
- description: string;
64
- }
65
- /** Picker rows for `/agents`: name with (current), built-in/default and read-only markers. */
66
- export declare function agentPickerItems(agents: ActiveAgent[], activeId: string): AgentPickerItem[];
5
+ export { type ActiveAgent, type AgentPickerItem, activeAgentCatalog, agentCatalogFromState, agentPickerItems, agentRunOptions, BUILTIN_AGENTS, DEFAULT_AGENT_ID, type MainCapableAgentRecord, mainAgentFromRecord, resolveActiveAgent, } from "@alisio/core";
@@ -1,113 +1,5 @@
1
- export const DEFAULT_AGENT_ID = "build";
2
- const PLAN_INSTRUCTIONS = `You are the "plan" agent: a read-only planning assistant for this main session.
3
-
4
- Your job is analysis and planning only. Investigate the repository with read-only tools
5
- (list_files, search_text, read_file, git_status, git_diff) and return a concrete, ordered plan:
6
- files to change, functions to touch, risks and verification steps. You may run context and
7
- inspection tools that do not modify anything.
8
-
9
- You never edit, write or delete files, and you never start processes that change the workspace
10
- or the network. If a task requires changes, hand back a precise plan (and the exact commands)
11
- instead of making them.`;
12
- /** Product built-ins. `build` keeps the CURRENT behavior exactly: no added persona, full power. */
13
- export const BUILTIN_AGENTS = [
14
- {
15
- id: DEFAULT_AGENT_ID,
16
- name: "build",
17
- description: "Default full-power agent: edits code and files, runs processes and verifies its work through the normal permission flow.",
18
- source: "builtin",
19
- },
20
- {
21
- id: "plan",
22
- name: "plan",
23
- description: "Read-only planning agent: analyzes the codebase and returns an implementation plan without modifying anything.",
24
- instructions: PLAN_INSTRUCTIONS,
25
- readOnly: true,
26
- source: "builtin",
27
- },
28
- ];
29
- /** Maps a main-capable definition published by the subagents plugin to an active agent. */
30
- export function mainAgentFromRecord(record) {
31
- const source = String(record.source ?? "");
32
- return {
33
- id: record.name,
34
- name: record.name,
35
- description: record.description,
36
- ...(record.prompt ? { instructions: record.prompt } : {}),
37
- ...(record.model ? { model: record.model } : {}),
38
- ...(record.readOnly ? { readOnly: true } : {}),
39
- ...(source.startsWith("plugin:")
40
- ? { source: "plugin" }
41
- : { source: "user" }),
42
- };
43
- }
44
- /** Builds the selectable catalog from the subagents plugin's published state (best-effort). */
45
- export function agentCatalogFromState(untrusted) {
46
- if (!Array.isArray(untrusted))
47
- return activeAgentCatalog([]);
48
- const records = untrusted.filter((entry) => !!entry &&
49
- typeof entry === "object" &&
50
- typeof entry.name === "string" &&
51
- typeof entry.description === "string" &&
52
- entry.description.length > 0 &&
53
- typeof entry.prompt === "string");
54
- return activeAgentCatalog(records.map(mainAgentFromRecord));
55
- }
56
1
  /**
57
- * The full selectable catalog: built-ins first, then contributed main-capable definitions.
58
- * A contributed definition may never shadow a built-in id; later duplicates of a contributed id
59
- * are dropped (the subagents plugin already resolved precedence by its own discovery order).
2
+ * The ACTIVE (main-session) agent logic lives in `@alisio/core` (`agents/active.ts`) so every
3
+ * surface (TUI, headless, server) resolves agents the same way. Re-exported for compatibility.
60
4
  */
61
- export function activeAgentCatalog(contributions = []) {
62
- const seen = new Set();
63
- const out = [];
64
- for (const agent of [...BUILTIN_AGENTS, ...contributions]) {
65
- if (seen.has(agent.id))
66
- continue;
67
- seen.add(agent.id);
68
- out.push(agent);
69
- }
70
- return out;
71
- }
72
- /** Resolves the persisted agent id; unknown ids fall back to the built-in default (`build`). */
73
- export function resolveActiveAgent(agents, id) {
74
- const found = id ? agents.find((a) => a.id === id) : undefined;
75
- if (found)
76
- return found;
77
- return agents.find((a) => a.id === DEFAULT_AGENT_ID) ?? agents[0] ?? BUILTIN_AGENTS[0];
78
- }
79
- /** The run options an active agent implies. An undefined agent yields the current behavior. */
80
- export function agentRunOptions(agent) {
81
- if (!agent)
82
- return {};
83
- return {
84
- ...(agent.instructions ? { instructions: agent.instructions } : {}),
85
- ...(agent.readOnly
86
- ? {
87
- policy: { write: false, process: false, external: false },
88
- approvals: false,
89
- }
90
- : {}),
91
- };
92
- }
93
- /** Picker rows for `/agents`: name with (current), built-in/default and read-only markers. */
94
- export function agentPickerItems(agents, activeId) {
95
- return agents.map((agent) => ({
96
- value: agent.id,
97
- label: [
98
- agent.name,
99
- agent.id === activeId ? "(current)" : undefined,
100
- agent.id === DEFAULT_AGENT_ID ? "(default)" : undefined,
101
- agent.readOnly ? "read-only" : undefined,
102
- ]
103
- .filter(Boolean)
104
- .join(" · "),
105
- description: [
106
- agent.description,
107
- agent.model ? `default model: ${agent.model}` : undefined,
108
- agent.instructions ? "injects its system prompt from the next prompt" : undefined,
109
- ]
110
- .filter(Boolean)
111
- .join(" · "),
112
- }));
113
- }
5
+ export { activeAgentCatalog, agentCatalogFromState, agentPickerItems, agentRunOptions, BUILTIN_AGENTS, DEFAULT_AGENT_ID, mainAgentFromRecord, resolveActiveAgent, } from "@alisio/core";
package/dist/tui/app.js CHANGED
@@ -3,8 +3,9 @@ import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, ge
3
3
  import { loadVersion } from "../version.js";
4
4
  import { activeAgentCatalog, agentPickerItems, agentRunOptions, mainAgentFromRecord, resolveActiveAgent, } from "./agents.js";
5
5
  import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard, removeLastAttachment, toApiAttachment, } from "./attachments.js";
6
+ import { BTW_DESCRIPTION, BTW_USAGE, btwBrowser, btwPending } from "./btw.js";
6
7
  import { copyText, nodeSpawn } from "./clipboard.js";
7
- import { AttachmentsBar, BannerBlock, ContentInset, clock, contentInnerWidth, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
8
+ import { AttachmentsBar, BannerBlock, BtwPanel, ContentInset, clock, contentInnerWidth, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
8
9
  import { ConnectInputPrompt } from "./connect-input.js";
9
10
  import { bounded, EXIT_PENDING_CAP_MS, EXIT_SESSION_END_CAP_MS } from "./exit.js";
10
11
  import { BRANCH_REFRESH_MS, createBranchCache } from "./git-branch.js";
@@ -136,7 +137,7 @@ export class Picker {
136
137
  }
137
138
  }
138
139
  export async function runTui(options) {
139
- const { createApplication } = await import("@alisio/core");
140
+ const { createApplication, CommandCatalog } = await import("@alisio/core");
140
141
  const { BUILTIN_PLUGINS } = await import("../builtin.js");
141
142
  const { BUILTIN_PROMPTS } = await import("../prompts/index.js");
142
143
  let dispatch = () => { };
@@ -473,7 +474,7 @@ export async function runTui(options) {
473
474
  // pulls the persisted rich parts (ui/image) itself so native rendering survives resume.
474
475
  if (event.type === "tool_completed") {
475
476
  const data = (event.data ?? {});
476
- const rich = richPartsOf(app.store.callResult(event.sessionId, String(data.id ?? "")));
477
+ const rich = richPartsOf(app.store.callResult(event.sessionId, String(data.id ?? "")), String(data.name ?? ""));
477
478
  if (rich.ui !== undefined || rich.image !== undefined)
478
479
  event = { ...event, data: { ...data, ...rich } };
479
480
  }
@@ -1381,22 +1382,6 @@ export async function runTui(options) {
1381
1382
  notice(`Resumed session ${found.id}`);
1382
1383
  refreshEstimate();
1383
1384
  };
1384
- const toolsReport = () => {
1385
- const policy = app.runner.policy;
1386
- const rows = app.registry
1387
- .list()
1388
- .map((t) => {
1389
- const effect = t.effect ?? "external";
1390
- const state = effect === "read" || effect === "internal" || policy[effect]
1391
- ? "enabled"
1392
- : app.runner.approvals && (effect === "write" || effect === "process")
1393
- ? "ask"
1394
- : "disabled";
1395
- return `| \`${t.name}\` | ${effect} | ${state} |`;
1396
- })
1397
- .join("\n");
1398
- return `**Tools**\n\n| Tool | Effect | State |\n| --- | --- | --- |\n${rows}\n\n\`ask\` prompts before running (allow once / session / deny). Use --allow-write / --allow-process to pre-allow; --read-only disables them. Paths outside the workspace ask per directory; pre-allow with --add-dir or the \`additionalDirectories\` config key. \`internal\` tools (built-in plugins) only write Alisio's own state.`;
1399
- };
1400
1385
  const statsReport = () => {
1401
1386
  const s = view.stats;
1402
1387
  const budget = app.contextBudget(view.model);
@@ -1456,6 +1441,58 @@ export async function runTui(options) {
1456
1441
  "**Keys**: Enter send · Shift+Enter / Alt+Enter / Ctrl+J newline · Tab complete · ↑↓ history · Esc interrupt · Ctrl+C clear input (twice to exit) · Ctrl+D exit on empty input · c / y copy last response (empty input) · x expand/collapse latest thought/tool batch/output (empty input) · click a collapsible header row to toggle it · PgUp/PgDn or mouse wheel scroll · Ctrl+X agent panel · Ctrl+B background running agents",
1457
1442
  `**Paste**: multi-line text pastes as one block automatically · Ctrl+V attach a clipboard image (PNG/JPEG/GIF/WebP, up to ${(MAX_IMAGE_BYTES / (1024 * 1024)).toFixed(0)} MB, up to ${MAX_ATTACHMENTS_PER_MESSAGE} per message) · Ctrl+R remove the last attached image`,
1458
1443
  ].join("\n");
1444
+ // `/btw` side questions: answered in the picker slot, never appended to the transcript. One
1445
+ // question is in flight at a time; Esc cancels it (independent of the run's controller, so a
1446
+ // side question also works, and is cancelled on its own, while a turn is running).
1447
+ let btwController;
1448
+ let btwPanel;
1449
+ const btwHeight = () => Math.max(4, Math.floor((process.stdout.rows ?? 30) / 2) - 6);
1450
+ const closeBtw = () => {
1451
+ btwController?.abort(new Error("Cancelled"));
1452
+ btwController = undefined;
1453
+ if (btwPanel && picker === btwPanel)
1454
+ closePicker();
1455
+ btwPanel = undefined;
1456
+ };
1457
+ const sideQuestion = async (question) => {
1458
+ const asked = session;
1459
+ const entries = app.sideQuestions.history(asked);
1460
+ if (!question) {
1461
+ const state = btwBrowser(entries);
1462
+ if (!state)
1463
+ return notice(`${BTW_USAGE}\n${BTW_DESCRIPTION}`);
1464
+ btwPanel = new BtwPanel(state, closeBtw, btwHeight);
1465
+ return showPicker(btwPanel);
1466
+ }
1467
+ btwController?.abort(new Error("Cancelled"));
1468
+ const controller = new AbortController();
1469
+ btwController = controller;
1470
+ const panel = new BtwPanel(btwPending(entries, question), closeBtw, btwHeight);
1471
+ btwPanel = panel;
1472
+ showPicker(panel);
1473
+ try {
1474
+ await app.sideQuestions.ask(asked, question, { signal: controller.signal });
1475
+ if (controller.signal.aborted)
1476
+ return;
1477
+ panel.dispatch({ type: "answered", entries: app.sideQuestions.history(asked) });
1478
+ // An approval may have taken the slot meanwhile: the answer is in the history anyway.
1479
+ if (picker !== panel)
1480
+ flashHint("Side answer ready: /btw to view it", 4000);
1481
+ }
1482
+ catch (e) {
1483
+ if (controller.signal.aborted)
1484
+ return;
1485
+ panel.dispatch({ type: "failed", message: e instanceof Error ? e.message : String(e) });
1486
+ if (picker !== panel)
1487
+ error(e);
1488
+ }
1489
+ finally {
1490
+ if (btwController === controller)
1491
+ btwController = undefined;
1492
+ tui.requestRender();
1493
+ }
1494
+ };
1495
+ const commandCatalog = new CommandCatalog(app);
1459
1496
  const mutating = new Set([
1460
1497
  "connect",
1461
1498
  "model",
@@ -1508,7 +1545,11 @@ export async function runTui(options) {
1508
1545
  case "stats":
1509
1546
  return info(statsReport());
1510
1547
  case "tools":
1511
- return info(toolsReport());
1548
+ case "sessions": {
1549
+ // Text-identical core handlers (parity-tested): output comes from the CommandCatalog.
1550
+ const result = await commandCatalog.execute(name, parsed.args, { sessionId: session });
1551
+ return result.tone === "notice" ? notice(result.text ?? "") : info(result.text ?? "");
1552
+ }
1512
1553
  case "connect":
1513
1554
  return await connect();
1514
1555
  case "model":
@@ -1532,6 +1573,8 @@ export async function runTui(options) {
1532
1573
  tui.flash(message);
1533
1574
  return result.ok ? undefined : notice(message);
1534
1575
  }
1576
+ case "btw":
1577
+ return await sideQuestion(parsed.args);
1535
1578
  case "ask": {
1536
1579
  if (!parsed.args)
1537
1580
  return notice("Usage: /ask <question>");
@@ -1575,16 +1618,6 @@ export async function runTui(options) {
1575
1618
  void app.herdr.report("idle", session);
1576
1619
  return refreshEstimate();
1577
1620
  }
1578
- case "sessions": {
1579
- const list = workspaceSessions().slice(0, 20);
1580
- if (!list.length)
1581
- return notice("No sessions in this workspace");
1582
- return info([
1583
- "**Recent sessions** (use `/resume <id-prefix>`)",
1584
- "",
1585
- ...list.map((s) => `- \`${s.id}\` ${s.id === session ? "**(current)** " : ""}${s.model} · ${app.store.messages(s.id).length} msgs · ${firstPrompt(s.id) || "_empty_"}`),
1586
- ].join("\n"));
1587
- }
1588
1621
  case "resume":
1589
1622
  if (parsed.args)
1590
1623
  return resume(parsed.args);
@@ -1855,6 +1888,7 @@ export async function runTui(options) {
1855
1888
  const ticker = setInterval(() => {
1856
1889
  clock.now = Date.now();
1857
1890
  if (busy ||
1891
+ btwPanel?.pending ||
1858
1892
  view.items.some((i) => i.kind === "tool" && i.status === "running") ||
1859
1893
  panelNodes().some((n) => n.status === "running")) {
1860
1894
  clock.frame++;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure state of the TUI `/btw` panel: a pending side question, its answer, and browsing earlier
3
+ * answers of the session. The panel lives in the picker slot and never touches the transcript.
4
+ *
5
+ * Keys: ←/→ earlier/later answer, ↑/↓ (and PgUp/PgDn) scroll a long answer, Esc cancels a pending
6
+ * question or closes the panel.
7
+ */
8
+ import type { SideQuestionEntry } from "@alisio/sdk";
9
+ export declare const BTW_USAGE = "Usage: /btw <question>";
10
+ export declare const BTW_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
11
+ export interface BtwState {
12
+ entries: SideQuestionEntry[];
13
+ /** Index of the entry on screen (ignored while `pending`). */
14
+ index: number;
15
+ /** A question waiting for its answer. */
16
+ pending?: {
17
+ question: string;
18
+ startedAt: number;
19
+ };
20
+ /** Why the last question failed (shown until the user navigates). */
21
+ error?: {
22
+ question: string;
23
+ message: string;
24
+ };
25
+ /** First answer line on screen. */
26
+ scroll: number;
27
+ }
28
+ export type BtwAction = {
29
+ type: "prev";
30
+ } | {
31
+ type: "next";
32
+ } | {
33
+ type: "scroll";
34
+ lines: number;
35
+ } | {
36
+ type: "answered";
37
+ entries: SideQuestionEntry[];
38
+ } | {
39
+ type: "failed";
40
+ message: string;
41
+ };
42
+ /** The browser for `/btw` without a question: the newest entry, or undefined when none. */
43
+ export declare function btwBrowser(entries: SideQuestionEntry[]): BtwState | undefined;
44
+ /** The panel while `question` is being answered (earlier entries stay browsable afterwards). */
45
+ export declare function btwPending(entries: SideQuestionEntry[], question: string, now?: number): BtwState;
46
+ export declare function reduceBtw(state: BtwState, action: BtwAction): BtwState;
47
+ /** The entry on screen, if the panel is not pending or showing an error. */
48
+ export declare function btwCurrent(state: BtwState): SideQuestionEntry | undefined;
49
+ /** "2/5" for the entry on screen; empty while pending or on an error. */
50
+ export declare function btwPosition(state: BtwState): string;
51
+ /** Footer hints for the current state. */
52
+ export declare function btwHints(state: BtwState, overflow: boolean): string;
53
+ /** "model · 12 in · 3 out tokens" for an entry. */
54
+ export declare function btwUsageLine(entry: SideQuestionEntry): string;
55
+ /** The visible slice of `lines` for `scroll` (clamped) and whether more lines exist. */
56
+ export declare function btwWindow(lines: string[], scroll: number, height: number): {
57
+ lines: string[];
58
+ scroll: number;
59
+ above: boolean;
60
+ below: boolean;
61
+ };
@@ -0,0 +1,84 @@
1
+ export const BTW_USAGE = "Usage: /btw <question>";
2
+ export const BTW_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
3
+ /** The browser for `/btw` without a question: the newest entry, or undefined when none. */
4
+ export function btwBrowser(entries) {
5
+ return entries.length ? { entries, index: entries.length - 1, scroll: 0 } : undefined;
6
+ }
7
+ /** The panel while `question` is being answered (earlier entries stay browsable afterwards). */
8
+ export function btwPending(entries, question, now = Date.now()) {
9
+ return {
10
+ entries,
11
+ index: Math.max(0, entries.length - 1),
12
+ pending: { question, startedAt: now },
13
+ scroll: 0,
14
+ };
15
+ }
16
+ export function reduceBtw(state, action) {
17
+ switch (action.type) {
18
+ case "prev":
19
+ case "next": {
20
+ if (state.pending || !state.entries.length)
21
+ return state;
22
+ const step = action.type === "prev" ? -1 : 1;
23
+ // Leaving an error shows the stored entries again (the newest first).
24
+ const from = state.error ? state.entries.length : state.index;
25
+ const index = Math.min(state.entries.length - 1, Math.max(0, from + step));
26
+ if (index === state.index && !state.error)
27
+ return state;
28
+ return { entries: state.entries, index, scroll: 0 };
29
+ }
30
+ case "scroll":
31
+ return { ...state, scroll: Math.max(0, state.scroll + action.lines) };
32
+ case "answered":
33
+ return {
34
+ entries: action.entries,
35
+ index: Math.max(0, action.entries.length - 1),
36
+ scroll: 0,
37
+ };
38
+ case "failed": {
39
+ const { pending: _pending, ...rest } = state;
40
+ return {
41
+ ...rest,
42
+ error: { question: state.pending?.question ?? "", message: action.message },
43
+ scroll: 0,
44
+ };
45
+ }
46
+ }
47
+ }
48
+ /** The entry on screen, if the panel is not pending or showing an error. */
49
+ export function btwCurrent(state) {
50
+ return state.pending || state.error ? undefined : state.entries[state.index];
51
+ }
52
+ /** "2/5" for the entry on screen; empty while pending or on an error. */
53
+ export function btwPosition(state) {
54
+ return btwCurrent(state) ? `${state.index + 1}/${state.entries.length}` : "";
55
+ }
56
+ /** Footer hints for the current state. */
57
+ export function btwHints(state, overflow) {
58
+ if (state.pending)
59
+ return "Esc cancel";
60
+ const current = btwCurrent(state);
61
+ const hints = [
62
+ ...(state.entries.length > 1 || (state.error && state.entries.length)
63
+ ? ["←/→ earlier/later"]
64
+ : []),
65
+ ...(overflow ? ["↑/↓ scroll"] : []),
66
+ "Esc close",
67
+ ];
68
+ return current || state.error ? hints.join(" · ") : "Esc close";
69
+ }
70
+ /** "model · 12 in · 3 out tokens" for an entry. */
71
+ export function btwUsageLine(entry) {
72
+ return `${entry.model} · ${entry.usage.input} in · ${entry.usage.output} out tokens${entry.truncated ? " · truncated" : ""}`;
73
+ }
74
+ /** The visible slice of `lines` for `scroll` (clamped) and whether more lines exist. */
75
+ export function btwWindow(lines, scroll, height) {
76
+ const max = Math.max(0, lines.length - height);
77
+ const start = Math.min(Math.max(0, scroll), max);
78
+ return {
79
+ lines: lines.slice(start, start + height),
80
+ scroll: start,
81
+ above: start > 0,
82
+ below: start + height < lines.length,
83
+ };
84
+ }
@@ -1,6 +1,7 @@
1
1
  import type { PanelNode, TreeNode, UiBlock } from "@alisio/sdk";
2
2
  import { type Component, Container } from "@earendil-works/pi-tui";
3
3
  import { type PendingAttachment } from "./attachments.ts";
4
+ import { type BtwAction, type BtwState } from "./btw.ts";
4
5
  import { type QuestionPanelState, type QuestionSpec } from "./questions.ts";
5
6
  import { type ContextBudget, type FoldableKind, type FoldCandidate, type GroupedTool, type IdentityInput, type TranscriptEntry, type TranscriptItem, type ViewState } from "./state.ts";
6
7
  export declare const SPINNER: string[];
@@ -21,7 +22,11 @@ export declare const exitCodeLine: (code: number | undefined, indent?: string) =
21
22
  * `|-`/`` `- ``/`|` otherwise.
22
23
  */
23
24
  export declare function renderTreeLines(nodes: TreeNode[], unicode: boolean): string[];
24
- /** Renders a `{type:"ui"}` block to styled lines for the given inner width. */
25
+ /**
26
+ * Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
27
+ * persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
28
+ * or a malformed plugin block) falls back to labeled JSON instead of throwing.
29
+ */
25
30
  export declare function renderUiBlock(block: UiBlock, width: number, unicode: boolean): string[];
26
31
  export type PermissionState = "on" | "ask" | "off";
27
32
  export interface HeaderInfo {
@@ -251,3 +256,21 @@ export declare class QuestionPanel implements Component {
251
256
  handleInput(data: string): void;
252
257
  render(width: number): string[];
253
258
  }
259
+ /**
260
+ * The `/btw` side-question panel (picker slot, never the transcript): a pending question with a
261
+ * spinner, then the answer rendered as Markdown like assistant text, scrollable, with ←/→ through
262
+ * the session's earlier side answers. `onClose` runs on Esc (the app cancels a pending question).
263
+ */
264
+ export declare class BtwPanel implements Component {
265
+ private state;
266
+ private readonly onClose;
267
+ private readonly height;
268
+ private markdown?;
269
+ constructor(state: BtwState, onClose: () => void, height: () => number);
270
+ get pending(): boolean;
271
+ dispatch(action: BtwAction): void;
272
+ invalidate(): void;
273
+ handleInput(data: string): void;
274
+ private answerLines;
275
+ render(width: number): string[];
276
+ }
@@ -1,10 +1,11 @@
1
1
  import { Container, getCapabilities, getImageDimensions, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
2
  import { terminalCapabilities } from "../banner.js";
3
3
  import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
4
+ import { btwCurrent, btwHints, btwPosition, btwUsageLine, btwWindow, reduceBtw, } from "./btw.js";
4
5
  import { branchDisplay } from "./git-branch.js";
5
6
  import { initialQuestionState, reduceQuestions, } from "./questions.js";
6
- import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, previewLinesFor, previewRows, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, } from "./state.js";
7
- import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, style, } from "./theme.js";
7
+ import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, } from "./state.js";
8
+ import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, sanitizeCode, style, } from "./theme.js";
8
9
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
10
  /** Shared animation clock advanced by the app while work is running. */
10
11
  export const clock = { frame: 0, now: Date.now() };
@@ -169,8 +170,133 @@ function renderCodeBlock(block, _width) {
169
170
  style.dim("```"),
170
171
  ];
171
172
  }
172
- /** Renders a `{type:"ui"}` block to styled lines for the given inner width. */
173
+ /** Longest JSON/unknown-block text shown inline before a `… truncated` marker. */
174
+ const MAX_BLOCK_LINES = 200;
175
+ const MAX_BLOCK_CHARS = 20_000;
176
+ /** Caps free-form text by lines and characters, appending a dim marker when anything is cut. */
177
+ function capText(text) {
178
+ let out = text.length > MAX_BLOCK_CHARS ? text.slice(0, MAX_BLOCK_CHARS) : text;
179
+ const lines = out.split("\n");
180
+ if (lines.length > MAX_BLOCK_LINES)
181
+ out = lines.slice(0, MAX_BLOCK_LINES).join("\n");
182
+ return { text: out, truncated: out.length < text.length };
183
+ }
184
+ /** `JSON.stringify` that never throws and never returns `undefined`. */
185
+ function jsonText(value) {
186
+ try {
187
+ return JSON.stringify(value, null, 2) ?? String(value);
188
+ }
189
+ catch {
190
+ return String(value);
191
+ }
192
+ }
193
+ function renderCappedCode(code, lang, caption) {
194
+ const capped = capText(code);
195
+ return [
196
+ ...renderCodeBlock({
197
+ kind: "code",
198
+ code: capped.text,
199
+ ...(lang ? { lang } : {}),
200
+ ...(caption ? { caption } : {}),
201
+ }, 0),
202
+ ...(capped.truncated ? [style.gray("… truncated")] : []),
203
+ ];
204
+ }
205
+ function renderDiffBlock(block) {
206
+ const caption = [block.caption, block.path].filter(Boolean).join(" · ") || undefined;
207
+ if (block.patch !== undefined)
208
+ return renderCappedCode(block.patch, "diff", caption);
209
+ if (block.before === undefined && block.after === undefined)
210
+ return [...(caption ? [style.dim(caption)] : []), style.gray("(empty diff)")];
211
+ return [
212
+ ...(caption ? [style.dim(caption)] : []),
213
+ ...renderCappedCode(block.before ?? "", block.lang, "before"),
214
+ ...renderCappedCode(block.after ?? "", block.lang, "after"),
215
+ ];
216
+ }
217
+ function renderTerminalBlock(block) {
218
+ const head = block.command
219
+ ? [style.dim(`$ ${block.command}${block.cwd ? ` (${block.cwd})` : ""}`)]
220
+ : [];
221
+ const status = [
222
+ block.exitCode !== undefined ? `exit ${block.exitCode}` : "",
223
+ block.durationMs !== undefined ? formatDuration(block.durationMs) : "",
224
+ ]
225
+ .filter(Boolean)
226
+ .join(" · ");
227
+ const paint = block.exitCode !== undefined && block.exitCode !== 0 ? style.red : style.gray;
228
+ return [
229
+ ...head,
230
+ ...renderCappedCode(sanitizeCode(block.output), undefined),
231
+ ...(block.truncated ? [style.gray("… output truncated")] : []),
232
+ ...(status ? [paint(status)] : []),
233
+ ];
234
+ }
235
+ function renderTestResultsBlock(block, width, unicode) {
236
+ const cases = block.suites.flatMap((suite) => suite.cases.map((c) => ({ suite, c })));
237
+ const counts = new Map();
238
+ for (const { c } of cases)
239
+ counts.set(c.status, (counts.get(c.status) ?? 0) + 1);
240
+ const summary = [
241
+ block.framework,
242
+ ["passed", "failed", "skipped", "todo"]
243
+ .filter((status) => counts.get(status))
244
+ .map((status) => `${counts.get(status)} ${status}`)
245
+ .join(", ") || "no tests",
246
+ block.durationMs !== undefined ? formatDuration(block.durationMs) : undefined,
247
+ ]
248
+ .filter(Boolean)
249
+ .join(" · ");
250
+ const table = renderTableBlock({
251
+ kind: "table",
252
+ columns: ["suite", "case", "status", "time"],
253
+ rows: cases.map(({ suite, c }) => [
254
+ suite.name,
255
+ c.name,
256
+ c.status,
257
+ c.durationMs !== undefined ? formatDuration(c.durationMs) : "",
258
+ ]),
259
+ caption: summary,
260
+ }, width);
261
+ const errors = cases
262
+ .filter(({ c }) => c.status === "failed" && c.error)
263
+ .map(({ suite, c }) => style.red(`${unicode ? "✗" : "x"} ${suite.name} ${unicode ? "›" : ">"} ${c.name}${suite.file ? ` (${suite.file}${c.line !== undefined ? `:${c.line}` : ""})` : ""}: ${c.error}`));
264
+ return [...table, ...errors];
265
+ }
266
+ const PROGRESS_GLYPHS = {
267
+ unicode: { completed: "✓", running: "●", pending: "○", failed: "✗", cancelled: "⊘" },
268
+ ascii: { completed: "[x]", running: "[>]", pending: "[ ]", failed: "[!]", cancelled: "[-]" },
269
+ };
270
+ function renderProgressBlock(block, unicode) {
271
+ const glyphs = PROGRESS_GLYPHS[unicode ? "unicode" : "ascii"];
272
+ const paint = {
273
+ completed: style.green,
274
+ running: style.brightCyan,
275
+ pending: style.gray,
276
+ failed: style.red,
277
+ cancelled: style.gray,
278
+ };
279
+ return [
280
+ ...(block.title ? [style.bold(block.title)] : []),
281
+ ...(block.steps.length ? [] : [style.gray("(no steps)")]),
282
+ ...block.steps.map((step) => `${paint[step.status](glyphs[step.status])} ${step.label}${step.detail ? ` ${style.dim(step.detail)}` : ""}`),
283
+ ];
284
+ }
285
+ /** Text fallback for a kind this build does not know (or a malformed block): never throws. */
286
+ function renderUnknownBlock(block) {
287
+ const kind = block && typeof block === "object" && typeof block.kind === "string"
288
+ ? block.kind
289
+ : "unknown";
290
+ return [style.gray(`[ui block: ${kind}]`), ...renderCappedCode(jsonText(block), "json")];
291
+ }
292
+ /**
293
+ * Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
294
+ * persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
295
+ * or a malformed plugin block) falls back to labeled JSON instead of throwing.
296
+ */
173
297
  export function renderUiBlock(block, width, unicode) {
298
+ if (!isUiBlock(block))
299
+ return renderUnknownBlock(block);
174
300
  switch (block.kind) {
175
301
  case "table":
176
302
  return renderTableBlock(block, width);
@@ -186,6 +312,22 @@ export function renderUiBlock(block, width, unicode) {
186
312
  });
187
313
  return md.render(Math.max(1, width - 2));
188
314
  }
315
+ case "diff":
316
+ return renderDiffBlock(block);
317
+ case "terminal":
318
+ return renderTerminalBlock(block);
319
+ case "mermaid":
320
+ return renderCappedCode(block.source, "mermaid", block.title);
321
+ case "math":
322
+ return block.latex.split("\n");
323
+ case "json":
324
+ return renderCappedCode(jsonText(block.value), "json", block.caption);
325
+ case "test-results":
326
+ return renderTestResultsBlock(block, width, unicode);
327
+ case "progress":
328
+ return renderProgressBlock(block, unicode);
329
+ default:
330
+ return renderUnknownBlock(block);
189
331
  }
190
332
  }
191
333
  const permission = (label, state, priority) => ({
@@ -972,3 +1114,89 @@ export class QuestionPanel {
972
1114
  return fit(lines, width);
973
1115
  }
974
1116
  }
1117
+ /**
1118
+ * The `/btw` side-question panel (picker slot, never the transcript): a pending question with a
1119
+ * spinner, then the answer rendered as Markdown like assistant text, scrollable, with ←/→ through
1120
+ * the session's earlier side answers. `onClose` runs on Esc (the app cancels a pending question).
1121
+ */
1122
+ export class BtwPanel {
1123
+ state;
1124
+ onClose;
1125
+ height;
1126
+ markdown;
1127
+ constructor(state, onClose, height) {
1128
+ this.state = state;
1129
+ this.onClose = onClose;
1130
+ this.height = height;
1131
+ }
1132
+ get pending() {
1133
+ return !!this.state.pending;
1134
+ }
1135
+ dispatch(action) {
1136
+ this.state = reduceBtw(this.state, action);
1137
+ }
1138
+ invalidate() {
1139
+ this.markdown = undefined;
1140
+ }
1141
+ handleInput(data) {
1142
+ if (matchesKey(data, Key.escape))
1143
+ return this.onClose();
1144
+ if (matchesKey(data, Key.left))
1145
+ return this.dispatch({ type: "prev" });
1146
+ if (matchesKey(data, Key.right))
1147
+ return this.dispatch({ type: "next" });
1148
+ if (matchesKey(data, Key.up))
1149
+ return this.dispatch({ type: "scroll", lines: -1 });
1150
+ if (matchesKey(data, Key.down))
1151
+ return this.dispatch({ type: "scroll", lines: 1 });
1152
+ if (matchesKey(data, Key.pageUp))
1153
+ return this.dispatch({ type: "scroll", lines: -this.height() });
1154
+ if (matchesKey(data, Key.pageDown))
1155
+ return this.dispatch({ type: "scroll", lines: this.height() });
1156
+ }
1157
+ answerLines(id, text, width) {
1158
+ if (this.markdown?.id === id && this.markdown.width === width)
1159
+ return this.markdown.lines;
1160
+ const lines = new Markdown(text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
1161
+ transform: markdownTransform,
1162
+ }).render(Math.max(1, width));
1163
+ this.markdown = { id, width, lines };
1164
+ return lines;
1165
+ }
1166
+ render(width) {
1167
+ const s = this.state;
1168
+ const inner = Math.max(1, width - 2);
1169
+ const bar = style.gray("│");
1170
+ const current = btwCurrent(s);
1171
+ const position = btwPosition(s);
1172
+ const lines = [
1173
+ "",
1174
+ `${style.bold(style.yellow("btw"))}${position ? ` ${style.dim(position)}` : ""}${style.dim(" · side question, not added to the conversation")}`,
1175
+ ];
1176
+ const question = s.pending?.question ?? s.error?.question ?? current?.question ?? "";
1177
+ lines.push(...wrap(question, inner).map((l) => `${bar} ${style.bold(l)}`));
1178
+ let overflow = false;
1179
+ if (s.pending) {
1180
+ const spinner = SPINNER[clock.frame % SPINNER.length] ?? "…";
1181
+ lines.push(`${bar} ${style.cyan(spinner)} Thinking… ${style.dim(formatDuration(clock.now - s.pending.startedAt))}`);
1182
+ }
1183
+ else if (s.error) {
1184
+ lines.push(...wrap(s.error.message, inner).map((l) => `${bar} ${style.red(l)}`));
1185
+ }
1186
+ else if (current) {
1187
+ const body = this.answerLines(current.id, current.answer, inner);
1188
+ const view = btwWindow(body, s.scroll, Math.max(3, this.height()));
1189
+ if (view.scroll !== s.scroll)
1190
+ this.state = { ...s, scroll: view.scroll };
1191
+ overflow = view.above || view.below;
1192
+ if (view.above)
1193
+ lines.push(`${bar} ${style.dim("↑ more")}`);
1194
+ lines.push(...view.lines.map((l) => `${bar} ${l}`));
1195
+ if (view.below)
1196
+ lines.push(`${bar} ${style.dim("↓ more")}`);
1197
+ lines.push(`${bar} ${style.dim(btwUsageLine(current))}`);
1198
+ }
1199
+ lines.push(style.dim(` ${btwHints(this.state, overflow)}`));
1200
+ return fit(lines, width);
1201
+ }
1202
+ }
@@ -1,7 +1,3 @@
1
- /**
2
- * Pure presentation logic for the TUI: formatting, command parsing and the reduction of
3
- * versioned runner events into a view model. No terminal or pi-tui imports here.
4
- */
5
1
  import type { Message, ModelInfo, RunEvent, ToolResult, TreeNode, UiBlock } from "@alisio/sdk";
6
2
  export type Level = "ok" | "warn" | "danger";
7
3
  export declare function formatTokens(n: number): string;
@@ -151,6 +147,10 @@ export interface CommandSpec {
151
147
  argumentHint?: string;
152
148
  aliases?: string[];
153
149
  }
150
+ /**
151
+ * Built-in slash commands the TUI offers, derived from the core `CommandCatalog` (the TUI
152
+ * surface of `BUILTIN_COMMANDS`). Kept as `COMMANDS` for compatibility.
153
+ */
154
154
  export declare const COMMANDS: CommandSpec[];
155
155
  /** Every TUI slash name (commands, aliases and routing prefixes); templates cannot take them. */
156
156
  export declare function reservedCommandNames(): string[];
@@ -382,7 +382,7 @@ export declare function isImagePart(value: unknown): value is {
382
382
  data: string;
383
383
  };
384
384
  /** Rich (ui/image) parts of a persisted tool result, if any. */
385
- export declare function richPartsOf(result: ToolResult | undefined): {
385
+ export declare function richPartsOf(result: ToolResult | undefined, toolName?: string): {
386
386
  ui?: UiBlock;
387
387
  image?: {
388
388
  mimeType: string;
package/dist/tui/state.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Pure presentation logic for the TUI: formatting, command parsing and the reduction of
3
+ * versioned runner events into a view model. No terminal or pi-tui imports here.
4
+ */
5
+ import { CommandCatalog } from "@alisio/core";
1
6
  const trimZero = (value) => value.replace(/\.0$/, "");
2
7
  export function formatTokens(n) {
3
8
  if (!Number.isFinite(n) || n < 0)
@@ -251,61 +256,27 @@ export function fitSegments(segments, width, separator) {
251
256
  kept[0] = { ...only, text: truncatePlain(only.text, width) };
252
257
  return kept;
253
258
  }
254
- export const COMMANDS = [
255
- { name: "help", description: "Show commands and keys" },
256
- { name: "connect", description: "Configure a provider and choose its active model" },
257
- {
258
- name: "model",
259
- description: "Switch provider and model",
260
- aliases: ["models"],
261
- },
262
- { name: "compact", description: "Summarize older history", argumentHint: "[focus]" },
263
- { name: "stats", description: "Session statistics" },
264
- { name: "clear", description: "Start a new session", aliases: ["new"] },
265
- { name: "sessions", description: "List recent sessions" },
266
- { name: "resume", description: "Resume a session by ID or prefix", argumentHint: "<id>" },
267
- { name: "tools", description: "List tools and permission state" },
268
- {
269
- name: "plugins",
270
- description: "Browse and manage project plugins",
271
- aliases: ["plugin"],
272
- },
273
- {
274
- name: "skills",
275
- description: "Browse and manage effective skills",
276
- aliases: ["skill"],
277
- },
278
- { name: "mcp", description: "Browse and manage MCP servers" },
279
- {
280
- name: "settings",
281
- description: "Open the settings menu",
282
- aliases: ["prefs"],
283
- },
284
- { name: "copy", description: "Copy the last assistant response to the clipboard" },
285
- {
286
- name: "agents",
287
- description: "List and switch the active agent (applies from the next prompt)",
288
- argumentHint: "[<verb>]",
289
- },
290
- {
291
- name: "effort",
292
- description: "Set the reasoning effort level for the active model",
293
- argumentHint: "[level]",
294
- },
295
- {
296
- name: "ask",
297
- description: "Ask the agent to turn your question into a multiple-choice ask_user_question",
298
- argumentHint: "<question>",
299
- },
300
- { name: "exit", description: "Exit Alisio", aliases: ["quit"] },
301
- ];
259
+ /** Built-ins only: plugin, template and skill commands are resolved against the live app. */
260
+ const BUILTIN_CATALOG = new CommandCatalog();
261
+ /**
262
+ * Built-in slash commands the TUI offers, derived from the core `CommandCatalog` (the TUI
263
+ * surface of `BUILTIN_COMMANDS`). Kept as `COMMANDS` for compatibility.
264
+ */
265
+ export const COMMANDS = BUILTIN_CATALOG.list("tui")
266
+ .filter((c) => c.source === "builtin")
267
+ .map((c) => ({
268
+ name: c.name,
269
+ description: c.description,
270
+ ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
271
+ ...(c.aliases ? { aliases: c.aliases } : {}),
272
+ }));
302
273
  /** Every TUI slash name (commands, aliases and routing prefixes); templates cannot take them. */
303
274
  export function reservedCommandNames() {
304
275
  return [...COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]), "command"];
305
276
  }
306
277
  export function resolveCommand(name) {
307
- const lower = name.toLowerCase();
308
- return COMMANDS.find((c) => c.name === lower || c.aliases?.includes(lower))?.name;
278
+ const found = BUILTIN_CATALOG.resolve(name);
279
+ return found?.source === "builtin" && found.surfaces.includes("tui") ? found.name : undefined;
309
280
  }
310
281
  export function parseCommand(input) {
311
282
  const match = /^\/([A-Za-z][\w:.-]*)(?:\s+([\s\S]*))?$/.exec(input.trim());
@@ -828,10 +799,51 @@ export function isUiBlock(value) {
828
799
  (block.caption === undefined || typeof block.caption === "string"));
829
800
  case "markdown":
830
801
  return typeof block.text === "string";
802
+ case "diff":
803
+ return optionalStrings(block, ["path", "patch", "before", "after", "lang", "caption"]);
804
+ case "terminal":
805
+ return (typeof block.output === "string" &&
806
+ optionalStrings(block, ["command", "cwd"]) &&
807
+ optionalNumbers(block, ["exitCode", "durationMs"]) &&
808
+ (block.truncated === undefined || typeof block.truncated === "boolean"));
809
+ case "mermaid":
810
+ return typeof block.source === "string" && optionalStrings(block, ["title"]);
811
+ case "math":
812
+ return (typeof block.latex === "string" &&
813
+ (block.display === undefined || typeof block.display === "boolean"));
814
+ case "json":
815
+ return ("value" in block &&
816
+ optionalNumbers(block, ["collapsedDepth"]) &&
817
+ optionalStrings(block, ["caption"]));
818
+ case "test-results":
819
+ return (optionalStrings(block, ["framework"]) &&
820
+ optionalNumbers(block, ["durationMs"]) &&
821
+ Array.isArray(block.suites) &&
822
+ block.suites.every((suite) => isRecord(suite) &&
823
+ typeof suite.name === "string" &&
824
+ optionalStrings(suite, ["file"]) &&
825
+ Array.isArray(suite.cases) &&
826
+ suite.cases.every((c) => isRecord(c) &&
827
+ typeof c.name === "string" &&
828
+ TEST_STATUSES.has(c.status) &&
829
+ optionalStrings(c, ["error"]) &&
830
+ optionalNumbers(c, ["durationMs", "line"]))));
831
+ case "progress":
832
+ return (optionalStrings(block, ["title"]) &&
833
+ Array.isArray(block.steps) &&
834
+ block.steps.every((step) => isRecord(step) &&
835
+ typeof step.label === "string" &&
836
+ PROGRESS_STATUSES.has(step.status) &&
837
+ optionalStrings(step, ["detail"])));
831
838
  default:
832
839
  return false;
833
840
  }
834
841
  }
842
+ const TEST_STATUSES = new Set(["passed", "failed", "skipped", "todo"]);
843
+ const PROGRESS_STATUSES = new Set(["pending", "running", "completed", "failed", "cancelled"]);
844
+ const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
845
+ const optionalStrings = (record, keys) => keys.every((key) => record[key] === undefined || typeof record[key] === "string");
846
+ const optionalNumbers = (record, keys) => keys.every((key) => record[key] === undefined || typeof record[key] === "number");
835
847
  /** True when an unknown event value is a `{type:"image"}`-style part (mime + base64 data). */
836
848
  export function isImagePart(value) {
837
849
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -839,11 +851,24 @@ export function isImagePart(value) {
839
851
  const record = value;
840
852
  return typeof record.mimeType === "string" && typeof record.data === "string";
841
853
  }
854
+ /**
855
+ * Display blocks the standard tools attach for the web UI only (spec §8.6): the TUI already
856
+ * shows the same content from the text (output rows, exit line), so rendering them would repeat it.
857
+ */
858
+ const WEB_ONLY_UI = {
859
+ write_file: "diff",
860
+ edit_file: "diff",
861
+ shell: "terminal",
862
+ run_process: "terminal",
863
+ };
842
864
  /** Rich (ui/image) parts of a persisted tool result, if any. */
843
- export function richPartsOf(result) {
865
+ export function richPartsOf(result, toolName) {
844
866
  if (!result)
845
867
  return {};
846
- const ui = result.content.find((part) => part.type === "ui");
868
+ const skip = toolName !== undefined && Object.hasOwn(WEB_ONLY_UI, toolName)
869
+ ? WEB_ONLY_UI[toolName]
870
+ : undefined;
871
+ const ui = result.content.find((part) => part.type === "ui" && part.block.kind !== skip);
847
872
  const image = result.content.find((part) => part.type === "image");
848
873
  return {
849
874
  ...(ui?.type === "ui" ? { ui: ui.block } : {}),
@@ -1191,7 +1216,7 @@ export function itemsFromHistory(messages) {
1191
1216
  preview,
1192
1217
  toolKind: toolKindOf(c.name),
1193
1218
  ...exitCodePart(preview),
1194
- ...richPartsOf(r?.result),
1219
+ ...richPartsOf(r?.result, c.name),
1195
1220
  });
1196
1221
  }
1197
1222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/alisio-code",
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.22",
4
4
  "description": "Alisio: an extensible, provider-agnostic coding-agent harness for your terminal. TUI, OpenAI-compatible providers, permissioned local tools, context compaction, persistent memory and a typed plugin SDK.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -45,11 +45,12 @@
45
45
  "alisio": "./dist/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/core": "0.1.0-alpha.17",
48
+ "@alisio/core": "0.1.0-alpha.19",
49
49
  "@alisio/plugin-memory": "0.1.0-alpha.9",
50
50
  "@alisio/plugin-openai-compatible": "0.1.0-alpha.9",
51
51
  "@alisio/plugin-subagents": "0.1.0-alpha.10",
52
- "@alisio/sdk": "0.1.0-alpha.12",
52
+ "@alisio/sdk": "0.1.0-alpha.16",
53
+ "@alisio/server": "0.1.0-alpha.2",
53
54
  "@earendil-works/pi-tui": "0.87.1",
54
55
  "commander": "15.0.0"
55
56
  },