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

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
- import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, matchesKey, ProcessTerminal, ScrollView, SelectList, TuiAltScreen, truncateToWidth, VStack, } from "@earendil-works/pi-tui";
2
+ import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, MouseRegion, 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, editorCopyKey, effectiveEffort, effortPickerItems, foldCandidates, foldToggleKey, formatContext, formatDuration, formatTokens, hostOf, initialViewState, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, richPartsOf, 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,
@@ -142,6 +170,23 @@ export async function runTui(options) {
142
170
  // Read-only views of child sessions (subagents), fed by their events.
143
171
  const childViews = new Map();
144
172
  const childView = new TranscriptSync();
173
+ // Fold state of every collapsible transcript row (reasoning sections, tool batches, long
174
+ // outputs). Keyed by stable fold keys from state.ts; absent entries use the pure defaults.
175
+ const folded = new Map();
176
+ const foldOf = (candidate) => folded.get(candidate.key) ?? candidate.defaultExpanded;
177
+ main.setFoldResolver(foldOf);
178
+ childView.setFoldResolver(foldOf);
179
+ /** Which transcript is on screen right now (child view while viewing a subagent, else main). */
180
+ const visibleTranscript = () => viewedState()
181
+ ? { items: viewedState()?.items ?? [], sync: childView }
182
+ : { items: view.items, sync: main };
183
+ /** Applies one fold toggle and re-syncs only the affected transcript (cheap: events, not frames). */
184
+ const toggleFold = (candidate) => {
185
+ folded.set(candidate.key, !(folded.get(candidate.key) ?? candidate.defaultExpanded));
186
+ const visible = visibleTranscript();
187
+ visible.sync.sync(visible.items);
188
+ tui.requestRender();
189
+ };
145
190
  let panelState = initialPanelState();
146
191
  const startupDiagnostics = [];
147
192
  let hint;
@@ -170,6 +215,24 @@ export async function runTui(options) {
170
215
  };
171
216
  refreshBranch();
172
217
  const branchTimer = setInterval(refreshBranch, BRANCH_REFRESH_MS);
218
+ /**
219
+ * Selectable ACTIVE (main-session) agents: the built-in `build`/`plan` pair plus the
220
+ * main-capable definitions the subagents plugin publishes (`mode: primary|all`) through its
221
+ * plugin state. Never imports the plugin package; a missing/unavailable contribution is just
222
+ * an empty extra list. The persisted `agents.active` id resolves against this catalog.
223
+ */
224
+ const mainAgents = () => {
225
+ let contributions = [];
226
+ try {
227
+ const records = app.plugins.pluginState("subagents", "mainAgents");
228
+ contributions = (records ?? []).map(mainAgentFromRecord);
229
+ }
230
+ catch {
231
+ /* plugin state is best-effort */
232
+ }
233
+ return activeAgentCatalog(contributions);
234
+ };
235
+ const currentAgent = () => resolveActiveAgent(mainAgents(), app.config.agents.active);
173
236
  const headerInfo = () => {
174
237
  const policy = app.runner.policy, ask = app.runner.approvals;
175
238
  return {
@@ -177,6 +240,8 @@ export async function runTui(options) {
177
240
  host: hostOf(String(activeProvider?.profile.baseURL ?? app.config.provider.baseURL)),
178
241
  apiMode: String(activeProvider?.profile.apiMode ?? app.config.provider.apiMode),
179
242
  provider: app.providers.get(activeProvider?.id ?? "")?.name,
243
+ modelName: modelDisplayName(view.model),
244
+ effort: currentEffort(),
180
245
  cwd: shortenPath(app.workspace, homedir()),
181
246
  session: shortId(session),
182
247
  branch: branchName,
@@ -228,7 +293,15 @@ export async function runTui(options) {
228
293
  : panelState.focus === "view"
229
294
  ? viewHint()
230
295
  : undefined;
231
- const footer = new Footer(() => viewedState() ?? view, () => app.contextBudget((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text));
296
+ const footer = new Footer(() => viewedState() ?? view, () => app.contextBudget((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text),
297
+ // Status row below the editor: `agent: <name> · <model> · <provider> · <effort>` (the
298
+ // effort segment appears only when the active model advertises supported levels).
299
+ () => ({
300
+ agent: currentAgent().name,
301
+ model: modelDisplayName(view.model),
302
+ provider: app.providers.get(activeProvider?.id ?? "")?.name,
303
+ effort: currentEffort(),
304
+ }));
232
305
  const treePanel = new TreePanel(() => {
233
306
  const entry = panelEntry();
234
307
  if (!entry)
@@ -256,18 +329,32 @@ export async function runTui(options) {
256
329
  bottom.addChild(editor);
257
330
  bottom.addChild(treePanel);
258
331
  bottom.addChild(footer);
332
+ // Click-to-toggle on collapsible transcript rows: pi-tui synthesizes `click` events after a
333
+ // press+release without movement, so plain clicks fold/unfold while drag-selection keeps
334
+ // working (copyOnSelect must never be disturbed). The region sits between the ScrollView and
335
+ // the content switch; content rows = visible rows + the scroll offset.
336
+ let transcriptScroll;
337
+ const transcriptRegion = new MouseRegion(new Switch(() => (viewedState() ? childView.container : transcript)), (event) => {
338
+ if (picker || event.type !== "click" || event.button !== "left")
339
+ return undefined;
340
+ const scroll = transcriptScroll;
341
+ if (!scroll)
342
+ return undefined;
343
+ const visible = visibleTranscript();
344
+ const candidate = visible.sync.entryAt(event.y + scroll.scrollTop, event.width);
345
+ if (!candidate)
346
+ return undefined;
347
+ toggleFold(candidate);
348
+ return { handled: true };
349
+ });
350
+ transcriptScroll = new ScrollView(transcriptRegion, {
351
+ follow: "end",
352
+ primary: true,
353
+ overscroll: "chain",
354
+ });
259
355
  tui.setLayoutRoot(new VStack([
260
356
  { component: header, basis: "auto" },
261
- {
262
- component: new ScrollView(new Switch(() => (viewedState() ? childView.container : transcript)), {
263
- follow: "end",
264
- primary: true,
265
- overscroll: "chain",
266
- }),
267
- basis: 0,
268
- grow: 1,
269
- minSize: 1,
270
- },
357
+ { component: transcriptScroll, basis: 0, grow: 1, minSize: 1 },
271
358
  { component: bottom, basis: "auto", shrink: 1, minSize: 3 },
272
359
  ]));
273
360
  // Prime the active provider's catalog so the context bar shows the model's real window
@@ -317,6 +404,14 @@ export async function runTui(options) {
317
404
  const info = (text) => push({ kind: "info", text });
318
405
  let terminalEvent = false;
319
406
  dispatch = (event) => {
407
+ // The event stream stays text-only (headless/JSONL consumers see the projection); the TUI
408
+ // pulls the persisted rich parts (ui/image) itself so native rendering survives resume.
409
+ if (event.type === "tool_completed") {
410
+ const data = (event.data ?? {});
411
+ const rich = richPartsOf(app.store.callResult(event.sessionId, String(data.id ?? "")));
412
+ if (rich.ui !== undefined || rich.image !== undefined)
413
+ event = { ...event, data: { ...data, ...rich } };
414
+ }
320
415
  if (event.sessionId !== session) {
321
416
  // Child session (e.g. a subagent): keep a view model for its read-only view.
322
417
  const child = childViews.get(event.sessionId) ?? initialViewState(view.model);
@@ -489,7 +584,14 @@ export async function runTui(options) {
489
584
  pendingAttachments = [];
490
585
  attachmentsBar.invalidate();
491
586
  push({ kind: "user", text: display });
587
+ // The ACTIVE agent drives the main session: its system prompt is appended per run and a
588
+ // read-only agent narrows the run to reads (no approvals). The persisted reasoning effort is
589
+ // resolved against the ACTIVE model and sent when the model advertises supported levels.
590
+ const agent = currentAgent();
591
+ const effort = currentEffort();
492
592
  return task((signal) => app.runner.run(session, prompt, signal, {
593
+ ...agentRunOptions(agent),
594
+ ...(effort ? { reasoningEffort: effort } : {}),
493
595
  ...(persistDisplay ? { display: persistDisplay } : {}),
494
596
  ...(attachments.length ? { attachments } : {}),
495
597
  }));
@@ -953,6 +1055,93 @@ export async function runTui(options) {
953
1055
  };
954
1056
  openCatalog();
955
1057
  };
1058
+ /**
1059
+ * `/agents` (active agent): a navigable picker listing every selectable main-session agent with
1060
+ * its description. Selecting one persists `agents.active` globally (applies from the next
1061
+ * prompt), optionally switches the session model to the agent's declared model, and starts a
1062
+ * fresh session just like `/model` when it does. Esc returns to the editor.
1063
+ */
1064
+ const openAgentsPicker = () => {
1065
+ const agents = mainAgents();
1066
+ const current = currentAgent();
1067
+ showPicker(new Picker("Active agent", agentPickerItems(agents, current.id), (item) => {
1068
+ const agent = agents.find((candidate) => candidate.id === item.value);
1069
+ if (agent)
1070
+ void applyActiveAgent(agent);
1071
+ }, closePicker, true, "Changing the active agent takes effect from the next prompt; the current session is kept unless the agent declares a different model."));
1072
+ };
1073
+ const applyActiveAgent = (agent) => {
1074
+ void (async () => {
1075
+ closePicker();
1076
+ const previous = currentAgent().name;
1077
+ try {
1078
+ await app.updateSetting("agents.active", agent.id);
1079
+ let switched = false;
1080
+ if (agent.model && app.store.get(session).model !== agent.model) {
1081
+ flashHint(`Switching to ${agent.name}'s model ${agent.model}…`, 60_000);
1082
+ const created = await app.switchModel(agent.model);
1083
+ if (created) {
1084
+ activeProvider = app.providerInfo;
1085
+ modelList = undefined;
1086
+ session = created.id;
1087
+ reset(initialViewState(created.model));
1088
+ switched = true;
1089
+ }
1090
+ }
1091
+ notice(`Active agent: ${agent.name}${agent.readOnly ? " (read-only)" : ""}. Applies from the next prompt.`);
1092
+ if (agent.model && switched)
1093
+ notice(`Agent ${agent.name} switches the session model to ${app.store.get(session).model}.`);
1094
+ else if (agent.model)
1095
+ flashHint(`Agent ${agent.name} keeps the current model (already ${view.model}). Override any time with /model.`);
1096
+ else
1097
+ flashHint(`Active agent: ${agent.name} (was ${previous}).`);
1098
+ refreshEffort();
1099
+ primeModels();
1100
+ tui.requestRender();
1101
+ }
1102
+ catch (cause) {
1103
+ error(cause);
1104
+ }
1105
+ })();
1106
+ };
1107
+ /**
1108
+ * `/effort [level]`: without an argument, a picker over the ACTIVE model's advertised effort
1109
+ * levels (marking the model default); with an argument, the level is validated against
1110
+ * `effort.supportedLevels` and persisted globally. The chosen level is sent from the next
1111
+ * prompt; if the model later changes to one that does not support it, the model's default is
1112
+ * used silently with a one-time notice.
1113
+ */
1114
+ const setEffort = async (args) => {
1115
+ const capability = effortSupportFor(view.model);
1116
+ if (!args) {
1117
+ if (!capability?.supportedLevels?.length)
1118
+ return notice(`${view.model} does not advertise reasoning effort levels (ModelInfo.effort), so /effort is not available for it.`);
1119
+ const items = effortPickerItems(capability, currentEffort());
1120
+ showPicker(new Picker(`Reasoning effort · ${modelDisplayName(view.model)}`, items, (item) => void applyEffort(item.value), closePicker, true));
1121
+ return;
1122
+ }
1123
+ const invalid = validateEffortLevel(args, capability);
1124
+ if (invalid)
1125
+ return notice(invalid);
1126
+ await applyEffort(args);
1127
+ };
1128
+ const applyEffort = async (value) => {
1129
+ const clear = value === "!clear";
1130
+ closePicker();
1131
+ try {
1132
+ await app.updateSetting("agents.effort", clear ? undefined : value);
1133
+ const capability = effortSupportFor(view.model);
1134
+ const effective = effectiveEffort(clear ? undefined : value, capability);
1135
+ notice(clear
1136
+ ? `Reasoning effort cleared; ${capability?.defaultLevel ? `the model default "${capability.defaultLevel}" is used` : "the provider default is used"}.`
1137
+ : `Reasoning effort: ${value}${value === capability?.defaultLevel ? ` (default for ${modelDisplayName(view.model)})` : ""}${effective && effective !== value ? ` — effective: ${effective}` : ""}. Applies from the next prompt.`);
1138
+ refreshEffort();
1139
+ tui.requestRender();
1140
+ }
1141
+ catch (cause) {
1142
+ error(cause);
1143
+ }
1144
+ };
956
1145
  /**
957
1146
  * Navigation rows at the bottom of `/settings`: they route to the existing managers and one-shot
958
1147
  * actions exactly like the old picker, so every prior entry stays reachable. `compact` and
@@ -1166,7 +1355,7 @@ export async function runTui(options) {
1166
1355
  "- `/init` (above) writes AGENTS.md; the shell command `alisio setup` only scaffolds `.alisio/config.json`",
1167
1356
  "- `/command plugin.id:name args` — run a plugin command",
1168
1357
  "",
1169
- "**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 · PgUp/PgDn or mouse wheel scroll · Ctrl+X agent panel · Ctrl+B background running agents",
1358
+ "**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",
1170
1359
  `**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`,
1171
1360
  ].join("\n");
1172
1361
  const mutating = new Set([
@@ -1256,6 +1445,30 @@ export async function runTui(options) {
1256
1445
  `(reuse the user's own wording for the question text). Do not answer in plain text ` +
1257
1446
  `first; call the tool right away.`);
1258
1447
  }
1448
+ case "agents": {
1449
+ // The subagents plugin registers its own built-in `/agents` command for subagent task
1450
+ // management (list/open/cancel/kill/resume/merge/discard/defs). With arguments, route
1451
+ // to it verbatim (still available while a turn runs, exactly as before); without
1452
+ // arguments, this opens the ACTIVE-agent picker.
1453
+ if (parsed.args) {
1454
+ const handler = app.plugins.commands.get("agents");
1455
+ if (handler)
1456
+ return info(await handler(parsed.args, { sessionId: session }));
1457
+ }
1458
+ if (busy) {
1459
+ editor.setText(raw);
1460
+ flashHint("A turn is running: wait for it to finish before switching the active agent");
1461
+ return;
1462
+ }
1463
+ return openAgentsPicker();
1464
+ }
1465
+ case "effort":
1466
+ if (busy) {
1467
+ editor.setText(raw);
1468
+ flashHint("A turn is running: wait for it to finish before changing the effort");
1469
+ return;
1470
+ }
1471
+ return await setEffort(parsed.args);
1259
1472
  case "clear": {
1260
1473
  await endSession("clear");
1261
1474
  session = app.store.create(app.workspace, app.provider.id, view.model).id;
@@ -1433,6 +1646,20 @@ export async function runTui(options) {
1433
1646
  removeAttachment();
1434
1647
  return { consume: true };
1435
1648
  }
1649
+ // `x` on an EMPTY editor line toggles the nearest collapsible transcript row — a finished
1650
+ // `Thought` section, a grouped batch of tool calls, or a long command output. Same
1651
+ // empty-input convention as Enter, Ctrl+D and `c`/`y`; typing `x` mid-message is never
1652
+ // intercepted, and with nothing collapsible it types normally.
1653
+ if (foldToggleKey(data, {
1654
+ text: editor.getText(),
1655
+ autocomplete: editor.isShowingAutocomplete(),
1656
+ })) {
1657
+ const target = foldCandidates(visibleTranscript().items).at(-1);
1658
+ if (target) {
1659
+ toggleFold(target);
1660
+ return { consume: true };
1661
+ }
1662
+ }
1436
1663
  }
1437
1664
  if (!picker) {
1438
1665
  if (matchesKey(data, Key.ctrl("x"))) {
@@ -1469,6 +1696,24 @@ export async function runTui(options) {
1469
1696
  return { consume: true };
1470
1697
  }
1471
1698
  }
1699
+ // `c`/`y` on an EMPTY editor line copies the last assistant response as raw text, the same
1700
+ // as /copy; typing `c` or `y` mid-message is never intercepted.
1701
+ if (editorCopyKey(data, {
1702
+ text: editor.getText(),
1703
+ autocomplete: editor.isShowingAutocomplete(),
1704
+ busy,
1705
+ })) {
1706
+ const last = lastAssistantText(view.items);
1707
+ if (last) {
1708
+ void copy(last).then((result) => {
1709
+ const message = copyMessage(result);
1710
+ tui.flash(message);
1711
+ if (!result.ok)
1712
+ notice(message);
1713
+ });
1714
+ }
1715
+ return { consume: true };
1716
+ }
1472
1717
  }
1473
1718
  if (matchesKey(data, Key.escape)) {
1474
1719
  if (busy && !picker && !editor.isShowingAutocomplete()) {