@alisio/alisio-code 0.1.0-alpha.14 → 0.1.0-alpha.15

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
@@ -129,6 +129,19 @@ async function run(cmd, prompt, sessionId) {
129
129
  });
130
130
  for (const failure of app.mcpStartupFailures())
131
131
  process.stderr.write(`[startup] ${failure}\n`);
132
+ // The ACTIVE agent (built-in `build`/`plan` or a main-capable definition) drives the main
133
+ // session here too: its system prompt is appended and a read-only agent narrows the run. The
134
+ // TUI adds the picker and the model switch; effort is a TUI feature (validated against the
135
+ // active model's catalog).
136
+ const { agentCatalogFromState, agentRunOptions, resolveActiveAgent } = await import("./tui/agents.js");
137
+ let activeRunOptions = {};
138
+ try {
139
+ const reported = app.plugins.pluginState("subagents", "mainAgents");
140
+ activeRunOptions = agentRunOptions(resolveActiveAgent(agentCatalogFromState(reported), app.config.agents.active));
141
+ }
142
+ catch {
143
+ /* agent resolution is best-effort */
144
+ }
132
145
  const controller = new AbortController();
133
146
  const interrupt = () => controller.abort(new Error("Interrupted"));
134
147
  process.on("SIGINT", interrupt);
@@ -151,7 +164,10 @@ async function run(cmd, prompt, sessionId) {
151
164
  const match = /^\/skill:([a-z0-9-]+)\s*([\s\S]*)$/.exec(prompt);
152
165
  if (match?.[1])
153
166
  prompt = `${await app.skills.load(match[1])}\n\nUser request: ${match[2] ?? ""}`;
154
- await app.runner.run(session, template?.text ?? prompt, controller.signal, template ? { display: template.display } : {});
167
+ await app.runner.run(session, template?.text ?? prompt, controller.signal, {
168
+ ...activeRunOptions,
169
+ ...(template ? { display: template.display } : {}),
170
+ });
155
171
  if (!opts.json)
156
172
  process.stdout.write(`\nSession: ${session}\n`);
157
173
  return;
@@ -211,7 +227,10 @@ async function run(cmd, prompt, sessionId) {
211
227
  const input = match?.[1] ? `${await app.skills.load(match[1])}\n\n${match[2] ?? ""}` : line;
212
228
  try {
213
229
  const template = app.expandPrompt(input);
214
- await app.runner.run(session, template?.text ?? input, controller.signal, template ? { display: template.display } : {});
230
+ await app.runner.run(session, template?.text ?? input, controller.signal, {
231
+ ...activeRunOptions,
232
+ ...(template ? { display: template.display } : {}),
233
+ });
215
234
  }
216
235
  catch (e) {
217
236
  console.error(String(e));
@@ -0,0 +1,66 @@
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.
11
+ */
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[];
@@ -0,0 +1,113 @@
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
+ /**
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).
60
+ */
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
+ }
package/dist/tui/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, matchesKey, ProcessTerminal, ScrollView, SelectList, TuiAltScreen, truncateToWidth, VStack, } from "@earendil-works/pi-tui";
3
3
  import { loadVersion } from "../version.js";
4
+ import { activeAgentCatalog, agentPickerItems, agentRunOptions, mainAgentFromRecord, resolveActiveAgent, } from "./agents.js";
4
5
  import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard, removeLastAttachment, toApiAttachment, } from "./attachments.js";
5
6
  import { copyText, nodeSpawn } from "./clipboard.js";
6
7
  import { AttachmentsBar, BannerBlock, clock, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
@@ -13,7 +14,7 @@ import { InteractiveQueue } from "./queue.js";
13
14
  import { settingsMenuRows } from "./settings.js";
14
15
  import { SettingsMenu } from "./settings-menu.js";
15
16
  import { SkillsManager } from "./skills-manager.js";
16
- import { addItem, COMMANDS, configuredProviderModelItems, formatContext, formatDuration, formatTokens, hostOf, initialViewState, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, shortenPath, shortId, slashCompletionCommands, summarizeToolArgs, } from "./state.js";
17
+ import { addItem, COMMANDS, configuredProviderModelItems, effectiveEffort, effortPickerItems, formatContext, formatDuration, formatTokens, hostOf, initialViewState, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, shortenPath, shortId, slashCompletionCommands, summarizeToolArgs, validateEffortLevel, } from "./state.js";
17
18
  import { editorTheme, selectListTheme, style } from "./theme.js";
18
19
  const VERSION = loadVersion(import.meta.url);
19
20
  /** Inline selection list with type-to-filter, rendered above the editor. */
@@ -106,17 +107,44 @@ export async function runTui(options) {
106
107
  modelList = undefined;
107
108
  throw e;
108
109
  }));
110
+ /** Active-provider catalog by model id; feeds effort support and display names. */
111
+ let modelCatalog = new Map();
109
112
  /**
110
113
  * Reloads the active-provider catalog and repaints when it lands, so the context bar learns
111
114
  * the ACTIVE model's real window (previously only the first /model picker or autocomplete
112
115
  * call loaded it, leaving a fabricated 40k total on screen). Called at startup and after
113
116
  * every provider/model switch; the bar honestly shows `?` until the catalog arrives.
117
+ * The same refresh feeds the effort status (supported levels + default) and the model
118
+ * display name used by the header and the status line below the editor.
114
119
  */
115
120
  const primeModels = () => {
116
121
  void models()
117
- .then(() => tui.requestRender())
122
+ .then((list) => {
123
+ modelCatalog = new Map(list.map((m) => [m.id, m]));
124
+ refreshEffort();
125
+ tui.requestRender();
126
+ })
118
127
  .catch(() => { });
119
128
  };
129
+ const effortSupportFor = (model) => modelCatalog.get(model)?.effort;
130
+ const modelDisplayName = (model) => modelCatalog.get(model)?.name ?? model;
131
+ /** Persisted `agents.effort` is silently replaced by the model default when unsupported; one notice. */
132
+ let notifiedEffortFallback = "";
133
+ function refreshEffort() {
134
+ const persisted = app.config.agents.effort;
135
+ const capability = effortSupportFor(view.model);
136
+ if (!persisted || !capability?.supportedLevels?.length)
137
+ return;
138
+ if (capability.supportedLevels.includes(persisted))
139
+ return;
140
+ const key = `${view.model}:${persisted}`;
141
+ if (notifiedEffortFallback !== key) {
142
+ notifiedEffortFallback = key;
143
+ flashHint(`Reasoning effort "${persisted}" is not supported by ${modelDisplayName(view.model)}; using ${capability.defaultLevel ? `"${capability.defaultLevel}"` : "the provider default"}.`, 7000);
144
+ }
145
+ }
146
+ /** Effective effort sent on runs: the persisted level when supported, else the model default. */
147
+ const currentEffort = () => effectiveEffort(app.config.agents.effort, effortSupportFor(view.model));
120
148
  const terminal = new ProcessTerminal();
121
149
  const copy = async (text) => copyText(text, {
122
150
  platform: process.platform,
@@ -170,6 +198,24 @@ export async function runTui(options) {
170
198
  };
171
199
  refreshBranch();
172
200
  const branchTimer = setInterval(refreshBranch, BRANCH_REFRESH_MS);
201
+ /**
202
+ * Selectable ACTIVE (main-session) agents: the built-in `build`/`plan` pair plus the
203
+ * main-capable definitions the subagents plugin publishes (`mode: primary|all`) through its
204
+ * plugin state. Never imports the plugin package; a missing/unavailable contribution is just
205
+ * an empty extra list. The persisted `agents.active` id resolves against this catalog.
206
+ */
207
+ const mainAgents = () => {
208
+ let contributions = [];
209
+ try {
210
+ const records = app.plugins.pluginState("subagents", "mainAgents");
211
+ contributions = (records ?? []).map(mainAgentFromRecord);
212
+ }
213
+ catch {
214
+ /* plugin state is best-effort */
215
+ }
216
+ return activeAgentCatalog(contributions);
217
+ };
218
+ const currentAgent = () => resolveActiveAgent(mainAgents(), app.config.agents.active);
173
219
  const headerInfo = () => {
174
220
  const policy = app.runner.policy, ask = app.runner.approvals;
175
221
  return {
@@ -177,6 +223,8 @@ export async function runTui(options) {
177
223
  host: hostOf(String(activeProvider?.profile.baseURL ?? app.config.provider.baseURL)),
178
224
  apiMode: String(activeProvider?.profile.apiMode ?? app.config.provider.apiMode),
179
225
  provider: app.providers.get(activeProvider?.id ?? "")?.name,
226
+ modelName: modelDisplayName(view.model),
227
+ effort: currentEffort(),
180
228
  cwd: shortenPath(app.workspace, homedir()),
181
229
  session: shortId(session),
182
230
  branch: branchName,
@@ -228,7 +276,15 @@ export async function runTui(options) {
228
276
  : panelState.focus === "view"
229
277
  ? viewHint()
230
278
  : undefined;
231
- const footer = new Footer(() => viewedState() ?? view, () => app.contextBudget((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text));
279
+ const footer = new Footer(() => viewedState() ?? view, () => app.contextBudget((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text),
280
+ // Status row below the editor: `agent: <name> · <model> · <provider> · <effort>` (the
281
+ // effort segment appears only when the active model advertises supported levels).
282
+ () => ({
283
+ agent: currentAgent().name,
284
+ model: modelDisplayName(view.model),
285
+ provider: app.providers.get(activeProvider?.id ?? "")?.name,
286
+ effort: currentEffort(),
287
+ }));
232
288
  const treePanel = new TreePanel(() => {
233
289
  const entry = panelEntry();
234
290
  if (!entry)
@@ -489,7 +545,14 @@ export async function runTui(options) {
489
545
  pendingAttachments = [];
490
546
  attachmentsBar.invalidate();
491
547
  push({ kind: "user", text: display });
548
+ // The ACTIVE agent drives the main session: its system prompt is appended per run and a
549
+ // read-only agent narrows the run to reads (no approvals). The persisted reasoning effort is
550
+ // resolved against the ACTIVE model and sent when the model advertises supported levels.
551
+ const agent = currentAgent();
552
+ const effort = currentEffort();
492
553
  return task((signal) => app.runner.run(session, prompt, signal, {
554
+ ...agentRunOptions(agent),
555
+ ...(effort ? { reasoningEffort: effort } : {}),
493
556
  ...(persistDisplay ? { display: persistDisplay } : {}),
494
557
  ...(attachments.length ? { attachments } : {}),
495
558
  }));
@@ -953,6 +1016,93 @@ export async function runTui(options) {
953
1016
  };
954
1017
  openCatalog();
955
1018
  };
1019
+ /**
1020
+ * `/agents` (active agent): a navigable picker listing every selectable main-session agent with
1021
+ * its description. Selecting one persists `agents.active` globally (applies from the next
1022
+ * prompt), optionally switches the session model to the agent's declared model, and starts a
1023
+ * fresh session just like `/model` when it does. Esc returns to the editor.
1024
+ */
1025
+ const openAgentsPicker = () => {
1026
+ const agents = mainAgents();
1027
+ const current = currentAgent();
1028
+ showPicker(new Picker("Active agent", agentPickerItems(agents, current.id), (item) => {
1029
+ const agent = agents.find((candidate) => candidate.id === item.value);
1030
+ if (agent)
1031
+ void applyActiveAgent(agent);
1032
+ }, closePicker, true, "Changing the active agent takes effect from the next prompt; the current session is kept unless the agent declares a different model."));
1033
+ };
1034
+ const applyActiveAgent = (agent) => {
1035
+ void (async () => {
1036
+ closePicker();
1037
+ const previous = currentAgent().name;
1038
+ try {
1039
+ await app.updateSetting("agents.active", agent.id);
1040
+ let switched = false;
1041
+ if (agent.model && app.store.get(session).model !== agent.model) {
1042
+ flashHint(`Switching to ${agent.name}'s model ${agent.model}…`, 60_000);
1043
+ const created = await app.switchModel(agent.model);
1044
+ if (created) {
1045
+ activeProvider = app.providerInfo;
1046
+ modelList = undefined;
1047
+ session = created.id;
1048
+ reset(initialViewState(created.model));
1049
+ switched = true;
1050
+ }
1051
+ }
1052
+ notice(`Active agent: ${agent.name}${agent.readOnly ? " (read-only)" : ""}. Applies from the next prompt.`);
1053
+ if (agent.model && switched)
1054
+ notice(`Agent ${agent.name} switches the session model to ${app.store.get(session).model}.`);
1055
+ else if (agent.model)
1056
+ flashHint(`Agent ${agent.name} keeps the current model (already ${view.model}). Override any time with /model.`);
1057
+ else
1058
+ flashHint(`Active agent: ${agent.name} (was ${previous}).`);
1059
+ refreshEffort();
1060
+ primeModels();
1061
+ tui.requestRender();
1062
+ }
1063
+ catch (cause) {
1064
+ error(cause);
1065
+ }
1066
+ })();
1067
+ };
1068
+ /**
1069
+ * `/effort [level]`: without an argument, a picker over the ACTIVE model's advertised effort
1070
+ * levels (marking the model default); with an argument, the level is validated against
1071
+ * `effort.supportedLevels` and persisted globally. The chosen level is sent from the next
1072
+ * prompt; if the model later changes to one that does not support it, the model's default is
1073
+ * used silently with a one-time notice.
1074
+ */
1075
+ const setEffort = async (args) => {
1076
+ const capability = effortSupportFor(view.model);
1077
+ if (!args) {
1078
+ if (!capability?.supportedLevels?.length)
1079
+ return notice(`${view.model} does not advertise reasoning effort levels (ModelInfo.effort), so /effort is not available for it.`);
1080
+ const items = effortPickerItems(capability, currentEffort());
1081
+ showPicker(new Picker(`Reasoning effort · ${modelDisplayName(view.model)}`, items, (item) => void applyEffort(item.value), closePicker, true));
1082
+ return;
1083
+ }
1084
+ const invalid = validateEffortLevel(args, capability);
1085
+ if (invalid)
1086
+ return notice(invalid);
1087
+ await applyEffort(args);
1088
+ };
1089
+ const applyEffort = async (value) => {
1090
+ const clear = value === "!clear";
1091
+ closePicker();
1092
+ try {
1093
+ await app.updateSetting("agents.effort", clear ? undefined : value);
1094
+ const capability = effortSupportFor(view.model);
1095
+ const effective = effectiveEffort(clear ? undefined : value, capability);
1096
+ notice(clear
1097
+ ? `Reasoning effort cleared; ${capability?.defaultLevel ? `the model default "${capability.defaultLevel}" is used` : "the provider default is used"}.`
1098
+ : `Reasoning effort: ${value}${value === capability?.defaultLevel ? ` (default for ${modelDisplayName(view.model)})` : ""}${effective && effective !== value ? ` — effective: ${effective}` : ""}. Applies from the next prompt.`);
1099
+ refreshEffort();
1100
+ tui.requestRender();
1101
+ }
1102
+ catch (cause) {
1103
+ error(cause);
1104
+ }
1105
+ };
956
1106
  /**
957
1107
  * Navigation rows at the bottom of `/settings`: they route to the existing managers and one-shot
958
1108
  * actions exactly like the old picker, so every prior entry stays reachable. `compact` and
@@ -1256,6 +1406,30 @@ export async function runTui(options) {
1256
1406
  `(reuse the user's own wording for the question text). Do not answer in plain text ` +
1257
1407
  `first; call the tool right away.`);
1258
1408
  }
1409
+ case "agents": {
1410
+ // The subagents plugin registers its own built-in `/agents` command for subagent task
1411
+ // management (list/open/cancel/kill/resume/merge/discard/defs). With arguments, route
1412
+ // to it verbatim (still available while a turn runs, exactly as before); without
1413
+ // arguments, this opens the ACTIVE-agent picker.
1414
+ if (parsed.args) {
1415
+ const handler = app.plugins.commands.get("agents");
1416
+ if (handler)
1417
+ return info(await handler(parsed.args, { sessionId: session }));
1418
+ }
1419
+ if (busy) {
1420
+ editor.setText(raw);
1421
+ flashHint("A turn is running: wait for it to finish before switching the active agent");
1422
+ return;
1423
+ }
1424
+ return openAgentsPicker();
1425
+ }
1426
+ case "effort":
1427
+ if (busy) {
1428
+ editor.setText(raw);
1429
+ flashHint("A turn is running: wait for it to finish before changing the effort");
1430
+ return;
1431
+ }
1432
+ return await setEffort(parsed.args);
1259
1433
  case "clear": {
1260
1434
  await endSession("clear");
1261
1435
  session = app.store.create(app.workspace, app.provider.id, view.model).id;
@@ -2,7 +2,7 @@ import type { PanelNode } from "@alisio/sdk";
2
2
  import { type Component, Container } from "@earendil-works/pi-tui";
3
3
  import { type PendingAttachment } from "./attachments.ts";
4
4
  import { type QuestionPanelState, type QuestionSpec } from "./questions.ts";
5
- import { type ContextBudget, type TranscriptItem, type ViewState } from "./state.ts";
5
+ import { type ContextBudget, type IdentityInput, type TranscriptItem, type ViewState } from "./state.ts";
6
6
  export declare const SPINNER: string[];
7
7
  /** Shared animation clock advanced by the app while work is running. */
8
8
  export declare const clock: {
@@ -15,6 +15,10 @@ export interface HeaderInfo {
15
15
  host: string;
16
16
  apiMode: string;
17
17
  provider?: string;
18
+ /** Model display name (catalog `name`), falling back to the session model id. */
19
+ modelName?: string;
20
+ /** Effective reasoning effort of the active model, when it advertises supported levels. */
21
+ effort?: string;
18
22
  cwd: string;
19
23
  session: string;
20
24
  /** Git branch (or short SHA on a detached HEAD) of the session workspace; undefined when unavailable. */
@@ -36,7 +40,8 @@ export declare class Footer implements Component {
36
40
  private budget;
37
41
  private hint;
38
42
  private statuses;
39
- constructor(view: () => ViewState, budget: () => ContextBudget | undefined, hint: () => string | undefined, statuses?: () => string[]);
43
+ private identity?;
44
+ constructor(view: () => ViewState, budget: () => ContextBudget | undefined, hint: () => string | undefined, statuses?: () => string[], identity?: (() => IdentityInput | undefined) | undefined);
40
45
  invalidate(): void;
41
46
  render(width: number): string[];
42
47
  }
@@ -3,7 +3,7 @@ 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, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
6
+ import { contextLevel, contextPercent, editSummary, fitIdentityParts, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
7
7
  import { imageTheme, levelColor, markdownTheme, style } from "./theme.js";
8
8
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
9
  /** Shared animation clock advanced by the app while work is running. */
@@ -33,12 +33,14 @@ export class Header {
33
33
  { text: "◆ Alisio Code", priority: 8, paint: (t) => style.bold(style.brightCyan(t)) },
34
34
  { text: `v${i.version}`, priority: 1, paint: style.gray },
35
35
  {
36
- text: v.model || "not connected",
36
+ text: i.modelName || v.model || "not connected",
37
37
  priority: 10,
38
- paint: v.model ? style.bold : style.yellow,
38
+ paint: v.model ? (t) => style.bold(style.cyan(t)) : style.yellow,
39
39
  },
40
- ...(i.provider ? [{ text: i.provider, priority: 6, paint: style.cyan }] : []),
41
- { text: i.host, priority: 5, paint: style.gray },
40
+ // `model · provider · effort` with each piece in a distinct color.
41
+ ...(i.provider ? [{ text: i.provider, priority: 6, paint: style.magenta }] : []),
42
+ ...(i.effort ? [{ text: i.effort, priority: 5, paint: style.yellow }] : []),
43
+ { text: i.host, priority: 4, paint: style.gray },
42
44
  { text: i.apiMode, priority: 2, paint: style.gray },
43
45
  ], width);
44
46
  const perms = i.readOnly
@@ -78,11 +80,13 @@ export class Footer {
78
80
  budget;
79
81
  hint;
80
82
  statuses;
81
- constructor(view, budget, hint, statuses = () => []) {
83
+ identity;
84
+ constructor(view, budget, hint, statuses = () => [], identity) {
82
85
  this.view = view;
83
86
  this.budget = budget;
84
87
  this.hint = hint;
85
88
  this.statuses = statuses;
89
+ this.identity = identity;
86
90
  }
87
91
  invalidate() { }
88
92
  render(width) {
@@ -124,7 +128,22 @@ export class Footer {
124
128
  (v.streaming || v.compacting
125
129
  ? "Esc interrupt · Ctrl+C clear/exit"
126
130
  : "Enter send · Shift+Enter newline · /help commands · Ctrl+D exit");
127
- return fit([line1, style.dim(hint)], width);
131
+ // Status row below the editor: `agent: <name> · <model> · <provider> · <effort>`, each piece
132
+ // in a distinct color (model bold/cyan, provider magenta, effort yellow); the effort part is
133
+ // omitted when the active model advertises no supported levels. Truncation drops the lowest
134
+ // priority parts first (effort, then provider) before truncating what remains.
135
+ const identity = this.identity?.();
136
+ const identityLine = identity
137
+ ? fitIdentityParts(identity, width)
138
+ .map((part) => ({
139
+ agent: (t) => style.bold(style.magenta(t)),
140
+ model: (t) => style.bold(style.cyan(t)),
141
+ provider: style.magenta,
142
+ effort: style.yellow,
143
+ })[part.role](part.text))
144
+ .join(style.gray(" · "))
145
+ : undefined;
146
+ return fit(identityLine ? [identityLine, line1, style.dim(hint)] : [line1, style.dim(hint)], width);
128
147
  }
129
148
  }
130
149
  export class UserBlock {
@@ -124,6 +124,44 @@ export declare function parseCommand(input: string): {
124
124
  name: string;
125
125
  args: string;
126
126
  } | undefined;
127
+ /** Reasoning-effort capability advertised by a model's catalog entry (`ModelInfo.effort`). */
128
+ export interface EffortCapability {
129
+ supportedLevels: string[];
130
+ defaultLevel?: string;
131
+ }
132
+ /**
133
+ * The effective reasoning effort to send for a model: the persisted level when the model
134
+ * supports it, otherwise the model's default level (silent fallback), and nothing when the
135
+ * model advertises no effort levels at all.
136
+ */
137
+ export declare function effectiveEffort(persisted: string | undefined, capability: EffortCapability | undefined): string | undefined;
138
+ /** Validates a `/effort <level>` argument against the active model; returns an error or nothing. */
139
+ export declare function validateEffortLevel(level: string, capability: EffortCapability | undefined): string | undefined;
140
+ export declare function effortPickerItems(capability: EffortCapability, active: string | undefined): Array<{
141
+ value: string;
142
+ label: string;
143
+ description?: string;
144
+ }>;
145
+ /** Role of a status-line identity part; the renderer maps each role to a distinct color. */
146
+ export type IdentityRole = "agent" | "model" | "provider" | "effort";
147
+ export interface IdentityPart extends Segment {
148
+ role: IdentityRole;
149
+ }
150
+ export interface IdentityInput {
151
+ agent: string;
152
+ model: string;
153
+ provider?: string;
154
+ effort?: string;
155
+ }
156
+ /**
157
+ * The ordered identity parts for the status line below the editor:
158
+ * `agent: <name> · <model> · <provider> · <effort>`, with the effort part omitted when the
159
+ * active model advertises no supported levels (`effort === undefined`). Colors are applied by
160
+ * the renderer per role; this module stays terminal-free and testable.
161
+ */
162
+ export declare function identityParts(input: IdentityInput): IdentityPart[];
163
+ /** Drops the lowest-priority identity parts until the line fits `width`; truncates the survivor. */
164
+ export declare function fitIdentityParts(input: IdentityInput, width: number): IdentityPart[];
127
165
  /** Minimal structural view of a skill for slash autocompletion (subset of the core catalog entry). */
128
166
  export interface SkillCompletionEntry {
129
167
  id: string;
package/dist/tui/state.js CHANGED
@@ -229,6 +229,16 @@ export const COMMANDS = [
229
229
  aliases: ["prefs"],
230
230
  },
231
231
  { name: "copy", description: "Copy the last assistant response to the clipboard" },
232
+ {
233
+ name: "agents",
234
+ description: "List and switch the active agent (applies from the next prompt)",
235
+ argumentHint: "[<verb>]",
236
+ },
237
+ {
238
+ name: "effort",
239
+ description: "Set the reasoning effort level for the active model",
240
+ argumentHint: "[level]",
241
+ },
232
242
  {
233
243
  name: "ask",
234
244
  description: "Ask the agent to turn your question into a multiple-choice ask_user_question",
@@ -250,6 +260,68 @@ export function parseCommand(input) {
250
260
  return undefined;
251
261
  return { name: match[1], args: (match[2] ?? "").trim() };
252
262
  }
263
+ /**
264
+ * The effective reasoning effort to send for a model: the persisted level when the model
265
+ * supports it, otherwise the model's default level (silent fallback), and nothing when the
266
+ * model advertises no effort levels at all.
267
+ */
268
+ export function effectiveEffort(persisted, capability) {
269
+ if (!capability?.supportedLevels?.length)
270
+ return undefined;
271
+ if (persisted && capability.supportedLevels.includes(persisted))
272
+ return persisted;
273
+ return capability.defaultLevel;
274
+ }
275
+ /** Validates a `/effort <level>` argument against the active model; returns an error or nothing. */
276
+ export function validateEffortLevel(level, capability) {
277
+ if (!capability?.supportedLevels?.length)
278
+ return "This model does not advertise reasoning effort levels, so no effort is sent to it.";
279
+ if (!capability.supportedLevels.includes(level))
280
+ return `Unsupported effort level "${level}". Supported levels: ${capability.supportedLevels.join(", ")}.`;
281
+ return undefined;
282
+ }
283
+ export function effortPickerItems(capability, active) {
284
+ const items = capability.supportedLevels.map((level) => ({
285
+ value: level,
286
+ label: [
287
+ level,
288
+ level === active ? "(current)" : undefined,
289
+ level === capability.defaultLevel ? "(default)" : undefined,
290
+ ]
291
+ .filter(Boolean)
292
+ .join(" · "),
293
+ ...(level === capability.defaultLevel ? { description: "The model's own default" } : {}),
294
+ }));
295
+ return [
296
+ ...items,
297
+ {
298
+ value: "!clear",
299
+ label: "Auto · provider default",
300
+ description: "Clear the saved effort; the provider uses its own default",
301
+ },
302
+ ];
303
+ }
304
+ /**
305
+ * The ordered identity parts for the status line below the editor:
306
+ * `agent: <name> · <model> · <provider> · <effort>`, with the effort part omitted when the
307
+ * active model advertises no supported levels (`effort === undefined`). Colors are applied by
308
+ * the renderer per role; this module stays terminal-free and testable.
309
+ */
310
+ export function identityParts(input) {
311
+ const parts = [
312
+ { text: `agent: ${input.agent}`, priority: 10, role: "agent" },
313
+ { text: input.model || "not connected", priority: 9, role: "model" },
314
+ ];
315
+ if (input.provider)
316
+ parts.push({ text: input.provider, priority: 8, role: "provider" });
317
+ if (input.effort)
318
+ parts.push({ text: input.effort, priority: 7, role: "effort" });
319
+ return parts;
320
+ }
321
+ /** Drops the lowest-priority identity parts until the line fits `width`; truncates the survivor. */
322
+ export function fitIdentityParts(input, width) {
323
+ return fitSegments(identityParts(input), width, " · ");
324
+ }
253
325
  export const SKILL_COMPLETION_DESCRIPTION_LIMIT = 80;
254
326
  /** Status hint prefixes (no color): shadowed < disabled < locked states shown by /skills. */
255
327
  const skillStatus = (skill) => !skill.effective
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/alisio-code",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.15",
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,14 +45,14 @@
45
45
  "alisio": "./dist/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/core": "0.1.0-alpha.12",
49
- "@alisio/plugin-deepseek": "0.1.0-alpha.9",
48
+ "@alisio/core": "0.1.0-alpha.13",
49
+ "@alisio/plugin-deepseek": "0.1.0-alpha.10",
50
50
  "@alisio/plugin-memory": "0.1.0-alpha.7",
51
51
  "@alisio/plugin-openai-compatible": "0.1.0-alpha.9",
52
52
  "@alisio/plugin-opencode": "0.1.0-alpha.8",
53
53
  "@alisio/plugin-opencode-go": "0.1.0-alpha.8",
54
- "@alisio/plugin-subagents": "0.1.0-alpha.8",
55
- "@alisio/sdk": "0.1.0-alpha.7",
54
+ "@alisio/plugin-subagents": "0.1.0-alpha.9",
55
+ "@alisio/sdk": "0.1.0-alpha.8",
56
56
  "@earendil-works/pi-tui": "0.87.1",
57
57
  "commander": "15.0.0"
58
58
  },