@alisio/alisio-code 0.1.0-alpha.10 → 0.1.0-alpha.11
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/tui/app.js +168 -30
- package/dist/tui/settings-menu.d.ts +40 -0
- package/dist/tui/settings-menu.js +132 -0
- package/dist/tui/settings.d.ts +168 -0
- package/dist/tui/settings.js +330 -0
- package/dist/tui/state.d.ts +71 -0
- package/dist/tui/state.js +86 -0
- package/package.json +2 -2
package/dist/tui/app.js
CHANGED
|
@@ -8,8 +8,10 @@ import { ConnectInputPrompt } from "./connect-input.js";
|
|
|
8
8
|
import { initialPanelState, reducePanel, visibleRows } from "./panel.js";
|
|
9
9
|
import { summarizeAnswers } from "./questions.js";
|
|
10
10
|
import { InteractiveQueue } from "./queue.js";
|
|
11
|
+
import { settingsMenuRows } from "./settings.js";
|
|
12
|
+
import { SettingsMenu } from "./settings-menu.js";
|
|
11
13
|
import { SkillsManager } from "./skills-manager.js";
|
|
12
|
-
import { addItem, COMMANDS, configuredProviderModelItems, formatContext, formatDuration, formatTokens, hostOf, initialViewState, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, shortenPath, shortId, summarizeToolArgs, } from "./state.js";
|
|
14
|
+
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";
|
|
13
15
|
import { editorTheme, selectListTheme, style } from "./theme.js";
|
|
14
16
|
const VERSION = loadVersion(import.meta.url);
|
|
15
17
|
/** Inline selection list with type-to-filter, rendered above the editor. */
|
|
@@ -229,7 +231,7 @@ export async function runTui(options) {
|
|
|
229
231
|
app.plugins.onStatusChange = () => tui.requestRender();
|
|
230
232
|
const pickerSlot = new Container();
|
|
231
233
|
const attachmentsBar = new AttachmentsBar(() => pendingAttachments);
|
|
232
|
-
const editor = new Editor(tui, editorTheme, { paddingX:
|
|
234
|
+
const editor = new Editor(tui, editorTheme, { paddingX: app.config.tui.paddingX });
|
|
233
235
|
const bottom = new Container();
|
|
234
236
|
bottom.addChild(pickerSlot);
|
|
235
237
|
bottom.addChild(attachmentsBar);
|
|
@@ -323,7 +325,7 @@ export async function runTui(options) {
|
|
|
323
325
|
})
|
|
324
326
|
.catch(() => { });
|
|
325
327
|
};
|
|
326
|
-
let busy = false, controller, pending, picker;
|
|
328
|
+
let busy = false, controller, pending, picker, settingsMenu;
|
|
327
329
|
const showPicker = (next) => {
|
|
328
330
|
picker = next;
|
|
329
331
|
pickerSlot.clear();
|
|
@@ -333,6 +335,7 @@ export async function runTui(options) {
|
|
|
333
335
|
};
|
|
334
336
|
const closePicker = () => {
|
|
335
337
|
picker = undefined;
|
|
338
|
+
settingsMenu = undefined;
|
|
336
339
|
pickerSlot.clear();
|
|
337
340
|
tui.setFocus(editor);
|
|
338
341
|
tui.requestRender();
|
|
@@ -922,6 +925,125 @@ export async function runTui(options) {
|
|
|
922
925
|
};
|
|
923
926
|
openCatalog();
|
|
924
927
|
};
|
|
928
|
+
/**
|
|
929
|
+
* Navigation rows at the bottom of `/settings`: they route to the existing managers and one-shot
|
|
930
|
+
* actions exactly like the old picker, so every prior entry stays reachable. `compact` and
|
|
931
|
+
* `stats` finish here and re-open the settings list (mirroring managePlugins' openCatalog loop);
|
|
932
|
+
* rows that delegate to another manager (provider/model, connect, plugins, skills, MCP) leave
|
|
933
|
+
* the list and that manager owns its Esc/back behavior.
|
|
934
|
+
*/
|
|
935
|
+
const SETTINGS_NAVIGATION = [
|
|
936
|
+
{
|
|
937
|
+
id: "model",
|
|
938
|
+
label: "Provider & model",
|
|
939
|
+
description: "Switch the active provider and model",
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
id: "connect",
|
|
943
|
+
label: "Connect provider",
|
|
944
|
+
description: "Configure a provider and choose its active model",
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
id: "compact",
|
|
948
|
+
label: "Compact context now",
|
|
949
|
+
description: "Summarize older history with the current model, then return here",
|
|
950
|
+
},
|
|
951
|
+
{ id: "plugins", label: "Plugins", description: "Browse and manage project plugins" },
|
|
952
|
+
{ id: "skills", label: "Skills", description: "Browse and manage effective skills" },
|
|
953
|
+
{ id: "mcp", label: "MCP servers", description: "Browse and manage MCP servers" },
|
|
954
|
+
{
|
|
955
|
+
id: "stats",
|
|
956
|
+
label: "Session statistics",
|
|
957
|
+
description: "View session statistics, then return here",
|
|
958
|
+
},
|
|
959
|
+
];
|
|
960
|
+
/**
|
|
961
|
+
* Central `/settings` (`/prefs`) menu: an OpenCode-style list of REAL, wired preferences
|
|
962
|
+
* (compaction, context fallback, MCP consent, limits, editor padding) with a live type-to-search
|
|
963
|
+
* filter, Enter/Space to cycle a value, a `(n/total)` counter, and a footer describing the
|
|
964
|
+
* highlighted row. Esc returns to the editor. Every setting persists to the GLOBAL user config
|
|
965
|
+
* through `app.updateSetting` (or the MCP consent path) and is applied to the running process
|
|
966
|
+
* where supported; rows rebuild from the live config after each change.
|
|
967
|
+
*/
|
|
968
|
+
const openSettings = () => {
|
|
969
|
+
const providerName = app.providers.get(activeProvider?.id ?? "")?.name;
|
|
970
|
+
const current = view.model
|
|
971
|
+
? providerName
|
|
972
|
+
? `${providerName} · ${view.model}`
|
|
973
|
+
: view.model
|
|
974
|
+
: "";
|
|
975
|
+
const build = () => settingsMenuRows({
|
|
976
|
+
config: app.config,
|
|
977
|
+
mcpAllowPersisted: app.mcpAllowPersisted(),
|
|
978
|
+
readOnly: !!options.readOnly,
|
|
979
|
+
}, SETTINGS_NAVIGATION);
|
|
980
|
+
const applySetting = (row, value) => {
|
|
981
|
+
const settle = () => settingsMenu?.refresh(build());
|
|
982
|
+
if (row.id === "mcp.allow") {
|
|
983
|
+
const consent = value === true ? app.rememberGlobalMcpConsent() : app.revokeGlobalMcpConsent();
|
|
984
|
+
void consent
|
|
985
|
+
.then(() => notice(value === true
|
|
986
|
+
? "MCP consent remembered globally (mcp.allow); enabled servers will auto-connect on every start. Use /mcp to connect them now."
|
|
987
|
+
: "Global MCP consent revoked: runtime permission dropped and configured servers disconnected."))
|
|
988
|
+
.catch(error)
|
|
989
|
+
.finally(settle);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const stored = row.id === "limits.timeoutMs" ? Number(value) * 1000 : value;
|
|
993
|
+
void app
|
|
994
|
+
// Setting ids are the exact SettableSettingKey paths SETTINGS_DEFINITIONS is built from.
|
|
995
|
+
.updateSetting(row.id, stored)
|
|
996
|
+
.then(() => {
|
|
997
|
+
if (row.id === "tui.paddingX")
|
|
998
|
+
editor.setPaddingX(Number(value));
|
|
999
|
+
// Rebuild the slash provider: the toggle gates the skill:<id> entries immediately.
|
|
1000
|
+
if (row.id === "tui.skillSlashCommands")
|
|
1001
|
+
editor.setAutocompleteProvider(buildSlashCompletionProvider());
|
|
1002
|
+
})
|
|
1003
|
+
.catch(error)
|
|
1004
|
+
.finally(settle);
|
|
1005
|
+
};
|
|
1006
|
+
const navigate = (row) => {
|
|
1007
|
+
switch (row.action) {
|
|
1008
|
+
case "model":
|
|
1009
|
+
closePicker();
|
|
1010
|
+
return void chooseModel();
|
|
1011
|
+
case "connect":
|
|
1012
|
+
closePicker();
|
|
1013
|
+
return void connect();
|
|
1014
|
+
case "compact":
|
|
1015
|
+
closePicker();
|
|
1016
|
+
void task((signal) => app.runner.compact(session, { signal }))
|
|
1017
|
+
.catch(error)
|
|
1018
|
+
.then(openSettings);
|
|
1019
|
+
return;
|
|
1020
|
+
case "plugins":
|
|
1021
|
+
closePicker();
|
|
1022
|
+
return managePlugins();
|
|
1023
|
+
case "skills":
|
|
1024
|
+
closePicker();
|
|
1025
|
+
return manageSkills();
|
|
1026
|
+
case "mcp":
|
|
1027
|
+
closePicker();
|
|
1028
|
+
return manageMcp();
|
|
1029
|
+
case "stats":
|
|
1030
|
+
closePicker();
|
|
1031
|
+
info(statsReport());
|
|
1032
|
+
openSettings();
|
|
1033
|
+
return;
|
|
1034
|
+
default:
|
|
1035
|
+
return openSettings();
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
const menu = new SettingsMenu("Settings", build(), applySetting, navigate, closePicker, [
|
|
1039
|
+
current ? `Current: ${current}` : "",
|
|
1040
|
+
...(options.readOnly ? ["Read-only run: changes are not persisted"] : []),
|
|
1041
|
+
]
|
|
1042
|
+
.filter(Boolean)
|
|
1043
|
+
.join("\n") || undefined);
|
|
1044
|
+
settingsMenu = menu;
|
|
1045
|
+
showPicker(menu);
|
|
1046
|
+
};
|
|
925
1047
|
const workspaceSessions = () => app.store.list().filter((s) => s.workspace === app.workspace && s.provider === app.provider.id);
|
|
926
1048
|
const firstPrompt = (id) => {
|
|
927
1049
|
const first = app.store.messages(id).find((m) => m.role === "user" && !m.summary);
|
|
@@ -1025,6 +1147,7 @@ export async function runTui(options) {
|
|
|
1025
1147
|
"plugins",
|
|
1026
1148
|
"skills",
|
|
1027
1149
|
"mcp",
|
|
1150
|
+
"settings",
|
|
1028
1151
|
"compact",
|
|
1029
1152
|
"clear",
|
|
1030
1153
|
"resume",
|
|
@@ -1081,6 +1204,8 @@ export async function runTui(options) {
|
|
|
1081
1204
|
return manageSkills();
|
|
1082
1205
|
case "mcp":
|
|
1083
1206
|
return manageMcp();
|
|
1207
|
+
case "settings":
|
|
1208
|
+
return openSettings();
|
|
1084
1209
|
case "compact":
|
|
1085
1210
|
return await task((signal) => app.runner.compact(session, { focus: parsed.args || undefined, signal }));
|
|
1086
1211
|
case "copy": {
|
|
@@ -1149,33 +1274,46 @@ export async function runTui(options) {
|
|
|
1149
1274
|
editor.onSubmit = (text) => {
|
|
1150
1275
|
void handleSubmit(text);
|
|
1151
1276
|
};
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
.
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
:
|
|
1178
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* Builds the editor slash-autocomplete provider from the live config: `tui.skillSlashCommands`
|
|
1279
|
+
* gates the standalone `skill:<id>` entries (the `/skills` manager always keeps its argument
|
|
1280
|
+
* completion), so rebuilding after a settings save takes effect without a restart.
|
|
1281
|
+
*/
|
|
1282
|
+
const buildSlashCompletionProvider = () => {
|
|
1283
|
+
const sources = [
|
|
1284
|
+
...COMMANDS,
|
|
1285
|
+
...[...app.prompts.templates.values()].map((t) => ({
|
|
1286
|
+
name: t.name,
|
|
1287
|
+
description: `${t.description} (template)`,
|
|
1288
|
+
...(t.argumentHint ? { argumentHint: t.argumentHint } : {}),
|
|
1289
|
+
})),
|
|
1290
|
+
...[...app.plugins.commandInfo.entries()]
|
|
1291
|
+
.filter(([name]) => !resolveCommand(name))
|
|
1292
|
+
.map(([name, c]) => ({
|
|
1293
|
+
name,
|
|
1294
|
+
description: c.description ?? `plugin ${c.plugin}`,
|
|
1295
|
+
...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
|
|
1296
|
+
})),
|
|
1297
|
+
];
|
|
1298
|
+
return new CombinedAutocompleteProvider(slashCompletionCommands(sources, {
|
|
1299
|
+
sessions: (prefix) => workspaceSessions()
|
|
1300
|
+
.filter((s) => s.id.startsWith(prefix))
|
|
1301
|
+
.map((s) => ({ value: s.id, label: shortId(s.id), description: s.model })),
|
|
1302
|
+
skills: app
|
|
1303
|
+
.skillCatalog()
|
|
1304
|
+
.map(({ id, name, displayId, description, scope, enabled, locked, effective }) => ({
|
|
1305
|
+
id,
|
|
1306
|
+
name,
|
|
1307
|
+
displayId,
|
|
1308
|
+
description,
|
|
1309
|
+
scope,
|
|
1310
|
+
enabled,
|
|
1311
|
+
locked,
|
|
1312
|
+
effective,
|
|
1313
|
+
})),
|
|
1314
|
+
}, { skillEntries: app.config.tui.skillSlashCommands !== false }), app.workspace);
|
|
1315
|
+
};
|
|
1316
|
+
editor.setAutocompleteProvider(buildSlashCompletionProvider());
|
|
1179
1317
|
// Interactive services for plugins: choices (e.g. worktree isolation) and session views.
|
|
1180
1318
|
app.plugins.setInteractiveUI({
|
|
1181
1319
|
// No session/label/signal on SelectRequest: queued FIFO like everything else, never withdrawn
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode-style settings menu for the TUI: two-column rows (name + current value), live
|
|
3
|
+
* type-to-search, Enter/Space to change a setting (or navigate), Esc to leave, a `(n/total)`
|
|
4
|
+
* counter and a footer with the highlighted row's description.
|
|
5
|
+
*
|
|
6
|
+
* This component is a thin shell: all decision logic (key decoding, filtering, cycling, counter)
|
|
7
|
+
* lives in the pure `settings.ts` module, which is fully unit-tested without pi-tui.
|
|
8
|
+
*/
|
|
9
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
10
|
+
import { type SettingRow } from "./settings.ts";
|
|
11
|
+
export declare class SettingsMenu implements Component {
|
|
12
|
+
private title;
|
|
13
|
+
/** A setting value changed: persist and apply it. The host re-refreshes on success/failure. */
|
|
14
|
+
private onChange;
|
|
15
|
+
/** A navigation row was activated (Enter/Space): route to its manager. */
|
|
16
|
+
private onNavigate;
|
|
17
|
+
/** Esc pressed: leave the menu (the host returns to the editor). */
|
|
18
|
+
private onCancel;
|
|
19
|
+
/** Optional dim lines under the title (e.g. the current provider/model). */
|
|
20
|
+
private detail?;
|
|
21
|
+
private full;
|
|
22
|
+
private filtered;
|
|
23
|
+
private state;
|
|
24
|
+
constructor(title: string, rows: SettingRow[],
|
|
25
|
+
/** A setting value changed: persist and apply it. The host re-refreshes on success/failure. */
|
|
26
|
+
onChange: (row: SettingRow, value: unknown) => void,
|
|
27
|
+
/** A navigation row was activated (Enter/Space): route to its manager. */
|
|
28
|
+
onNavigate: (row: SettingRow) => void,
|
|
29
|
+
/** Esc pressed: leave the menu (the host returns to the editor). */
|
|
30
|
+
onCancel: () => void,
|
|
31
|
+
/** Optional dim lines under the title (e.g. the current provider/model). */
|
|
32
|
+
detail?: string | undefined);
|
|
33
|
+
/** Rebuild rows from the live config after a write; keeps the filter and re-selects the row. */
|
|
34
|
+
refresh(rows: SettingRow[]): void;
|
|
35
|
+
/** Update the displayed value of one row without rebuilding the whole list. */
|
|
36
|
+
updateValue(id: string, value: unknown): void;
|
|
37
|
+
invalidate(): void;
|
|
38
|
+
handleInput(data: string): void;
|
|
39
|
+
render(width: number): string[];
|
|
40
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode-style settings menu for the TUI: two-column rows (name + current value), live
|
|
3
|
+
* type-to-search, Enter/Space to change a setting (or navigate), Esc to leave, a `(n/total)`
|
|
4
|
+
* counter and a footer with the highlighted row's description.
|
|
5
|
+
*
|
|
6
|
+
* This component is a thin shell: all decision logic (key decoding, filtering, cycling, counter)
|
|
7
|
+
* lives in the pure `settings.ts` module, which is fully unit-tested without pi-tui.
|
|
8
|
+
*/
|
|
9
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { filterSettingRows, formatSettingValue, initialSettingsMenuState, reduceSettingsInput, settingsCounter, settingsKeyAction, } from "./settings.js";
|
|
11
|
+
import { style } from "./theme.js";
|
|
12
|
+
const MAX_VISIBLE = 10;
|
|
13
|
+
const wrapText = (text, width) => {
|
|
14
|
+
const rows = [];
|
|
15
|
+
let row = "";
|
|
16
|
+
for (const word of text.split(/\s+/)) {
|
|
17
|
+
if (row && [...`${row} ${word}`].length > width) {
|
|
18
|
+
rows.push(row);
|
|
19
|
+
row = word;
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
row = row ? `${row} ${word}` : word;
|
|
23
|
+
}
|
|
24
|
+
if (row)
|
|
25
|
+
rows.push(row);
|
|
26
|
+
return rows;
|
|
27
|
+
};
|
|
28
|
+
export class SettingsMenu {
|
|
29
|
+
title;
|
|
30
|
+
onChange;
|
|
31
|
+
onNavigate;
|
|
32
|
+
onCancel;
|
|
33
|
+
detail;
|
|
34
|
+
full;
|
|
35
|
+
filtered;
|
|
36
|
+
state = initialSettingsMenuState();
|
|
37
|
+
constructor(title, rows,
|
|
38
|
+
/** A setting value changed: persist and apply it. The host re-refreshes on success/failure. */
|
|
39
|
+
onChange,
|
|
40
|
+
/** A navigation row was activated (Enter/Space): route to its manager. */
|
|
41
|
+
onNavigate,
|
|
42
|
+
/** Esc pressed: leave the menu (the host returns to the editor). */
|
|
43
|
+
onCancel,
|
|
44
|
+
/** Optional dim lines under the title (e.g. the current provider/model). */
|
|
45
|
+
detail) {
|
|
46
|
+
this.title = title;
|
|
47
|
+
this.onChange = onChange;
|
|
48
|
+
this.onNavigate = onNavigate;
|
|
49
|
+
this.onCancel = onCancel;
|
|
50
|
+
this.detail = detail;
|
|
51
|
+
this.full = rows;
|
|
52
|
+
this.filtered = rows;
|
|
53
|
+
}
|
|
54
|
+
/** Rebuild rows from the live config after a write; keeps the filter and re-selects the row. */
|
|
55
|
+
refresh(rows) {
|
|
56
|
+
const previous = this.filtered[this.state.selected]?.id;
|
|
57
|
+
this.full = rows;
|
|
58
|
+
this.filtered = filterSettingRows(rows, this.state.filter);
|
|
59
|
+
const index = previous ? this.filtered.findIndex((row) => row.id === previous) : -1;
|
|
60
|
+
this.state = { ...this.state, selected: index >= 0 ? index : 0 };
|
|
61
|
+
}
|
|
62
|
+
/** Update the displayed value of one row without rebuilding the whole list. */
|
|
63
|
+
updateValue(id, value) {
|
|
64
|
+
for (const row of this.full)
|
|
65
|
+
if (row.id === id)
|
|
66
|
+
row.current = value;
|
|
67
|
+
}
|
|
68
|
+
invalidate() { }
|
|
69
|
+
handleInput(data) {
|
|
70
|
+
const action = settingsKeyAction(data, this.state);
|
|
71
|
+
const { state, effect } = reduceSettingsInput(this.state, action, this.filtered);
|
|
72
|
+
this.state = state;
|
|
73
|
+
// Re-apply the live filter so typing narrows the list before the next render/input.
|
|
74
|
+
this.filtered = filterSettingRows(this.full, state.filter);
|
|
75
|
+
switch (effect.type) {
|
|
76
|
+
case "change":
|
|
77
|
+
// Optimistically reflect the new value; the host refreshes from the config on success.
|
|
78
|
+
effect.row.current = effect.value;
|
|
79
|
+
this.onChange(effect.row, effect.value);
|
|
80
|
+
break;
|
|
81
|
+
case "navigate":
|
|
82
|
+
this.onNavigate(effect.row);
|
|
83
|
+
break;
|
|
84
|
+
case "cancel":
|
|
85
|
+
this.onCancel();
|
|
86
|
+
break;
|
|
87
|
+
case "none":
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
render(width) {
|
|
92
|
+
const lines = [truncateToWidth(style.bold(style.yellow(this.title)), width)];
|
|
93
|
+
if (this.detail?.trim())
|
|
94
|
+
for (const line of wrapText(this.detail, Math.max(20, width - 2)))
|
|
95
|
+
lines.push(truncateToWidth(style.dim(` ${line}`), width));
|
|
96
|
+
if (this.state.filter)
|
|
97
|
+
lines.push(truncateToWidth(style.dim(` filter: ${this.state.filter}`), width));
|
|
98
|
+
const rows = this.filtered;
|
|
99
|
+
if (!rows.length) {
|
|
100
|
+
lines.push(truncateToWidth(style.yellow(this.full.length ? " No matching settings" : " No settings available"), width));
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push(truncateToWidth(style.dim(" Type to search · Esc to cancel"), width));
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
// Two-column layout: labels padded to the widest row (capped), values right beside them.
|
|
106
|
+
const maxLabel = Math.min(36, Math.max(...rows.map((row) => [...row.label].length)));
|
|
107
|
+
const start = Math.max(0, Math.min(this.state.selected - Math.floor(MAX_VISIBLE / 2), rows.length - MAX_VISIBLE));
|
|
108
|
+
const visible = rows.slice(start, start + MAX_VISIBLE);
|
|
109
|
+
for (let i = 0; i < visible.length; i++) {
|
|
110
|
+
const row = visible[i];
|
|
111
|
+
if (!row)
|
|
112
|
+
continue;
|
|
113
|
+
const selected = start + i === this.state.selected;
|
|
114
|
+
const label = row.label.padEnd(maxLabel);
|
|
115
|
+
const value = row.kind === "setting" ? formatSettingValue(row, row.current) : "";
|
|
116
|
+
const rendered = selected
|
|
117
|
+
? `${style.cyan("›")} ${style.bold(style.cyan(label))}${value ? ` ${style.bold(style.cyan(value))}` : ""}`
|
|
118
|
+
: ` ${label}${value ? ` ${value}` : ""}`;
|
|
119
|
+
lines.push(truncateToWidth(rendered, width));
|
|
120
|
+
}
|
|
121
|
+
lines.push(truncateToWidth(style.dim(` ${settingsCounter(rows, this.state.selected)}`), width));
|
|
122
|
+
const selectedRow = rows[this.state.selected];
|
|
123
|
+
if (selectedRow?.description) {
|
|
124
|
+
lines.push("");
|
|
125
|
+
for (const wrapped of wrapText(selectedRow.description, Math.max(20, width - 4)))
|
|
126
|
+
lines.push(truncateToWidth(style.gray(` ${wrapped}`), width));
|
|
127
|
+
lines.push("");
|
|
128
|
+
}
|
|
129
|
+
lines.push(truncateToWidth(style.dim(" Type to search · Enter/Space to change · Esc to cancel"), width));
|
|
130
|
+
return lines;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure settings-menu logic for the TUI: row building, filtering, value cycling, counter and
|
|
3
|
+
* keyboard reduction. No terminal or pi-tui imports here — the `SettingsMenu` component in
|
|
4
|
+
* `settings-menu.ts` is a thin shell over this module.
|
|
5
|
+
*
|
|
6
|
+
* Only settings Alisio actually supports are defined here. The row set is the honest, wired
|
|
7
|
+
* list: every "setting" row maps to a real config key persisted through the atomic
|
|
8
|
+
* `setConfigValue` writer (or the MCP consent path) and is applied to the running process.
|
|
9
|
+
*/
|
|
10
|
+
export type SettingValueType = "boolean" | "number" | "percent" | "enum";
|
|
11
|
+
export interface SettingRow {
|
|
12
|
+
/** Config key (or MCP consent id) this row edits; navigation rows carry their action id. */
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
description: string;
|
|
16
|
+
/** Group the row belongs to; also matched by the filter ("type" of the row). */
|
|
17
|
+
category: string;
|
|
18
|
+
kind: "setting" | "navigation";
|
|
19
|
+
/** Current raw value for setting rows (read live from the config at build time). */
|
|
20
|
+
current?: unknown;
|
|
21
|
+
/** Ordered candidate values cycled by Enter/Space; wraps around at the ends. */
|
|
22
|
+
values?: unknown[];
|
|
23
|
+
valueType?: SettingValueType;
|
|
24
|
+
/** Displayed but not editable in this run (e.g. under `--read-only`). */
|
|
25
|
+
readOnly?: boolean;
|
|
26
|
+
/** Navigation target id for navigation rows; the host maps it to a concrete action. */
|
|
27
|
+
action?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface SettingsNavigationAction {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
description: string;
|
|
33
|
+
}
|
|
34
|
+
/** Ordered candidates of the `websearch.provider` enum, aligned exactly with the config schema. */
|
|
35
|
+
export declare const WEBSEARCH_PROVIDERS: readonly ["searxng", "duckduckgo-instant", "tavily", "brave", "serpapi", "native"];
|
|
36
|
+
/**
|
|
37
|
+
* The config surface the menu reads. A structural view of the loaded config: rows are built only
|
|
38
|
+
* from the documented fields below, so unknown keys in a real config are ignored by construction.
|
|
39
|
+
*/
|
|
40
|
+
export interface SettingsConfigView {
|
|
41
|
+
compaction: {
|
|
42
|
+
auto: boolean;
|
|
43
|
+
threshold: number;
|
|
44
|
+
keepTurns: number;
|
|
45
|
+
maxOutputTokens: number;
|
|
46
|
+
};
|
|
47
|
+
context: {
|
|
48
|
+
claudeMdFallback: boolean;
|
|
49
|
+
maxBytes: number;
|
|
50
|
+
};
|
|
51
|
+
limits: {
|
|
52
|
+
maxTurns: number;
|
|
53
|
+
maxOutputTokens: number;
|
|
54
|
+
maxContextChars: number;
|
|
55
|
+
timeoutMs: number;
|
|
56
|
+
};
|
|
57
|
+
tui: {
|
|
58
|
+
paddingX: number;
|
|
59
|
+
skillSlashCommands: boolean;
|
|
60
|
+
};
|
|
61
|
+
mcp: {
|
|
62
|
+
allow?: boolean;
|
|
63
|
+
};
|
|
64
|
+
websearch: {
|
|
65
|
+
provider?: (typeof WEBSEARCH_PROVIDERS)[number];
|
|
66
|
+
};
|
|
67
|
+
pluginHooks: {
|
|
68
|
+
timeoutMs: number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface SettingsMenuInput {
|
|
72
|
+
config: SettingsConfigView;
|
|
73
|
+
/** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
|
|
74
|
+
mcpAllowPersisted: boolean;
|
|
75
|
+
/** `--read-only` run: every row is shown but none can be changed. */
|
|
76
|
+
readOnly: boolean;
|
|
77
|
+
}
|
|
78
|
+
/** The config view with the schema defaults; also the canonical fixture for tests. */
|
|
79
|
+
export declare const defaultConfig: SettingsConfigView;
|
|
80
|
+
interface SettingDefinition {
|
|
81
|
+
id: string;
|
|
82
|
+
label: string;
|
|
83
|
+
category: string;
|
|
84
|
+
description: string;
|
|
85
|
+
valueType: SettingValueType;
|
|
86
|
+
values: unknown[];
|
|
87
|
+
read: (config: SettingsConfigView, mcpAllowPersisted: boolean) => unknown;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Every real, wired setting the menu offers. `read` maps the live config to the current value;
|
|
91
|
+
* the keys are exactly the ones `setConfigValue` accepts (`compaction.*`, `context.*`, `limits.*`,
|
|
92
|
+
* `pluginHooks.timeoutMs`, `tui.*` and `websearch.provider`), and `mcp.allow` flows through the
|
|
93
|
+
* application's consent path instead. `limits.timeoutMs` is displayed in seconds (the values are
|
|
94
|
+
* seconds); the app host multiplies by 1000 before persisting the milliseconds.
|
|
95
|
+
*/
|
|
96
|
+
export declare const SETTINGS_DEFINITIONS: readonly SettingDefinition[];
|
|
97
|
+
/**
|
|
98
|
+
* Builds the full settings menu: the real, wired setting rows followed by the navigation rows the
|
|
99
|
+
* host wires to the existing managers (`/model`, `/connect`, `/plugins`, `/skills`, `/mcp`, ...).
|
|
100
|
+
* Under `--read-only` every setting row is marked read-only: nothing can be persisted.
|
|
101
|
+
*/
|
|
102
|
+
export declare function settingsMenuRows(input: SettingsMenuInput, navigation: SettingsNavigationAction[]): SettingRow[];
|
|
103
|
+
/** Whether a row matches every whitespace-separated filter term, on label/id/category/description. */
|
|
104
|
+
export declare function matchSettingRow(row: SettingRow, terms: string[]): boolean;
|
|
105
|
+
/** Filters rows by name, id, category (type) and description; an empty query returns everything. */
|
|
106
|
+
export declare function filterSettingRows(rows: SettingRow[], filter: string): SettingRow[];
|
|
107
|
+
/** The value shown in the right-hand column of a setting row. */
|
|
108
|
+
export declare function formatSettingValue(row: SettingRow, value: unknown): string;
|
|
109
|
+
/**
|
|
110
|
+
* The next candidate after the current value in the row's ordered `values`, wrapping around.
|
|
111
|
+
* A current value that is not in the list (hand-edited config) moves to the next candidate at or
|
|
112
|
+
* above it for numbers, or wraps to the first candidate otherwise. Never mutates the row.
|
|
113
|
+
*/
|
|
114
|
+
export declare function cycleSettingValue(row: SettingRow, current: unknown): unknown | undefined;
|
|
115
|
+
/** OpenCode-style `(n/total)` counter; empty when there are no rows to count. */
|
|
116
|
+
export declare function settingsCounter(rows: SettingRow[], selected: number): string;
|
|
117
|
+
export interface SettingsMenuState {
|
|
118
|
+
/** Live type-to-search filter (matched against name, id, category and description). */
|
|
119
|
+
filter: string;
|
|
120
|
+
/** Index into the FILTERED row list. */
|
|
121
|
+
selected: number;
|
|
122
|
+
}
|
|
123
|
+
export declare const initialSettingsMenuState: () => SettingsMenuState;
|
|
124
|
+
/**
|
|
125
|
+
* Normalized keyboard actions for the settings menu, decoded from raw terminal input with plain
|
|
126
|
+
* string matching so the logic stays testable without pi-tui. The component just forwards
|
|
127
|
+
* `handleInput(data)` here and maps the resulting effect to its callbacks.
|
|
128
|
+
*/
|
|
129
|
+
export type SettingsKeyAction = {
|
|
130
|
+
kind: "move";
|
|
131
|
+
direction: "up" | "down" | "start" | "end" | "pageUp" | "pageDown";
|
|
132
|
+
} | {
|
|
133
|
+
kind: "type";
|
|
134
|
+
char: string;
|
|
135
|
+
} | {
|
|
136
|
+
kind: "backspace";
|
|
137
|
+
}
|
|
138
|
+
/** Enter, or Space while the filter is empty (Space with an active filter types a space). */
|
|
139
|
+
| {
|
|
140
|
+
kind: "change";
|
|
141
|
+
} | {
|
|
142
|
+
kind: "cancel";
|
|
143
|
+
} | {
|
|
144
|
+
kind: "ignore";
|
|
145
|
+
};
|
|
146
|
+
export declare function settingsKeyAction(data: string, state: SettingsMenuState): SettingsKeyAction;
|
|
147
|
+
export type SettingsMenuEffect = {
|
|
148
|
+
type: "change";
|
|
149
|
+
row: SettingRow;
|
|
150
|
+
value: unknown;
|
|
151
|
+
} | {
|
|
152
|
+
type: "navigate";
|
|
153
|
+
row: SettingRow;
|
|
154
|
+
} | {
|
|
155
|
+
type: "cancel";
|
|
156
|
+
} | {
|
|
157
|
+
type: "none";
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Pure keyboard reducer over the menu state. `rows` is the current FILTERED list (navigation rows
|
|
161
|
+
* and settings rows alike); Enter/Space changes a setting or navigates, Esc cancels, typing
|
|
162
|
+
* filters, Backspace edits the filter, and movement keys move/clamp the selection.
|
|
163
|
+
*/
|
|
164
|
+
export declare function reduceSettingsInput(state: SettingsMenuState, action: SettingsKeyAction, rows: SettingRow[]): {
|
|
165
|
+
state: SettingsMenuState;
|
|
166
|
+
effect: SettingsMenuEffect;
|
|
167
|
+
};
|
|
168
|
+
export {};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/** Ordered candidates of the `websearch.provider` enum, aligned exactly with the config schema. */
|
|
2
|
+
export const WEBSEARCH_PROVIDERS = [
|
|
3
|
+
"searxng",
|
|
4
|
+
"duckduckgo-instant",
|
|
5
|
+
"tavily",
|
|
6
|
+
"brave",
|
|
7
|
+
"serpapi",
|
|
8
|
+
"native",
|
|
9
|
+
];
|
|
10
|
+
/** The config view with the schema defaults; also the canonical fixture for tests. */
|
|
11
|
+
export const defaultConfig = {
|
|
12
|
+
compaction: { auto: true, threshold: 0.85, keepTurns: 2, maxOutputTokens: 16_000 },
|
|
13
|
+
context: { claudeMdFallback: false, maxBytes: 32 * 1024 },
|
|
14
|
+
limits: { maxTurns: 20, maxOutputTokens: 4_096, maxContextChars: 160_000, timeoutMs: 300_000 },
|
|
15
|
+
tui: { paddingX: 1, skillSlashCommands: true },
|
|
16
|
+
mcp: { allow: false },
|
|
17
|
+
websearch: { provider: undefined },
|
|
18
|
+
pluginHooks: { timeoutMs: 15_000 },
|
|
19
|
+
};
|
|
20
|
+
/** Strictly ascending number sequence with exact step arithmetic (0.85 stays 0.85). */
|
|
21
|
+
function stepValues(start, end, step) {
|
|
22
|
+
const values = [];
|
|
23
|
+
for (let value = start; value <= end + 1e-9; value += step)
|
|
24
|
+
values.push(Number(value.toFixed(3)));
|
|
25
|
+
return values;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every real, wired setting the menu offers. `read` maps the live config to the current value;
|
|
29
|
+
* the keys are exactly the ones `setConfigValue` accepts (`compaction.*`, `context.*`, `limits.*`,
|
|
30
|
+
* `pluginHooks.timeoutMs`, `tui.*` and `websearch.provider`), and `mcp.allow` flows through the
|
|
31
|
+
* application's consent path instead. `limits.timeoutMs` is displayed in seconds (the values are
|
|
32
|
+
* seconds); the app host multiplies by 1000 before persisting the milliseconds.
|
|
33
|
+
*/
|
|
34
|
+
export const SETTINGS_DEFINITIONS = [
|
|
35
|
+
{
|
|
36
|
+
id: "compaction.auto",
|
|
37
|
+
label: "Auto-compact",
|
|
38
|
+
category: "Compaction",
|
|
39
|
+
valueType: "boolean",
|
|
40
|
+
values: [false, true],
|
|
41
|
+
read: (config) => config.compaction.auto,
|
|
42
|
+
description: "Summarize older history automatically when context usage crosses the threshold. Off keeps history verbatim until you run /compact manually.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "compaction.threshold",
|
|
46
|
+
label: "Compaction threshold",
|
|
47
|
+
category: "Compaction",
|
|
48
|
+
valueType: "percent",
|
|
49
|
+
values: stepValues(0.5, 0.95, 0.05),
|
|
50
|
+
read: (config) => config.compaction.threshold,
|
|
51
|
+
description: "Share of the model context window that triggers auto-compaction (or of the char-budget fallback when the window is unknown). Applied from the next run.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "compaction.keepTurns",
|
|
55
|
+
label: "Keep latest turns",
|
|
56
|
+
category: "Compaction",
|
|
57
|
+
valueType: "number",
|
|
58
|
+
values: stepValues(0, 20, 1),
|
|
59
|
+
read: (config) => config.compaction.keepTurns,
|
|
60
|
+
description: "Recent user turns kept verbatim after an auto or manual compaction. 0 summarizes everything; the newest turns above the cap are kept intact.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "compaction.maxOutputTokens",
|
|
64
|
+
label: "Compaction max output tokens",
|
|
65
|
+
category: "Compaction",
|
|
66
|
+
valueType: "number",
|
|
67
|
+
values: [8_000, 12_000, 16_000, 24_000, 32_000],
|
|
68
|
+
read: (config) => config.compaction.maxOutputTokens,
|
|
69
|
+
description: "Output token budget for the summarizer call. A summary cut by this budget is kept as partial; raise it for very long sessions.",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "context.claudeMdFallback",
|
|
73
|
+
label: "CLAUDE.md fallback",
|
|
74
|
+
category: "Context",
|
|
75
|
+
valueType: "boolean",
|
|
76
|
+
values: [false, true],
|
|
77
|
+
read: (config) => config.context.claudeMdFallback,
|
|
78
|
+
description: "Use a directory's CLAUDE.md when it has no AGENTS.md (agents.md convention). Loaded as project instructions from the next turn.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "context.maxBytes",
|
|
82
|
+
label: "AGENTS.md max bytes",
|
|
83
|
+
category: "Context",
|
|
84
|
+
valueType: "number",
|
|
85
|
+
// 4 KiB steps aligned to the default (32 KiB is on-grid); 1024 stays reachable by hand-edit.
|
|
86
|
+
values: stepValues(4096, 1_048_576, 4096),
|
|
87
|
+
read: (config) => config.context.maxBytes,
|
|
88
|
+
description: "Total AGENTS.md bytes injected. The closest instruction files are kept up to this budget; raise it for very large repositories.",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "websearch.provider",
|
|
92
|
+
label: "Web search provider",
|
|
93
|
+
category: "Web",
|
|
94
|
+
valueType: "enum",
|
|
95
|
+
values: [...WEBSEARCH_PROVIDERS],
|
|
96
|
+
read: (config) => config.websearch.provider,
|
|
97
|
+
description: "Backend used by websearch tools. Unset falls back to a plugin extension, then a public SearXNG instance; `native` runs search server-side and must be supported by the active provider.",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "mcp.allow",
|
|
101
|
+
label: "Remember MCP consent",
|
|
102
|
+
category: "MCP",
|
|
103
|
+
valueType: "boolean",
|
|
104
|
+
values: [false, true],
|
|
105
|
+
read: (_config, mcpAllowPersisted) => mcpAllowPersisted,
|
|
106
|
+
description: "Persist process/network consent for MCP servers in your user configuration (mcp.allow). On: every start grants permission and auto-connects enabled servers; use /mcp to connect now. Off: revokes the persisted consent and disconnects servers.",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "limits.maxTurns",
|
|
110
|
+
label: "Max turns",
|
|
111
|
+
category: "Limits",
|
|
112
|
+
valueType: "number",
|
|
113
|
+
values: [5, 10, 15, 20, 30, 50, 100],
|
|
114
|
+
read: (config) => config.limits.maxTurns,
|
|
115
|
+
description: "Maximum agent-loop turns per run before the run ends. Applied from the next run; longer tasks may need a higher budget.",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "limits.maxOutputTokens",
|
|
119
|
+
label: "Agent max output tokens",
|
|
120
|
+
category: "Limits",
|
|
121
|
+
valueType: "number",
|
|
122
|
+
values: [1_024, 2_048, 4_096, 8_192, 16_384],
|
|
123
|
+
read: (config) => config.limits.maxOutputTokens,
|
|
124
|
+
description: "Per-call output token budget for agent turns. A cut response shows a notice suggesting a higher value. Applied from the next run.",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "limits.maxContextChars",
|
|
128
|
+
label: "Context char budget",
|
|
129
|
+
category: "Limits",
|
|
130
|
+
valueType: "number",
|
|
131
|
+
values: [80_000, 120_000, 160_000, 240_000, 320_000],
|
|
132
|
+
read: (config) => config.limits.maxContextChars,
|
|
133
|
+
description: "Hard context limit in characters (instruction files + transcript + tool list) per run; the budget fallback that auto-compaction measures when the model window is unknown. Applied from the next run.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "limits.timeoutMs",
|
|
137
|
+
label: "Run timeout",
|
|
138
|
+
category: "Limits",
|
|
139
|
+
valueType: "number",
|
|
140
|
+
// Displayed in seconds; the app host multiplies by 1000 before persisting milliseconds.
|
|
141
|
+
values: stepValues(30, 600, 30),
|
|
142
|
+
read: (config) => config.limits.timeoutMs / 1000,
|
|
143
|
+
description: "Per-run timeout. A run over the limit is aborted; raise it for very long autonomous tasks.",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "pluginHooks.timeoutMs",
|
|
147
|
+
label: "Plugin hook timeout",
|
|
148
|
+
category: "Plugins",
|
|
149
|
+
valueType: "number",
|
|
150
|
+
values: stepValues(1000, 120_000, 1000),
|
|
151
|
+
read: (config) => config.pluginHooks.timeoutMs,
|
|
152
|
+
description: "Host-enforced plugin hook timeout. Hooks (compaction, session start/end) are aborted when they exceed it; raise it for plugins that summarize slowly.",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "tui.paddingX",
|
|
156
|
+
label: "Editor padding",
|
|
157
|
+
category: "TUI",
|
|
158
|
+
valueType: "number",
|
|
159
|
+
values: [0, 1, 2, 3, 4],
|
|
160
|
+
read: (config) => config.tui.paddingX,
|
|
161
|
+
description: "Horizontal padding (columns) around the editor input box. Applied immediately to the current editor.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "tui.skillSlashCommands",
|
|
165
|
+
label: "Skill slash commands",
|
|
166
|
+
category: "TUI",
|
|
167
|
+
valueType: "boolean",
|
|
168
|
+
values: [false, true],
|
|
169
|
+
read: (config) => config.tui.skillSlashCommands,
|
|
170
|
+
description: "Offer effective skills as first-class `skill:<id>` editor autocomplete entries. Off hides those entries; the `/skills` manager and its argument completion stay available. Applied immediately to the editor.",
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
/**
|
|
174
|
+
* Builds the full settings menu: the real, wired setting rows followed by the navigation rows the
|
|
175
|
+
* host wires to the existing managers (`/model`, `/connect`, `/plugins`, `/skills`, `/mcp`, ...).
|
|
176
|
+
* Under `--read-only` every setting row is marked read-only: nothing can be persisted.
|
|
177
|
+
*/
|
|
178
|
+
export function settingsMenuRows(input, navigation) {
|
|
179
|
+
const rows = [];
|
|
180
|
+
for (const def of SETTINGS_DEFINITIONS) {
|
|
181
|
+
rows.push({
|
|
182
|
+
id: def.id,
|
|
183
|
+
label: def.label,
|
|
184
|
+
category: def.category,
|
|
185
|
+
description: def.description,
|
|
186
|
+
kind: "setting",
|
|
187
|
+
current: def.read(input.config, input.mcpAllowPersisted),
|
|
188
|
+
values: [...def.values],
|
|
189
|
+
valueType: def.valueType,
|
|
190
|
+
...(input.readOnly ? { readOnly: true } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
rows.push(...navigation.map((entry) => ({
|
|
194
|
+
id: entry.id,
|
|
195
|
+
label: entry.label,
|
|
196
|
+
description: entry.description,
|
|
197
|
+
category: "Go to",
|
|
198
|
+
kind: "navigation",
|
|
199
|
+
action: entry.id,
|
|
200
|
+
})));
|
|
201
|
+
return rows;
|
|
202
|
+
}
|
|
203
|
+
/** Whether a row matches every whitespace-separated filter term, on label/id/category/description. */
|
|
204
|
+
export function matchSettingRow(row, terms) {
|
|
205
|
+
const haystack = [row.label, row.id, row.category, row.description].join(" ").toLowerCase();
|
|
206
|
+
return terms.every((term) => haystack.includes(term));
|
|
207
|
+
}
|
|
208
|
+
/** Filters rows by name, id, category (type) and description; an empty query returns everything. */
|
|
209
|
+
export function filterSettingRows(rows, filter) {
|
|
210
|
+
const terms = filter.toLowerCase().split(/\s+/).filter(Boolean);
|
|
211
|
+
if (!terms.length)
|
|
212
|
+
return rows;
|
|
213
|
+
return rows.filter((row) => matchSettingRow(row, terms));
|
|
214
|
+
}
|
|
215
|
+
/** The value shown in the right-hand column of a setting row. */
|
|
216
|
+
export function formatSettingValue(row, value) {
|
|
217
|
+
if (row.valueType === "percent" && typeof value === "number")
|
|
218
|
+
return `${Math.round(value * 100)}%`;
|
|
219
|
+
if (row.valueType === "boolean")
|
|
220
|
+
return value === true ? "true" : "false";
|
|
221
|
+
return value === undefined || value === null ? "" : String(value);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The next candidate after the current value in the row's ordered `values`, wrapping around.
|
|
225
|
+
* A current value that is not in the list (hand-edited config) moves to the next candidate at or
|
|
226
|
+
* above it for numbers, or wraps to the first candidate otherwise. Never mutates the row.
|
|
227
|
+
*/
|
|
228
|
+
export function cycleSettingValue(row, current) {
|
|
229
|
+
const values = row.values;
|
|
230
|
+
if (!values?.length || row.readOnly)
|
|
231
|
+
return undefined;
|
|
232
|
+
const index = values.findIndex((value) => Object.is(value, current));
|
|
233
|
+
if (index >= 0)
|
|
234
|
+
return values[(index + 1) % values.length];
|
|
235
|
+
if (typeof current === "number" && values.every((value) => typeof value === "number"))
|
|
236
|
+
return values.find((value) => value >= current) ?? values[0];
|
|
237
|
+
return values[0];
|
|
238
|
+
}
|
|
239
|
+
/** OpenCode-style `(n/total)` counter; empty when there are no rows to count. */
|
|
240
|
+
export function settingsCounter(rows, selected) {
|
|
241
|
+
if (!rows.length)
|
|
242
|
+
return "";
|
|
243
|
+
const clamped = Math.min(Math.max(0, selected), rows.length - 1);
|
|
244
|
+
return `(${clamped + 1}/${rows.length})`;
|
|
245
|
+
}
|
|
246
|
+
export const initialSettingsMenuState = () => ({ filter: "", selected: 0 });
|
|
247
|
+
const PAGE_SIZE = 10;
|
|
248
|
+
export function settingsKeyAction(data, state) {
|
|
249
|
+
if (!data)
|
|
250
|
+
return { kind: "ignore" };
|
|
251
|
+
if (data === "\x1b" || data === "\x1b[")
|
|
252
|
+
return { kind: "cancel" };
|
|
253
|
+
if (data === "\r" || data === "\n")
|
|
254
|
+
return { kind: "change" };
|
|
255
|
+
if (data === " " && !state.filter)
|
|
256
|
+
return { kind: "change" };
|
|
257
|
+
if (data === "\x1b[A")
|
|
258
|
+
return { kind: "move", direction: "up" };
|
|
259
|
+
if (data === "\x1b[B")
|
|
260
|
+
return { kind: "move", direction: "down" };
|
|
261
|
+
if (data === "\x1b[H" || data === "\x1b[1~")
|
|
262
|
+
return { kind: "move", direction: "start" };
|
|
263
|
+
if (data === "\x1b[F" || data === "\x1b[4~")
|
|
264
|
+
return { kind: "move", direction: "end" };
|
|
265
|
+
if (data === "\x1b[5~")
|
|
266
|
+
return { kind: "move", direction: "pageUp" };
|
|
267
|
+
if (data === "\x1b[6~")
|
|
268
|
+
return { kind: "move", direction: "pageDown" };
|
|
269
|
+
if (data === "\x7f" || data === "\b" || data === "\x08" || data === "\x1b[3~")
|
|
270
|
+
return { kind: "backspace" };
|
|
271
|
+
if (data.length === 1 && data >= " " && data <= "~")
|
|
272
|
+
return { kind: "type", char: data };
|
|
273
|
+
return { kind: "ignore" };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Pure keyboard reducer over the menu state. `rows` is the current FILTERED list (navigation rows
|
|
277
|
+
* and settings rows alike); Enter/Space changes a setting or navigates, Esc cancels, typing
|
|
278
|
+
* filters, Backspace edits the filter, and movement keys move/clamp the selection.
|
|
279
|
+
*/
|
|
280
|
+
export function reduceSettingsInput(state, action, rows) {
|
|
281
|
+
switch (action.kind) {
|
|
282
|
+
case "cancel":
|
|
283
|
+
return { state, effect: { type: "cancel" } };
|
|
284
|
+
case "change": {
|
|
285
|
+
const row = rows[state.selected];
|
|
286
|
+
if (!row)
|
|
287
|
+
return { state, effect: { type: "none" } };
|
|
288
|
+
if (row.kind === "navigation")
|
|
289
|
+
return { state, effect: { type: "navigate", row } };
|
|
290
|
+
const value = cycleSettingValue(row, row.current);
|
|
291
|
+
if (value === undefined)
|
|
292
|
+
return { state, effect: { type: "none" } };
|
|
293
|
+
return { state, effect: { type: "change", row, value } };
|
|
294
|
+
}
|
|
295
|
+
case "type": {
|
|
296
|
+
const filter = `${state.filter}${action.char}`;
|
|
297
|
+
return { state: { filter, selected: 0 }, effect: { type: "none" } };
|
|
298
|
+
}
|
|
299
|
+
case "backspace": {
|
|
300
|
+
const filter = state.filter.slice(0, -1);
|
|
301
|
+
return {
|
|
302
|
+
state: { filter, selected: 0 },
|
|
303
|
+
effect: { type: "none" },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
case "move": {
|
|
307
|
+
if (!rows.length)
|
|
308
|
+
return { state, effect: { type: "none" } };
|
|
309
|
+
const last = rows.length - 1;
|
|
310
|
+
const selected = action.direction === "up"
|
|
311
|
+
? state.selected === 0
|
|
312
|
+
? last
|
|
313
|
+
: state.selected - 1
|
|
314
|
+
: action.direction === "down"
|
|
315
|
+
? state.selected === last
|
|
316
|
+
? 0
|
|
317
|
+
: state.selected + 1
|
|
318
|
+
: action.direction === "start"
|
|
319
|
+
? 0
|
|
320
|
+
: action.direction === "end"
|
|
321
|
+
? last
|
|
322
|
+
: action.direction === "pageUp"
|
|
323
|
+
? Math.max(0, state.selected - PAGE_SIZE)
|
|
324
|
+
: Math.min(last, state.selected + PAGE_SIZE);
|
|
325
|
+
return { state: { ...state, selected }, effect: { type: "none" } };
|
|
326
|
+
}
|
|
327
|
+
case "ignore":
|
|
328
|
+
return { state, effect: { type: "none" } };
|
|
329
|
+
}
|
|
330
|
+
}
|
package/dist/tui/state.d.ts
CHANGED
|
@@ -124,6 +124,77 @@ export declare function parseCommand(input: string): {
|
|
|
124
124
|
name: string;
|
|
125
125
|
args: string;
|
|
126
126
|
} | undefined;
|
|
127
|
+
/** Minimal structural view of a skill for slash autocompletion (subset of the core catalog entry). */
|
|
128
|
+
export interface SkillCompletionEntry {
|
|
129
|
+
id: string;
|
|
130
|
+
name: string;
|
|
131
|
+
displayId: string;
|
|
132
|
+
description: string;
|
|
133
|
+
/** Catalog scope of the skill: user | project | config | plugin (drives the [u]/[p]/[c]/[l] marker). */
|
|
134
|
+
scope?: string;
|
|
135
|
+
enabled: boolean;
|
|
136
|
+
locked: boolean;
|
|
137
|
+
effective: boolean;
|
|
138
|
+
}
|
|
139
|
+
export interface SlashCompletionItem {
|
|
140
|
+
value: string;
|
|
141
|
+
label: string;
|
|
142
|
+
description?: string;
|
|
143
|
+
}
|
|
144
|
+
export declare const SKILL_COMPLETION_DESCRIPTION_LIMIT = 80;
|
|
145
|
+
/**
|
|
146
|
+
* OpenCode-style scope marker for a skill's slash entry, mirroring the catalog scopes the /skills
|
|
147
|
+
* manager shows: user, project, config and plugin (plugin skills are locked by their owner plugin).
|
|
148
|
+
*/
|
|
149
|
+
export declare const skillScopeMarker: (skill: SkillCompletionEntry) => string | undefined;
|
|
150
|
+
/**
|
|
151
|
+
* Description for a skill's first-class `skill:<id>` slash entry: scope marker, status hint, then
|
|
152
|
+
* the skill description truncated to the shared limit. The marker and status are prepended before
|
|
153
|
+
* truncating, so they survive the cap just like the /skills argument completions.
|
|
154
|
+
*/
|
|
155
|
+
export declare function skillSlashDescription(skill: SkillCompletionEntry): string;
|
|
156
|
+
/**
|
|
157
|
+
* Suggested completions for `/skills <prefix>`: every entry the /skills manager shows, filtered by
|
|
158
|
+
* prefix (case-insensitive) on name or description. Selecting a suggestion only fills the argument;
|
|
159
|
+
* submitting still opens the skills manager.
|
|
160
|
+
*/
|
|
161
|
+
export declare function skillCompletions(catalog: SkillCompletionEntry[], prefix: string, limit?: number): SlashCompletionItem[];
|
|
162
|
+
export interface SlashCompletionSource {
|
|
163
|
+
name: string;
|
|
164
|
+
description?: string;
|
|
165
|
+
argumentHint?: string;
|
|
166
|
+
aliases?: string[];
|
|
167
|
+
}
|
|
168
|
+
export interface SlashCompletionContext {
|
|
169
|
+
/** Live session rows for `/resume`: already filtered to the typed prefix and mapped. */
|
|
170
|
+
sessions?: (prefix: string) => SlashCompletionItem[];
|
|
171
|
+
/** Effective skill catalog backing `/skills` (the same entries the skills manager shows). */
|
|
172
|
+
skills: SkillCompletionEntry[];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Builds the editor slash-autocomplete command list. Aliases get their own entries with the same
|
|
176
|
+
* description and argument completions, because the provider matches commands by fuzzy name.
|
|
177
|
+
* Effective skills are appended as first-class `skill:<id>` entries so `/ski…`, `/skill:b…` and
|
|
178
|
+
* even `/branch…` all surface them; submitting already routes `skill:` commands to skill load, so
|
|
179
|
+
* selecting an entry only needs to insert the command name. `options.skillEntries: false` gates
|
|
180
|
+
* ONLY those standalone `skill:` entries (e.g. the `tui.skillSlashCommands` toggle): the `/skills`
|
|
181
|
+
* manager and its argument completion keep working, since `context.skills` still feeds both.
|
|
182
|
+
*/
|
|
183
|
+
export interface SlashCompletionOptions {
|
|
184
|
+
/** Include first-class `skill:<id>` entries for effective skills (default true). */
|
|
185
|
+
skillEntries?: boolean;
|
|
186
|
+
}
|
|
187
|
+
export declare function slashCompletionCommands(sources: SlashCompletionSource[], context: SlashCompletionContext, options?: SlashCompletionOptions): ({
|
|
188
|
+
getArgumentCompletions: (prefix: string) => SlashCompletionItem[];
|
|
189
|
+
name: string;
|
|
190
|
+
description: string | undefined;
|
|
191
|
+
argumentHint?: string | undefined;
|
|
192
|
+
} | {
|
|
193
|
+
getArgumentCompletions?: undefined;
|
|
194
|
+
name: string;
|
|
195
|
+
description: string | undefined;
|
|
196
|
+
argumentHint?: string | undefined;
|
|
197
|
+
})[];
|
|
127
198
|
export declare function summarizeToolArgs(name: string, args: string): string;
|
|
128
199
|
export interface DiffLine {
|
|
129
200
|
sign: "+" | "-";
|
package/dist/tui/state.js
CHANGED
|
@@ -223,6 +223,11 @@ export const COMMANDS = [
|
|
|
223
223
|
aliases: ["skill"],
|
|
224
224
|
},
|
|
225
225
|
{ name: "mcp", description: "Browse and manage MCP servers" },
|
|
226
|
+
{
|
|
227
|
+
name: "settings",
|
|
228
|
+
description: "Open the settings menu",
|
|
229
|
+
aliases: ["prefs"],
|
|
230
|
+
},
|
|
226
231
|
{ name: "copy", description: "Copy the last assistant response to the clipboard" },
|
|
227
232
|
{
|
|
228
233
|
name: "ask",
|
|
@@ -245,6 +250,87 @@ export function parseCommand(input) {
|
|
|
245
250
|
return undefined;
|
|
246
251
|
return { name: match[1], args: (match[2] ?? "").trim() };
|
|
247
252
|
}
|
|
253
|
+
export const SKILL_COMPLETION_DESCRIPTION_LIMIT = 80;
|
|
254
|
+
/** Status hint prefixes (no color): shadowed < disabled < locked states shown by /skills. */
|
|
255
|
+
const skillStatus = (skill) => !skill.effective
|
|
256
|
+
? "shadowed"
|
|
257
|
+
: !skill.enabled
|
|
258
|
+
? "disabled"
|
|
259
|
+
: skill.locked
|
|
260
|
+
? "locked by plugin"
|
|
261
|
+
: undefined;
|
|
262
|
+
/**
|
|
263
|
+
* OpenCode-style scope marker for a skill's slash entry, mirroring the catalog scopes the /skills
|
|
264
|
+
* manager shows: user, project, config and plugin (plugin skills are locked by their owner plugin).
|
|
265
|
+
*/
|
|
266
|
+
export const skillScopeMarker = (skill) => {
|
|
267
|
+
const marker = { user: "[u]", project: "[p]", config: "[c]", plugin: "[l]" }[skill.scope];
|
|
268
|
+
return marker ?? (skill.scope ? `[${skill.scope}]` : undefined);
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Description for a skill's first-class `skill:<id>` slash entry: scope marker, status hint, then
|
|
272
|
+
* the skill description truncated to the shared limit. The marker and status are prepended before
|
|
273
|
+
* truncating, so they survive the cap just like the /skills argument completions.
|
|
274
|
+
*/
|
|
275
|
+
export function skillSlashDescription(skill) {
|
|
276
|
+
const marker = skillScopeMarker(skill);
|
|
277
|
+
const status = skillStatus(skill);
|
|
278
|
+
if (!marker && !status)
|
|
279
|
+
return truncatePlain(skill.description, SKILL_COMPLETION_DESCRIPTION_LIMIT);
|
|
280
|
+
return truncatePlain(`${[marker, status].filter(Boolean).join(" ")}${status ? " · " : " "}${skill.description}`, SKILL_COMPLETION_DESCRIPTION_LIMIT);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Suggested completions for `/skills <prefix>`: every entry the /skills manager shows, filtered by
|
|
284
|
+
* prefix (case-insensitive) on name or description. Selecting a suggestion only fills the argument;
|
|
285
|
+
* submitting still opens the skills manager.
|
|
286
|
+
*/
|
|
287
|
+
export function skillCompletions(catalog, prefix, limit = 20) {
|
|
288
|
+
const query = prefix.trim().toLowerCase();
|
|
289
|
+
const items = query
|
|
290
|
+
? catalog.filter((skill) => [skill.displayId, skill.name, skill.description].join(" ").toLowerCase().includes(query))
|
|
291
|
+
: catalog;
|
|
292
|
+
return items.slice(0, limit).map((skill) => {
|
|
293
|
+
const status = skillStatus(skill);
|
|
294
|
+
return {
|
|
295
|
+
value: skill.id,
|
|
296
|
+
label: skill.displayId,
|
|
297
|
+
description: truncatePlain(status ? `${status} · ${skill.description}` : skill.description, SKILL_COMPLETION_DESCRIPTION_LIMIT),
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
function argumentCompletionsFor(source, context) {
|
|
302
|
+
const sessions = context.sessions;
|
|
303
|
+
if (source.name === "resume" && sessions)
|
|
304
|
+
return {
|
|
305
|
+
getArgumentCompletions: (prefix) => sessions(prefix).slice(0, 20),
|
|
306
|
+
};
|
|
307
|
+
if (source.name === "skills")
|
|
308
|
+
return {
|
|
309
|
+
getArgumentCompletions: (prefix) => skillCompletions(context.skills, prefix, 20),
|
|
310
|
+
};
|
|
311
|
+
return {};
|
|
312
|
+
}
|
|
313
|
+
export function slashCompletionCommands(sources, context, options = {}) {
|
|
314
|
+
const entries = sources.flatMap((source) => {
|
|
315
|
+
const entry = (name) => ({
|
|
316
|
+
name,
|
|
317
|
+
description: source.description,
|
|
318
|
+
...(source.argumentHint ? { argumentHint: source.argumentHint } : {}),
|
|
319
|
+
...argumentCompletionsFor(source, context),
|
|
320
|
+
});
|
|
321
|
+
return [
|
|
322
|
+
entry(source.name),
|
|
323
|
+
...(source.aliases ?? []).filter((a) => a !== source.name).map(entry),
|
|
324
|
+
];
|
|
325
|
+
});
|
|
326
|
+
if (options.skillEntries !== false)
|
|
327
|
+
for (const skill of context.skills)
|
|
328
|
+
entries.push({
|
|
329
|
+
name: `skill:${skill.id}`,
|
|
330
|
+
description: skillSlashDescription(skill),
|
|
331
|
+
});
|
|
332
|
+
return entries;
|
|
333
|
+
}
|
|
248
334
|
function parseArgs(args) {
|
|
249
335
|
try {
|
|
250
336
|
const value = JSON.parse(args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alisio/alisio-code",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.11",
|
|
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,7 +45,7 @@
|
|
|
45
45
|
"alisio": "./dist/main.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@alisio/core": "0.1.0-alpha.
|
|
48
|
+
"@alisio/core": "0.1.0-alpha.9",
|
|
49
49
|
"@alisio/plugin-deepseek": "0.1.0-alpha.7",
|
|
50
50
|
"@alisio/plugin-memory": "0.1.0-alpha.6",
|
|
51
51
|
"@alisio/plugin-openai-compatible": "0.1.0-alpha.7",
|