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

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/builtin.d.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  * Built-in plugin registry wired by the CLI. Built-ins load through the trusted path of the
3
3
  * plugin host (unprefixed names, `internal` effect) and can be disabled by configuration
4
4
  * (`builtinPlugins.<id>.enabled: false`) or `--disable-plugin <id>`. Add a built-in by
5
- * appending an entry here.
5
+ * appending an entry here. Dedicated model providers (DeepSeek, OpenCode Console, OpenCode Go)
6
+ * are no longer built-ins: users install them independently via
7
+ * `alisio install npm:@alisio/plugin-{deepseek,opencode,opencode-go}`.
6
8
  */
7
9
  import type { BuiltinPlugin } from "@alisio/core";
8
10
  export declare const BUILTIN_PLUGINS: BuiltinPlugin[];
package/dist/builtin.js CHANGED
@@ -1,31 +1,7 @@
1
- import { createDeepSeekPlugin } from "@alisio/plugin-deepseek";
2
1
  import { createMemoryPlugin } from "@alisio/plugin-memory";
3
2
  import { createOpenAICompatiblePlugin } from "@alisio/plugin-openai-compatible";
4
- import { createOpenCodePlugin } from "@alisio/plugin-opencode";
5
- import { createOpenCodeGoPlugin } from "@alisio/plugin-opencode-go";
6
3
  import { createSubagentsPlugin } from "@alisio/plugin-subagents";
7
4
  export const BUILTIN_PLUGINS = [
8
- {
9
- id: "deepseek",
10
- name: "DeepSeek",
11
- description: "Dedicated DeepSeek Chat Completions and Responses provider",
12
- categories: ["model-provider"],
13
- create: createDeepSeekPlugin,
14
- },
15
- {
16
- id: "opencode",
17
- name: "OpenCode Console (Zen)",
18
- description: "Dedicated OpenCode Console (Zen) multi-protocol provider",
19
- categories: ["model-provider"],
20
- create: createOpenCodePlugin,
21
- },
22
- {
23
- id: "opencode-go",
24
- name: "OpenCode Go",
25
- description: "Dedicated OpenCode Go multi-protocol provider",
26
- categories: ["model-provider"],
27
- create: createOpenCodeGoPlugin,
28
- },
29
5
  {
30
6
  id: "openai-compatible",
31
7
  name: "OpenAI compatible",
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
  : {}),
package/dist/tui/app.d.ts CHANGED
@@ -1,4 +1,41 @@
1
1
  import type { AppOptions } from "@alisio/core";
2
+ import { type Component, type SelectItem } from "@earendil-works/pi-tui";
3
+ /**
4
+ * Inline selection list with type-to-filter, rendered above the editor.
5
+ *
6
+ * Grouped item builders (pluginCatalogItems) interleave their output with non-selectable
7
+ * `__group:` header items. Headers stay in the SelectList so they render, scroll and truncate
8
+ * like rows, but selection is owned here: navigation jumps over headers via skipGroupHeaders
9
+ * (wrapping both directions), Enter never fires onSelect for a header, and the whole display
10
+ * sequence is re-derived on every filter keystroke so headers of fully-filtered groups vanish.
11
+ */
12
+ export declare class Picker implements Component {
13
+ private title;
14
+ private readonly onSelectItem;
15
+ private readonly onCancelPick;
16
+ private filterable;
17
+ private detail?;
18
+ private list;
19
+ private filter;
20
+ /** Full item sequence (group headers + rows) before any filtering. */
21
+ private readonly allItems;
22
+ /** Display sequence: filtered rows plus the headers of groups that still match. */
23
+ private items;
24
+ /** Selection index within `items`; always a real row, never a group header. */
25
+ private index;
26
+ /** Picker height target: counted on real rows only, so headers never stretch the list. */
27
+ private readonly maxVisible;
28
+ constructor(title: string, items: SelectItem[], onSelectItem: (item: SelectItem) => void, onCancelPick: () => void, filterable?: boolean, detail?: string | undefined);
29
+ /** Copies items, dimming group-header labels (style helpers stay plain under NO_COLOR). */
30
+ private static displayItems;
31
+ /** (Re)builds the SelectList over the filtered display sequence; selection starts on a row. */
32
+ private resetList;
33
+ invalidate(): void;
34
+ /** The currently selected item — always a real row, never a group header. */
35
+ getSelectedItem(): SelectItem | null;
36
+ handleInput(data: string): void;
37
+ render(width: number): string[];
38
+ }
2
39
  export interface TuiOptions extends AppOptions {
3
40
  session?: string;
4
41
  quiet?: boolean;
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";
@@ -14,38 +14,94 @@ import { InteractiveQueue } from "./queue.js";
14
14
  import { settingsMenuRows } from "./settings.js";
15
15
  import { SettingsMenu } from "./settings-menu.js";
16
16
  import { SkillsManager } from "./skills-manager.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
+ import { addItem, COMMANDS, configuredProviderModelItems, editorCopyKey, effectiveEffort, effortPickerItems, foldCandidates, foldToggleKey, formatContext, formatDuration, formatTokens, hostOf, initialViewState, isGroupHeader, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, richPartsOf, shortenPath, shortId, skipGroupHeaders, slashCompletionCommands, summarizeToolArgs, validateEffortLevel, visibleGroupedItems, } from "./state.js";
18
18
  import { editorTheme, selectListTheme, style } from "./theme.js";
19
19
  const VERSION = loadVersion(import.meta.url);
20
- /** Inline selection list with type-to-filter, rendered above the editor. */
21
- class Picker {
20
+ /**
21
+ * Inline selection list with type-to-filter, rendered above the editor.
22
+ *
23
+ * Grouped item builders (pluginCatalogItems) interleave their output with non-selectable
24
+ * `__group:` header items. Headers stay in the SelectList so they render, scroll and truncate
25
+ * like rows, but selection is owned here: navigation jumps over headers via skipGroupHeaders
26
+ * (wrapping both directions), Enter never fires onSelect for a header, and the whole display
27
+ * sequence is re-derived on every filter keystroke so headers of fully-filtered groups vanish.
28
+ */
29
+ export class Picker {
22
30
  title;
31
+ onSelectItem;
32
+ onCancelPick;
23
33
  filterable;
24
34
  detail;
25
- list;
35
+ list = new SelectList([], 1, selectListTheme);
26
36
  filter = "";
27
- constructor(title, items, onSelect, onCancel, filterable = false, detail) {
37
+ /** Full item sequence (group headers + rows) before any filtering. */
38
+ allItems;
39
+ /** Display sequence: filtered rows plus the headers of groups that still match. */
40
+ items = [];
41
+ /** Selection index within `items`; always a real row, never a group header. */
42
+ index = 0;
43
+ /** Picker height target: counted on real rows only, so headers never stretch the list. */
44
+ maxVisible;
45
+ constructor(title, items, onSelectItem, onCancelPick, filterable = false, detail) {
28
46
  this.title = title;
47
+ this.onSelectItem = onSelectItem;
48
+ this.onCancelPick = onCancelPick;
29
49
  this.filterable = filterable;
30
50
  this.detail = detail;
31
- this.list = new SelectList(items, Math.min(10, Math.max(1, items.length)), selectListTheme);
32
- this.list.onSelect = onSelect;
33
- this.list.onCancel = onCancel;
51
+ this.allItems = items;
52
+ this.maxVisible = Math.min(10, Math.max(1, items.filter((item) => !isGroupHeader(item)).length));
53
+ this.resetList();
54
+ }
55
+ /** Copies items, dimming group-header labels (style helpers stay plain under NO_COLOR). */
56
+ static displayItems(items) {
57
+ return items.map((item) => isGroupHeader(item) ? { ...item, label: style.gray(item.label) } : item);
58
+ }
59
+ /** (Re)builds the SelectList over the filtered display sequence; selection starts on a row. */
60
+ resetList() {
61
+ this.items = Picker.displayItems(this.filter ? visibleGroupedItems(this.allItems, this.filter) : this.allItems);
62
+ this.list = new SelectList(this.items, this.maxVisible, selectListTheme);
63
+ this.list.onSelect = (item) => {
64
+ if (!isGroupHeader(item))
65
+ this.onSelectItem(item);
66
+ };
67
+ this.list.onCancel = this.onCancelPick;
68
+ this.index = this.items.length ? skipGroupHeaders(this.items, -1, 1) : 0;
69
+ if (this.index < 0)
70
+ this.index = 0;
71
+ this.list.setSelectedIndex(this.index);
34
72
  }
35
73
  invalidate() {
36
74
  this.list.invalidate();
37
75
  }
76
+ /** The currently selected item — always a real row, never a group header. */
77
+ getSelectedItem() {
78
+ return this.items[this.index] ?? null;
79
+ }
38
80
  handleInput(data) {
39
81
  if (this.filterable && matchesKey(data, Key.backspace)) {
40
82
  this.filter = this.filter.slice(0, -1);
41
- this.list.setFilter(this.filter);
83
+ this.resetList();
42
84
  }
43
85
  else if (this.filterable && data.length === 1 && data >= " " && data <= "~") {
44
86
  this.filter += data;
45
- this.list.setFilter(this.filter);
87
+ this.resetList();
46
88
  }
47
- else
89
+ else if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
90
+ const dir = matchesKey(data, Key.down) ? 1 : -1;
91
+ this.index = this.items.length ? skipGroupHeaders(this.items, this.index, dir) : 0;
92
+ this.list.setSelectedIndex(this.index);
93
+ }
94
+ else if (matchesKey(data, Key.enter)) {
95
+ const item = this.items[this.index];
96
+ if (item && !isGroupHeader(item))
97
+ this.onSelectItem(item);
98
+ }
99
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
100
+ this.onCancelPick();
101
+ }
102
+ else {
48
103
  this.list.handleInput(data);
104
+ }
49
105
  }
50
106
  render(width) {
51
107
  const hint = this.filterable
@@ -85,6 +141,7 @@ export async function runTui(options) {
85
141
  const { BUILTIN_PROMPTS } = await import("../prompts/index.js");
86
142
  let dispatch = () => { };
87
143
  let approve = async () => "deny";
144
+ let approveExternalDirectory = async () => "deny";
88
145
  const app = await createApplication({
89
146
  builtins: BUILTIN_PLUGINS,
90
147
  builtinPrompts: BUILTIN_PROMPTS,
@@ -92,6 +149,7 @@ export async function runTui(options) {
92
149
  ...options,
93
150
  onEvent: (event) => dispatch(event),
94
151
  approve: (request) => approve(request),
152
+ approveExternalDirectory: (request) => approveExternalDirectory(request),
95
153
  });
96
154
  let activeProvider = app.providerInfo;
97
155
  let session = options.session ?? app.store.create(app.workspace, app.provider.id, app.provider.model).id;
@@ -334,14 +392,21 @@ export async function runTui(options) {
334
392
  // working (copyOnSelect must never be disturbed). The region sits between the ScrollView and
335
393
  // the content switch; content rows = visible rows + the scroll offset.
336
394
  let transcriptScroll;
337
- const transcriptRegion = new MouseRegion(new Switch(() => (viewedState() ? childView.container : transcript)), (event) => {
395
+ // Transcript content is inset from both borders in ONE place: the wrapper hands children a
396
+ // reduced width and prefixes every row, so agent text, copy hints, tool sub-lines, reasoning
397
+ // lines and group headers all share the same visual column. The config is read live, so a
398
+ // `/settings` change applies on the next render without rebuilding the view.
399
+ const contentInsetX = () => app.config.tui.contentPaddingX;
400
+ const transcriptRegion = new MouseRegion(new ContentInset(new Switch(() => (viewedState() ? childView.container : transcript)), contentInsetX), (event) => {
338
401
  if (picker || event.type !== "click" || event.button !== "left")
339
402
  return undefined;
340
403
  const scroll = transcriptScroll;
341
404
  if (!scroll)
342
405
  return undefined;
343
406
  const visible = visibleTranscript();
344
- const candidate = visible.sync.entryAt(event.y + scroll.scrollTop, event.width);
407
+ // entryAt re-renders each row to measure its height, so it must use the same reduced width
408
+ // the wrapper hands the rows during render.
409
+ const candidate = visible.sync.entryAt(event.y + scroll.scrollTop, contentInnerWidth(event.width, contentInsetX()));
345
410
  if (!candidate)
346
411
  return undefined;
347
412
  toggleFold(candidate);
@@ -493,6 +558,36 @@ export async function runTui(options) {
493
558
  ], (item) => finish(item.value), () => finish("deny")));
494
559
  }),
495
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
+ });
496
591
  /**
497
592
  * `ask_user_question` / `/ask`: routed through the same `interactiveQueue` as `approve`/`select`,
498
593
  * so a root or subagent question never races another prompt for the screen. A question withdrawn
@@ -1213,6 +1308,9 @@ export async function runTui(options) {
1213
1308
  .then(() => {
1214
1309
  if (row.id === "tui.paddingX")
1215
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();
1216
1314
  // Rebuild the slash provider: the toggle gates the skill:<id> entries immediately.
1217
1315
  if (row.id === "tui.skillSlashCommands")
1218
1316
  editor.setAutocompleteProvider(buildSlashCompletionProvider());
@@ -1297,7 +1395,7 @@ export async function runTui(options) {
1297
1395
  return `| \`${t.name}\` | ${effect} | ${state} |`;
1298
1396
  })
1299
1397
  .join("\n");
1300
- 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.`;
1398
+ return `**Tools**\n\n| Tool | Effect | State |\n| --- | --- | --- |\n${rows}\n\n\`ask\` prompts before running (allow once / session / deny). Use --allow-write / --allow-process to pre-allow; --read-only disables them. Paths outside the workspace ask per directory; pre-allow with --add-dir or the \`additionalDirectories\` config key. \`internal\` tools (built-in plugins) only write Alisio's own state.`;
1301
1399
  };
1302
1400
  const statsReport = () => {
1303
1401
  const s = view.stats;
@@ -129,6 +129,33 @@ export declare class InfoBlock implements Component {
129
129
  invalidate(): void;
130
130
  render(width: number): string[];
131
131
  }
132
+ /**
133
+ * Minimum usable inner width (cells) the transcript keeps no matter how narrow the terminal is or
134
+ * how large the configured inset grows. The effective inset is clamped so this floor is honored,
135
+ * which also keeps the inner width positive.
136
+ */
137
+ export declare const MIN_CONTENT_WIDTH = 20;
138
+ /**
139
+ * Inset actually applied on each side: the configured value capped so the inner column stays at
140
+ * least `MIN_CONTENT_WIDTH` wide. On terminals narrower than that floor the inset degrades to 0
141
+ * rather than overflowing (the inner width then simply equals the terminal width).
142
+ */
143
+ export declare function effectiveContentInset(width: number, inset: number): number;
144
+ /** Inner width handed to transcript children after the effective inset is applied. Always >= 1. */
145
+ export declare function contentInnerWidth(width: number, inset: number): number;
146
+ /**
147
+ * Applies the transcript's horizontal inset in ONE place: it hands children a reduced width and
148
+ * prefixes every produced line with the inset, so all transcript chrome (text, copy hint, tool
149
+ * sub-lines, reasoning lines, group headers) shares the same visual column. The reduced width is a
150
+ * deterministic function of the terminal width, so per-row caches keyed by width stay effective.
151
+ */
152
+ export declare class ContentInset implements Component {
153
+ private readonly inner;
154
+ private readonly inset;
155
+ constructor(inner: Component, inset: () => number);
156
+ invalidate(): void;
157
+ render(width: number): string[];
158
+ }
132
159
  export declare function componentFor(entry: TranscriptEntry): Component;
133
160
  /**
134
161
  * Keeps a container of transcript components in step with view-model items. Rows are keyed
@@ -588,6 +588,51 @@ export class InfoBlock {
588
588
  return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
589
589
  }
590
590
  }
591
+ /**
592
+ * Minimum usable inner width (cells) the transcript keeps no matter how narrow the terminal is or
593
+ * how large the configured inset grows. The effective inset is clamped so this floor is honored,
594
+ * which also keeps the inner width positive.
595
+ */
596
+ export const MIN_CONTENT_WIDTH = 20;
597
+ /**
598
+ * Inset actually applied on each side: the configured value capped so the inner column stays at
599
+ * least `MIN_CONTENT_WIDTH` wide. On terminals narrower than that floor the inset degrades to 0
600
+ * rather than overflowing (the inner width then simply equals the terminal width).
601
+ */
602
+ export function effectiveContentInset(width, inset) {
603
+ const requested = Math.max(0, Math.floor(inset));
604
+ const max = Math.max(0, Math.floor((Math.floor(width) - MIN_CONTENT_WIDTH) / 2));
605
+ return Math.min(requested, max);
606
+ }
607
+ /** Inner width handed to transcript children after the effective inset is applied. Always >= 1. */
608
+ export function contentInnerWidth(width, inset) {
609
+ return Math.max(1, Math.floor(width) - effectiveContentInset(width, inset) * 2);
610
+ }
611
+ /**
612
+ * Applies the transcript's horizontal inset in ONE place: it hands children a reduced width and
613
+ * prefixes every produced line with the inset, so all transcript chrome (text, copy hint, tool
614
+ * sub-lines, reasoning lines, group headers) shares the same visual column. The reduced width is a
615
+ * deterministic function of the terminal width, so per-row caches keyed by width stay effective.
616
+ */
617
+ export class ContentInset {
618
+ inner;
619
+ inset;
620
+ constructor(inner, inset) {
621
+ this.inner = inner;
622
+ this.inset = inset;
623
+ }
624
+ invalidate() {
625
+ this.inner.invalidate();
626
+ }
627
+ render(width) {
628
+ const inset = effectiveContentInset(width, this.inset());
629
+ const lines = this.inner.render(contentInnerWidth(width, inset));
630
+ if (!inset)
631
+ return lines;
632
+ const pad = " ".repeat(inset);
633
+ return lines.map((line) => `${pad}${line}`);
634
+ }
635
+ }
591
636
  export function componentFor(entry) {
592
637
  if (entry.kind === "group")
593
638
  return new ToolGroupBlock(entry.group);
@@ -56,6 +56,7 @@ export interface SettingsConfigView {
56
56
  };
57
57
  tui: {
58
58
  paddingX: number;
59
+ contentPaddingX: number;
59
60
  skillSlashCommands: boolean;
60
61
  };
61
62
  mcp: {
@@ -13,7 +13,7 @@ export const defaultConfig = {
13
13
  compaction: { auto: true, threshold: 0.85, keepTurns: 2, maxOutputTokens: 16_000 },
14
14
  context: { claudeMdFallback: false, maxBytes: 32 * 1024 },
15
15
  limits: { maxTurns: 100, maxOutputTokens: 16_384, maxContextChars: 800_000, timeoutMs: 300_000 },
16
- tui: { paddingX: 1, skillSlashCommands: true },
16
+ tui: { paddingX: 1, contentPaddingX: 2, skillSlashCommands: true },
17
17
  mcp: { allow: false },
18
18
  websearch: { provider: undefined },
19
19
  pluginHooks: { timeoutMs: 15_000 },
@@ -161,6 +161,15 @@ export const SETTINGS_DEFINITIONS = [
161
161
  read: (config) => config.tui.paddingX,
162
162
  description: "Horizontal padding (columns) around the editor input box. Applied immediately to the current editor.",
163
163
  },
164
+ {
165
+ id: "tui.contentPaddingX",
166
+ label: "Content inset",
167
+ category: "TUI",
168
+ valueType: "number",
169
+ values: [0, 1, 2, 3, 4, 6, 8, 12],
170
+ read: (config) => config.tui.contentPaddingX,
171
+ description: "Horizontal inset (columns) on each side of the transcript content, keeping agent-written rows clear of the window borders. Clamped on narrow terminals so the content column stays usable. Applied immediately.",
172
+ },
164
173
  {
165
174
  id: "tui.skillSlashCommands",
166
175
  label: "Skill slash commands",
@@ -39,11 +39,46 @@ export interface PluginCatalogView {
39
39
  }
40
40
  /** Text markers remain meaningful without color: [x] active, [ ] inactive, [!] failed, [*] pending. */
41
41
  /** Group headings are derived from the primary category (or "General") of each plugin. */
42
- export declare function pluginCatalogItems(entries: PluginCatalogView[]): {
42
+ /**
43
+ * Reserved value prefix marking a non-selectable group-header row in picker item lists. It can
44
+ * never collide with real entry ids (plugin ids, server names), which never start with "_".
45
+ */
46
+ export declare const GROUP_HEADER_PREFIX = "__group:";
47
+ /** True when the item is a group header emitted under GROUP_HEADER_PREFIX (never selectable). */
48
+ export declare const isGroupHeader: (item: {
49
+ value: string;
50
+ }) => boolean;
51
+ /**
52
+ * Nearest non-header index from `index` stepping `dir` (1 = down, -1 = up), wrapping at the
53
+ * edges exactly like a plain list would, so navigation skips header rows in both directions and
54
+ * never stops on one. Returns -1 for an empty list and `index` unchanged when every item is a
55
+ * header (callers then treat it as "no move").
56
+ */
57
+ export declare function skipGroupHeaders(items: readonly {
58
+ value: string;
59
+ }[], index: number, dir: 1 | -1): number;
60
+ /**
61
+ * The display sequence a picker should render for a filter: rows matching SelectList's own
62
+ * prefix semantics, plus the group header of every group that still has at least one visible
63
+ * row, in original order. Groups with no match emit no header.
64
+ */
65
+ export declare function visibleGroupedItems(items: ReadonlyArray<{
66
+ value: string;
67
+ label: string;
68
+ description?: string;
69
+ }>, filter: string): Array<{
70
+ value: string;
71
+ label: string;
72
+ description?: string;
73
+ }>;
74
+ export declare function pluginCatalogItems(entries: PluginCatalogView[]): ({
75
+ value: string;
76
+ label: string;
77
+ } | {
43
78
  value: string;
44
79
  label: string;
45
80
  description: string;
46
- }[];
81
+ })[];
47
82
  export declare const pluginToggleNeedsConfirmation: (entry: PluginCatalogView) => boolean;
48
83
  export interface McpServerView {
49
84
  name: string;
package/dist/tui/state.js CHANGED
@@ -24,6 +24,55 @@ export function providerModelItems(provider, models, current) {
24
24
  }
25
25
  /** Text markers remain meaningful without color: [x] active, [ ] inactive, [!] failed, [*] pending. */
26
26
  /** Group headings are derived from the primary category (or "General") of each plugin. */
27
+ /**
28
+ * Reserved value prefix marking a non-selectable group-header row in picker item lists. It can
29
+ * never collide with real entry ids (plugin ids, server names), which never start with "_".
30
+ */
31
+ export const GROUP_HEADER_PREFIX = "__group:";
32
+ /** True when the item is a group header emitted under GROUP_HEADER_PREFIX (never selectable). */
33
+ export const isGroupHeader = (item) => item.value.startsWith(GROUP_HEADER_PREFIX);
34
+ /**
35
+ * Nearest non-header index from `index` stepping `dir` (1 = down, -1 = up), wrapping at the
36
+ * edges exactly like a plain list would, so navigation skips header rows in both directions and
37
+ * never stops on one. Returns -1 for an empty list and `index` unchanged when every item is a
38
+ * header (callers then treat it as "no move").
39
+ */
40
+ export function skipGroupHeaders(items, index, dir) {
41
+ const count = items.length;
42
+ if (!count)
43
+ return -1;
44
+ let next = (index + dir + count) % count;
45
+ for (let steps = 0; steps < count && next !== index; steps++) {
46
+ if (!isGroupHeader(items[next]))
47
+ return next;
48
+ next = (next + dir + count) % count;
49
+ }
50
+ return index;
51
+ }
52
+ /**
53
+ * The display sequence a picker should render for a filter: rows matching SelectList's own
54
+ * prefix semantics, plus the group header of every group that still has at least one visible
55
+ * row, in original order. Groups with no match emit no header.
56
+ */
57
+ export function visibleGroupedItems(items, filter) {
58
+ const query = filter.toLowerCase();
59
+ const visible = [];
60
+ let header;
61
+ for (const item of items) {
62
+ if (isGroupHeader(item)) {
63
+ header = item;
64
+ continue;
65
+ }
66
+ if (item.value.toLowerCase().startsWith(query)) {
67
+ if (header) {
68
+ visible.push(header);
69
+ header = undefined;
70
+ }
71
+ visible.push(item);
72
+ }
73
+ }
74
+ return visible;
75
+ }
27
76
  export function pluginCatalogItems(entries) {
28
77
  const marker = (entry) => entry.status === "active"
29
78
  ? "[x]"
@@ -37,11 +86,15 @@ export function pluginCatalogItems(entries) {
37
86
  const primary = entry.categories[0] ?? "General";
38
87
  grouped.set(primary, [...(grouped.get(primary) ?? []), entry]);
39
88
  }
40
- return [...grouped].flatMap(([title, group]) => group.map((entry, index) => ({
41
- value: entry.id,
42
- label: `${index === 0 ? `${title} · ` : ""}${marker(entry)} ${entry.name} · ${entry.builtin ? "built-in" : entry.source}`,
43
- description: `${entry.status}${entry.categories.length ? ` · ${entry.categories.join(", ")}` : ""} · ${entry.description}`,
44
- })));
89
+ // Every group opens with its own non-selectable header row; plugin rows are indented under it.
90
+ return [...grouped].flatMap(([title, group]) => [
91
+ { value: `${GROUP_HEADER_PREFIX}${title}`, label: `${title} ›` },
92
+ ...group.map((entry) => ({
93
+ value: entry.id,
94
+ label: ` ${marker(entry)} ${entry.name} · ${entry.builtin ? "built-in" : entry.source}`,
95
+ description: `${entry.status}${entry.categories.length ? ` · ${entry.categories.join(", ")}` : ""} · ${entry.description}`,
96
+ })),
97
+ ]);
45
98
  }
46
99
  export const pluginToggleNeedsConfirmation = (entry) => !entry.builtin;
47
100
  const mcpSourceTitle = (kind) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/alisio-code",
3
- "version": "0.1.0-alpha.18",
3
+ "version": "0.1.0-alpha.20",
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,11 @@
45
45
  "alisio": "./dist/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/core": "0.1.0-alpha.16",
49
- "@alisio/plugin-deepseek": "0.1.0-alpha.10",
48
+ "@alisio/core": "0.1.0-alpha.17",
50
49
  "@alisio/plugin-memory": "0.1.0-alpha.9",
51
50
  "@alisio/plugin-openai-compatible": "0.1.0-alpha.9",
52
- "@alisio/plugin-opencode": "0.1.0-alpha.8",
53
- "@alisio/plugin-opencode-go": "0.1.0-alpha.8",
54
51
  "@alisio/plugin-subagents": "0.1.0-alpha.10",
55
- "@alisio/sdk": "0.1.0-alpha.11",
52
+ "@alisio/sdk": "0.1.0-alpha.12",
56
53
  "@earendil-works/pi-tui": "0.87.1",
57
54
  "commander": "15.0.0"
58
55
  },