@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 +15 -0
- package/dist/serve.d.ts +15 -0
- package/dist/serve.js +99 -0
- package/dist/tui/agents.d.ts +3 -64
- package/dist/tui/agents.js +3 -111
- package/dist/tui/app.js +53 -32
- package/dist/tui/components.d.ts +32 -1
- package/dist/tui/components.js +189 -3
- package/dist/tui/settings.d.ts +1 -0
- package/dist/tui/settings.js +10 -1
- package/dist/tui/state.d.ts +5 -5
- package/dist/tui/state.js +78 -53
- package/package.json +4 -3
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)")
|
package/dist/serve.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/tui/agents.d.ts
CHANGED
|
@@ -1,66 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
|
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";
|
package/dist/tui/agents.js
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/tui/components.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/tui/components.js
CHANGED
|
@@ -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
|
-
/**
|
|
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);
|
package/dist/tui/settings.d.ts
CHANGED
package/dist/tui/settings.js
CHANGED
|
@@ -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",
|
package/dist/tui/state.d.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
308
|
-
return
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
},
|