@alisio/alisio-code 0.1.0-alpha.19 → 0.1.0-alpha.21

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
@@ -38,6 +38,7 @@ program
38
38
  .option("--allow-external", "Allow network tools: webfetch, websearch and provider-native search")
39
39
  .option("--allow-mcp", "Allow configured MCP servers and remote tool calls")
40
40
  .option("--allow-agents", "Allow messaging neighboring agents through Herdr")
41
+ .option("--add-dir <paths...>", "Additional directories tools may read/write outside the workspace (repeatable)")
41
42
  .option("--no-herdr", "Disable automatic Herdr lifecycle reports")
42
43
  .option("--read-only", "Disable writes, arbitrary processes, network tools, executable plugins and MCP")
43
44
  .option("--db <path>", "Session database")
@@ -54,6 +55,7 @@ const options = (cmd) => {
54
55
  baseURL: o.baseUrl,
55
56
  noHerdr: o.herdr === false,
56
57
  disablePlugins: o.disablePlugin,
58
+ addDirs: o.addDir,
57
59
  ...(o.agents
58
60
  ? { pluginOptions: { subagents: { agents: parseAgents(String(o.agents)) } } }
59
61
  : {}),
@@ -258,6 +260,19 @@ program
258
260
  .argument("[prompt]")
259
261
  .action((id, prompt, _opts, cmd) => run(cmd, prompt, id));
260
262
  program.action((_opts, cmd) => run(cmd));
263
+ program
264
+ .command("serve")
265
+ .description("Start the local web UI server (HTTP + SSE)")
266
+ .option("--port <port>", "Port to listen on (0 picks a free one)", "4317")
267
+ .option("--host <address>", "Address to bind; non-loopback requires --allow-remote", "127.0.0.1")
268
+ .option("--allow-remote", "Allow a non-loopback --host (no TLS; prefer an SSH tunnel)")
269
+ .option("--no-open", "Do not open the browser")
270
+ .option("--max-workspaces <n>", "Maximum workspaces open at once", "4")
271
+ .option("--max-runs <n>", "Maximum concurrent runs across all sessions", "4")
272
+ .action(async (flags, cmd) => {
273
+ const { serve } = await import("./serve.js");
274
+ await serve(flags, { ...(await cliDefaults()), ...options(cmd) }, VERSION);
275
+ });
261
276
  program
262
277
  .command("setup")
263
278
  .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
@@ -4,7 +4,7 @@ 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
6
  import { copyText, nodeSpawn } from "./clipboard.js";
7
- import { AttachmentsBar, BannerBlock, clock, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
7
+ import { AttachmentsBar, BannerBlock, ContentInset, clock, contentInnerWidth, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
8
8
  import { ConnectInputPrompt } from "./connect-input.js";
9
9
  import { bounded, EXIT_PENDING_CAP_MS, EXIT_SESSION_END_CAP_MS } from "./exit.js";
10
10
  import { BRANCH_REFRESH_MS, createBranchCache } from "./git-branch.js";
@@ -136,11 +136,12 @@ export class Picker {
136
136
  }
137
137
  }
138
138
  export async function runTui(options) {
139
- const { createApplication } = await import("@alisio/core");
139
+ const { createApplication, CommandCatalog } = await import("@alisio/core");
140
140
  const { BUILTIN_PLUGINS } = await import("../builtin.js");
141
141
  const { BUILTIN_PROMPTS } = await import("../prompts/index.js");
142
142
  let dispatch = () => { };
143
143
  let approve = async () => "deny";
144
+ let approveExternalDirectory = async () => "deny";
144
145
  const app = await createApplication({
145
146
  builtins: BUILTIN_PLUGINS,
146
147
  builtinPrompts: BUILTIN_PROMPTS,
@@ -148,6 +149,7 @@ export async function runTui(options) {
148
149
  ...options,
149
150
  onEvent: (event) => dispatch(event),
150
151
  approve: (request) => approve(request),
152
+ approveExternalDirectory: (request) => approveExternalDirectory(request),
151
153
  });
152
154
  let activeProvider = app.providerInfo;
153
155
  let session = options.session ?? app.store.create(app.workspace, app.provider.id, app.provider.model).id;
@@ -390,14 +392,21 @@ export async function runTui(options) {
390
392
  // working (copyOnSelect must never be disturbed). The region sits between the ScrollView and
391
393
  // the content switch; content rows = visible rows + the scroll offset.
392
394
  let transcriptScroll;
393
- const transcriptRegion = new MouseRegion(new Switch(() => (viewedState() ? childView.container : transcript)), (event) => {
395
+ // Transcript content is inset from both borders in ONE place: the wrapper hands children a
396
+ // reduced width and prefixes every row, so agent text, copy hints, tool sub-lines, reasoning
397
+ // lines and group headers all share the same visual column. The config is read live, so a
398
+ // `/settings` change applies on the next render without rebuilding the view.
399
+ const contentInsetX = () => app.config.tui.contentPaddingX;
400
+ const transcriptRegion = new MouseRegion(new ContentInset(new Switch(() => (viewedState() ? childView.container : transcript)), contentInsetX), (event) => {
394
401
  if (picker || event.type !== "click" || event.button !== "left")
395
402
  return undefined;
396
403
  const scroll = transcriptScroll;
397
404
  if (!scroll)
398
405
  return undefined;
399
406
  const visible = visibleTranscript();
400
- const candidate = visible.sync.entryAt(event.y + scroll.scrollTop, event.width);
407
+ // entryAt re-renders each row to measure its height, so it must use the same reduced width
408
+ // the wrapper hands the rows during render.
409
+ const candidate = visible.sync.entryAt(event.y + scroll.scrollTop, contentInnerWidth(event.width, contentInsetX()));
401
410
  if (!candidate)
402
411
  return undefined;
403
412
  toggleFold(candidate);
@@ -464,7 +473,7 @@ export async function runTui(options) {
464
473
  // pulls the persisted rich parts (ui/image) itself so native rendering survives resume.
465
474
  if (event.type === "tool_completed") {
466
475
  const data = (event.data ?? {});
467
- const rich = richPartsOf(app.store.callResult(event.sessionId, String(data.id ?? "")));
476
+ const rich = richPartsOf(app.store.callResult(event.sessionId, String(data.id ?? "")), String(data.name ?? ""));
468
477
  if (rich.ui !== undefined || rich.image !== undefined)
469
478
  event = { ...event, data: { ...data, ...rich } };
470
479
  }
@@ -549,6 +558,36 @@ export async function runTui(options) {
549
558
  ], (item) => finish(item.value), () => finish("deny")));
550
559
  }),
551
560
  });
561
+ /**
562
+ * External-directory approval: a tool path outside the workspace and every declared extra root
563
+ * asks here before the call runs, scoped to the containing directory (not the individual file),
564
+ * so one approval covers that directory's subtree for the session. Same queue and the same
565
+ * allow-once / allow-session / deny choices as the capability approval above.
566
+ */
567
+ approveExternalDirectory = (request) => interactiveQueue.submit({
568
+ sessionId: request.session,
569
+ label: request.label,
570
+ signal: request.signal,
571
+ onWithdrawn: () => "deny",
572
+ run: () => new Promise((resolve) => {
573
+ let settled = false;
574
+ const finish = (decision) => {
575
+ if (settled)
576
+ return;
577
+ settled = true;
578
+ request.signal.removeEventListener("abort", onAbort);
579
+ closePicker();
580
+ resolve(decision);
581
+ };
582
+ const onAbort = () => finish("deny");
583
+ request.signal.addEventListener("abort", onAbort, { once: true });
584
+ showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}Allow access outside the workspace to ${shortenPath(request.directory, homedir(), 72)}?`, [
585
+ { value: "once", label: "Allow once" },
586
+ { value: "session", label: "Always allow this directory in this session" },
587
+ { value: "deny", label: "Deny" },
588
+ ], (item) => finish(item.value), () => finish("deny")));
589
+ }),
590
+ });
552
591
  /**
553
592
  * `ask_user_question` / `/ask`: routed through the same `interactiveQueue` as `approve`/`select`,
554
593
  * so a root or subagent question never races another prompt for the screen. A question withdrawn
@@ -1269,6 +1308,9 @@ export async function runTui(options) {
1269
1308
  .then(() => {
1270
1309
  if (row.id === "tui.paddingX")
1271
1310
  editor.setPaddingX(Number(value));
1311
+ // The inset wrapper reads the live config; repaint so the new value shows immediately.
1312
+ if (row.id === "tui.contentPaddingX")
1313
+ tui.requestRender();
1272
1314
  // Rebuild the slash provider: the toggle gates the skill:<id> entries immediately.
1273
1315
  if (row.id === "tui.skillSlashCommands")
1274
1316
  editor.setAutocompleteProvider(buildSlashCompletionProvider());
@@ -1339,22 +1381,6 @@ export async function runTui(options) {
1339
1381
  notice(`Resumed session ${found.id}`);
1340
1382
  refreshEstimate();
1341
1383
  };
1342
- const toolsReport = () => {
1343
- const policy = app.runner.policy;
1344
- const rows = app.registry
1345
- .list()
1346
- .map((t) => {
1347
- const effect = t.effect ?? "external";
1348
- const state = effect === "read" || effect === "internal" || policy[effect]
1349
- ? "enabled"
1350
- : app.runner.approvals && (effect === "write" || effect === "process")
1351
- ? "ask"
1352
- : "disabled";
1353
- return `| \`${t.name}\` | ${effect} | ${state} |`;
1354
- })
1355
- .join("\n");
1356
- 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. \`internal\` tools (built-in plugins) only write Alisio's own state.`;
1357
- };
1358
1384
  const statsReport = () => {
1359
1385
  const s = view.stats;
1360
1386
  const budget = app.contextBudget(view.model);
@@ -1414,6 +1440,7 @@ export async function runTui(options) {
1414
1440
  "**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",
1415
1441
  `**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`,
1416
1442
  ].join("\n");
1443
+ const commandCatalog = new CommandCatalog(app);
1417
1444
  const mutating = new Set([
1418
1445
  "connect",
1419
1446
  "model",
@@ -1466,7 +1493,11 @@ export async function runTui(options) {
1466
1493
  case "stats":
1467
1494
  return info(statsReport());
1468
1495
  case "tools":
1469
- return info(toolsReport());
1496
+ case "sessions": {
1497
+ // Text-identical core handlers (parity-tested): output comes from the CommandCatalog.
1498
+ const result = await commandCatalog.execute(name, parsed.args, { sessionId: session });
1499
+ return result.tone === "notice" ? notice(result.text ?? "") : info(result.text ?? "");
1500
+ }
1470
1501
  case "connect":
1471
1502
  return await connect();
1472
1503
  case "model":
@@ -1533,16 +1564,6 @@ export async function runTui(options) {
1533
1564
  void app.herdr.report("idle", session);
1534
1565
  return refreshEstimate();
1535
1566
  }
1536
- case "sessions": {
1537
- const list = workspaceSessions().slice(0, 20);
1538
- if (!list.length)
1539
- return notice("No sessions in this workspace");
1540
- return info([
1541
- "**Recent sessions** (use `/resume <id-prefix>`)",
1542
- "",
1543
- ...list.map((s) => `- \`${s.id}\` ${s.id === session ? "**(current)** " : ""}${s.model} · ${app.store.messages(s.id).length} msgs · ${firstPrompt(s.id) || "_empty_"}`),
1544
- ].join("\n"));
1545
- }
1546
1567
  case "resume":
1547
1568
  if (parsed.args)
1548
1569
  return resume(parsed.args);
@@ -21,7 +21,11 @@ export declare const exitCodeLine: (code: number | undefined, indent?: string) =
21
21
  * `|-`/`` `- ``/`|` otherwise.
22
22
  */
23
23
  export declare function renderTreeLines(nodes: TreeNode[], unicode: boolean): string[];
24
- /** Renders a `{type:"ui"}` block to styled lines for the given inner width. */
24
+ /**
25
+ * Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
26
+ * persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
27
+ * or a malformed plugin block) falls back to labeled JSON instead of throwing.
28
+ */
25
29
  export declare function renderUiBlock(block: UiBlock, width: number, unicode: boolean): string[];
26
30
  export type PermissionState = "on" | "ask" | "off";
27
31
  export interface HeaderInfo {
@@ -129,6 +133,33 @@ export declare class InfoBlock implements Component {
129
133
  invalidate(): void;
130
134
  render(width: number): string[];
131
135
  }
136
+ /**
137
+ * Minimum usable inner width (cells) the transcript keeps no matter how narrow the terminal is or
138
+ * how large the configured inset grows. The effective inset is clamped so this floor is honored,
139
+ * which also keeps the inner width positive.
140
+ */
141
+ export declare const MIN_CONTENT_WIDTH = 20;
142
+ /**
143
+ * Inset actually applied on each side: the configured value capped so the inner column stays at
144
+ * least `MIN_CONTENT_WIDTH` wide. On terminals narrower than that floor the inset degrades to 0
145
+ * rather than overflowing (the inner width then simply equals the terminal width).
146
+ */
147
+ export declare function effectiveContentInset(width: number, inset: number): number;
148
+ /** Inner width handed to transcript children after the effective inset is applied. Always >= 1. */
149
+ export declare function contentInnerWidth(width: number, inset: number): number;
150
+ /**
151
+ * Applies the transcript's horizontal inset in ONE place: it hands children a reduced width and
152
+ * prefixes every produced line with the inset, so all transcript chrome (text, copy hint, tool
153
+ * sub-lines, reasoning lines, group headers) shares the same visual column. The reduced width is a
154
+ * deterministic function of the terminal width, so per-row caches keyed by width stay effective.
155
+ */
156
+ export declare class ContentInset implements Component {
157
+ private readonly inner;
158
+ private readonly inset;
159
+ constructor(inner: Component, inset: () => number);
160
+ invalidate(): void;
161
+ render(width: number): string[];
162
+ }
132
163
  export declare function componentFor(entry: TranscriptEntry): Component;
133
164
  /**
134
165
  * Keeps a container of transcript components in step with view-model items. Rows are keyed
@@ -3,8 +3,8 @@ import { terminalCapabilities } from "../banner.js";
3
3
  import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
4
4
  import { branchDisplay } from "./git-branch.js";
5
5
  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";
6
+ 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";
7
+ import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, sanitizeCode, style, } from "./theme.js";
8
8
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
9
  /** Shared animation clock advanced by the app while work is running. */
10
10
  export const clock = { frame: 0, now: Date.now() };
@@ -169,8 +169,133 @@ function renderCodeBlock(block, _width) {
169
169
  style.dim("```"),
170
170
  ];
171
171
  }
172
- /** Renders a `{type:"ui"}` block to styled lines for the given inner width. */
172
+ /** Longest JSON/unknown-block text shown inline before a `… truncated` marker. */
173
+ const MAX_BLOCK_LINES = 200;
174
+ const MAX_BLOCK_CHARS = 20_000;
175
+ /** Caps free-form text by lines and characters, appending a dim marker when anything is cut. */
176
+ function capText(text) {
177
+ let out = text.length > MAX_BLOCK_CHARS ? text.slice(0, MAX_BLOCK_CHARS) : text;
178
+ const lines = out.split("\n");
179
+ if (lines.length > MAX_BLOCK_LINES)
180
+ out = lines.slice(0, MAX_BLOCK_LINES).join("\n");
181
+ return { text: out, truncated: out.length < text.length };
182
+ }
183
+ /** `JSON.stringify` that never throws and never returns `undefined`. */
184
+ function jsonText(value) {
185
+ try {
186
+ return JSON.stringify(value, null, 2) ?? String(value);
187
+ }
188
+ catch {
189
+ return String(value);
190
+ }
191
+ }
192
+ function renderCappedCode(code, lang, caption) {
193
+ const capped = capText(code);
194
+ return [
195
+ ...renderCodeBlock({
196
+ kind: "code",
197
+ code: capped.text,
198
+ ...(lang ? { lang } : {}),
199
+ ...(caption ? { caption } : {}),
200
+ }, 0),
201
+ ...(capped.truncated ? [style.gray("… truncated")] : []),
202
+ ];
203
+ }
204
+ function renderDiffBlock(block) {
205
+ const caption = [block.caption, block.path].filter(Boolean).join(" · ") || undefined;
206
+ if (block.patch !== undefined)
207
+ return renderCappedCode(block.patch, "diff", caption);
208
+ if (block.before === undefined && block.after === undefined)
209
+ return [...(caption ? [style.dim(caption)] : []), style.gray("(empty diff)")];
210
+ return [
211
+ ...(caption ? [style.dim(caption)] : []),
212
+ ...renderCappedCode(block.before ?? "", block.lang, "before"),
213
+ ...renderCappedCode(block.after ?? "", block.lang, "after"),
214
+ ];
215
+ }
216
+ function renderTerminalBlock(block) {
217
+ const head = block.command
218
+ ? [style.dim(`$ ${block.command}${block.cwd ? ` (${block.cwd})` : ""}`)]
219
+ : [];
220
+ const status = [
221
+ block.exitCode !== undefined ? `exit ${block.exitCode}` : "",
222
+ block.durationMs !== undefined ? formatDuration(block.durationMs) : "",
223
+ ]
224
+ .filter(Boolean)
225
+ .join(" · ");
226
+ const paint = block.exitCode !== undefined && block.exitCode !== 0 ? style.red : style.gray;
227
+ return [
228
+ ...head,
229
+ ...renderCappedCode(sanitizeCode(block.output), undefined),
230
+ ...(block.truncated ? [style.gray("… output truncated")] : []),
231
+ ...(status ? [paint(status)] : []),
232
+ ];
233
+ }
234
+ function renderTestResultsBlock(block, width, unicode) {
235
+ const cases = block.suites.flatMap((suite) => suite.cases.map((c) => ({ suite, c })));
236
+ const counts = new Map();
237
+ for (const { c } of cases)
238
+ counts.set(c.status, (counts.get(c.status) ?? 0) + 1);
239
+ const summary = [
240
+ block.framework,
241
+ ["passed", "failed", "skipped", "todo"]
242
+ .filter((status) => counts.get(status))
243
+ .map((status) => `${counts.get(status)} ${status}`)
244
+ .join(", ") || "no tests",
245
+ block.durationMs !== undefined ? formatDuration(block.durationMs) : undefined,
246
+ ]
247
+ .filter(Boolean)
248
+ .join(" · ");
249
+ const table = renderTableBlock({
250
+ kind: "table",
251
+ columns: ["suite", "case", "status", "time"],
252
+ rows: cases.map(({ suite, c }) => [
253
+ suite.name,
254
+ c.name,
255
+ c.status,
256
+ c.durationMs !== undefined ? formatDuration(c.durationMs) : "",
257
+ ]),
258
+ caption: summary,
259
+ }, width);
260
+ const errors = cases
261
+ .filter(({ c }) => c.status === "failed" && c.error)
262
+ .map(({ suite, c }) => style.red(`${unicode ? "✗" : "x"} ${suite.name} ${unicode ? "›" : ">"} ${c.name}${suite.file ? ` (${suite.file}${c.line !== undefined ? `:${c.line}` : ""})` : ""}: ${c.error}`));
263
+ return [...table, ...errors];
264
+ }
265
+ const PROGRESS_GLYPHS = {
266
+ unicode: { completed: "✓", running: "●", pending: "○", failed: "✗", cancelled: "⊘" },
267
+ ascii: { completed: "[x]", running: "[>]", pending: "[ ]", failed: "[!]", cancelled: "[-]" },
268
+ };
269
+ function renderProgressBlock(block, unicode) {
270
+ const glyphs = PROGRESS_GLYPHS[unicode ? "unicode" : "ascii"];
271
+ const paint = {
272
+ completed: style.green,
273
+ running: style.brightCyan,
274
+ pending: style.gray,
275
+ failed: style.red,
276
+ cancelled: style.gray,
277
+ };
278
+ return [
279
+ ...(block.title ? [style.bold(block.title)] : []),
280
+ ...(block.steps.length ? [] : [style.gray("(no steps)")]),
281
+ ...block.steps.map((step) => `${paint[step.status](glyphs[step.status])} ${step.label}${step.detail ? ` ${style.dim(step.detail)}` : ""}`),
282
+ ];
283
+ }
284
+ /** Text fallback for a kind this build does not know (or a malformed block): never throws. */
285
+ function renderUnknownBlock(block) {
286
+ const kind = block && typeof block === "object" && typeof block.kind === "string"
287
+ ? block.kind
288
+ : "unknown";
289
+ return [style.gray(`[ui block: ${kind}]`), ...renderCappedCode(jsonText(block), "json")];
290
+ }
291
+ /**
292
+ * Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
293
+ * persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
294
+ * or a malformed plugin block) falls back to labeled JSON instead of throwing.
295
+ */
173
296
  export function renderUiBlock(block, width, unicode) {
297
+ if (!isUiBlock(block))
298
+ return renderUnknownBlock(block);
174
299
  switch (block.kind) {
175
300
  case "table":
176
301
  return renderTableBlock(block, width);
@@ -186,6 +311,22 @@ export function renderUiBlock(block, width, unicode) {
186
311
  });
187
312
  return md.render(Math.max(1, width - 2));
188
313
  }
314
+ case "diff":
315
+ return renderDiffBlock(block);
316
+ case "terminal":
317
+ return renderTerminalBlock(block);
318
+ case "mermaid":
319
+ return renderCappedCode(block.source, "mermaid", block.title);
320
+ case "math":
321
+ return block.latex.split("\n");
322
+ case "json":
323
+ return renderCappedCode(jsonText(block.value), "json", block.caption);
324
+ case "test-results":
325
+ return renderTestResultsBlock(block, width, unicode);
326
+ case "progress":
327
+ return renderProgressBlock(block, unicode);
328
+ default:
329
+ return renderUnknownBlock(block);
189
330
  }
190
331
  }
191
332
  const permission = (label, state, priority) => ({
@@ -588,6 +729,51 @@ export class InfoBlock {
588
729
  return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
589
730
  }
590
731
  }
732
+ /**
733
+ * Minimum usable inner width (cells) the transcript keeps no matter how narrow the terminal is or
734
+ * how large the configured inset grows. The effective inset is clamped so this floor is honored,
735
+ * which also keeps the inner width positive.
736
+ */
737
+ export const MIN_CONTENT_WIDTH = 20;
738
+ /**
739
+ * Inset actually applied on each side: the configured value capped so the inner column stays at
740
+ * least `MIN_CONTENT_WIDTH` wide. On terminals narrower than that floor the inset degrades to 0
741
+ * rather than overflowing (the inner width then simply equals the terminal width).
742
+ */
743
+ export function effectiveContentInset(width, inset) {
744
+ const requested = Math.max(0, Math.floor(inset));
745
+ const max = Math.max(0, Math.floor((Math.floor(width) - MIN_CONTENT_WIDTH) / 2));
746
+ return Math.min(requested, max);
747
+ }
748
+ /** Inner width handed to transcript children after the effective inset is applied. Always >= 1. */
749
+ export function contentInnerWidth(width, inset) {
750
+ return Math.max(1, Math.floor(width) - effectiveContentInset(width, inset) * 2);
751
+ }
752
+ /**
753
+ * Applies the transcript's horizontal inset in ONE place: it hands children a reduced width and
754
+ * prefixes every produced line with the inset, so all transcript chrome (text, copy hint, tool
755
+ * sub-lines, reasoning lines, group headers) shares the same visual column. The reduced width is a
756
+ * deterministic function of the terminal width, so per-row caches keyed by width stay effective.
757
+ */
758
+ export class ContentInset {
759
+ inner;
760
+ inset;
761
+ constructor(inner, inset) {
762
+ this.inner = inner;
763
+ this.inset = inset;
764
+ }
765
+ invalidate() {
766
+ this.inner.invalidate();
767
+ }
768
+ render(width) {
769
+ const inset = effectiveContentInset(width, this.inset());
770
+ const lines = this.inner.render(contentInnerWidth(width, inset));
771
+ if (!inset)
772
+ return lines;
773
+ const pad = " ".repeat(inset);
774
+ return lines.map((line) => `${pad}${line}`);
775
+ }
776
+ }
591
777
  export function componentFor(entry) {
592
778
  if (entry.kind === "group")
593
779
  return new ToolGroupBlock(entry.group);
@@ -56,6 +56,7 @@ export interface SettingsConfigView {
56
56
  };
57
57
  tui: {
58
58
  paddingX: number;
59
+ contentPaddingX: number;
59
60
  skillSlashCommands: boolean;
60
61
  };
61
62
  mcp: {
@@ -13,7 +13,7 @@ export const defaultConfig = {
13
13
  compaction: { auto: true, threshold: 0.85, keepTurns: 2, maxOutputTokens: 16_000 },
14
14
  context: { claudeMdFallback: false, maxBytes: 32 * 1024 },
15
15
  limits: { maxTurns: 100, maxOutputTokens: 16_384, maxContextChars: 800_000, timeoutMs: 300_000 },
16
- tui: { paddingX: 1, skillSlashCommands: true },
16
+ tui: { paddingX: 1, contentPaddingX: 2, skillSlashCommands: true },
17
17
  mcp: { allow: false },
18
18
  websearch: { provider: undefined },
19
19
  pluginHooks: { timeoutMs: 15_000 },
@@ -161,6 +161,15 @@ export const SETTINGS_DEFINITIONS = [
161
161
  read: (config) => config.tui.paddingX,
162
162
  description: "Horizontal padding (columns) around the editor input box. Applied immediately to the current editor.",
163
163
  },
164
+ {
165
+ id: "tui.contentPaddingX",
166
+ label: "Content inset",
167
+ category: "TUI",
168
+ valueType: "number",
169
+ values: [0, 1, 2, 3, 4, 6, 8, 12],
170
+ read: (config) => config.tui.contentPaddingX,
171
+ description: "Horizontal inset (columns) on each side of the transcript content, keeping agent-written rows clear of the window borders. Clamped on narrow terminals so the content column stays usable. Applied immediately.",
172
+ },
164
173
  {
165
174
  id: "tui.skillSlashCommands",
166
175
  label: "Skill slash commands",
@@ -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.19",
3
+ "version": "0.1.0-alpha.21",
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.16",
48
+ "@alisio/core": "0.1.0-alpha.18",
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.11",
52
+ "@alisio/sdk": "0.1.0-alpha.15",
53
+ "@alisio/server": "0.1.0-alpha.1",
53
54
  "@earendil-works/pi-tui": "0.87.1",
54
55
  "commander": "15.0.0"
55
56
  },