@alisio/alisio-code 0.1.0-alpha.10 → 0.1.0-alpha.12
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 +194 -33
- package/dist/tui/components.d.ts +2 -0
- package/dist/tui/components.js +15 -0
- package/dist/tui/exit.d.ts +10 -0
- package/dist/tui/exit.js +15 -0
- package/dist/tui/git-branch.d.ts +48 -0
- package/dist/tui/git-branch.js +99 -0
- 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 +87 -1
- package/package.json +8 -8
package/dist/tui/app.js
CHANGED
|
@@ -5,11 +5,15 @@ import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard,
|
|
|
5
5
|
import { copyText, nodeSpawn } from "./clipboard.js";
|
|
6
6
|
import { AttachmentsBar, BannerBlock, clock, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
|
|
7
7
|
import { ConnectInputPrompt } from "./connect-input.js";
|
|
8
|
+
import { bounded, EXIT_PENDING_CAP_MS, EXIT_SESSION_END_CAP_MS } from "./exit.js";
|
|
9
|
+
import { BRANCH_REFRESH_MS, createBranchCache } from "./git-branch.js";
|
|
8
10
|
import { initialPanelState, reducePanel, visibleRows } from "./panel.js";
|
|
9
11
|
import { summarizeAnswers } from "./questions.js";
|
|
10
12
|
import { InteractiveQueue } from "./queue.js";
|
|
13
|
+
import { settingsMenuRows } from "./settings.js";
|
|
14
|
+
import { SettingsMenu } from "./settings-menu.js";
|
|
11
15
|
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";
|
|
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";
|
|
13
17
|
import { editorTheme, selectListTheme, style } from "./theme.js";
|
|
14
18
|
const VERSION = loadVersion(import.meta.url);
|
|
15
19
|
/** Inline selection list with type-to-filter, rendered above the editor. */
|
|
@@ -151,6 +155,21 @@ export async function runTui(options) {
|
|
|
151
155
|
}, ms);
|
|
152
156
|
tui.requestRender();
|
|
153
157
|
};
|
|
158
|
+
// Git branch of the workspace for the header. Reading refs is a read operation, so it stays on
|
|
159
|
+
// under --read-only; the TTL cache means git is spawned at most once per ~10s, never per frame,
|
|
160
|
+
// and a missing repo/git or a slow spawn simply yields no branch segment.
|
|
161
|
+
const branchCache = createBranchCache();
|
|
162
|
+
let branchName;
|
|
163
|
+
const refreshBranch = () => {
|
|
164
|
+
void branchCache.read(app.workspace).then((branch) => {
|
|
165
|
+
if (branch !== branchName) {
|
|
166
|
+
branchName = branch;
|
|
167
|
+
tui.requestRender();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
refreshBranch();
|
|
172
|
+
const branchTimer = setInterval(refreshBranch, BRANCH_REFRESH_MS);
|
|
154
173
|
const headerInfo = () => {
|
|
155
174
|
const policy = app.runner.policy, ask = app.runner.approvals;
|
|
156
175
|
return {
|
|
@@ -160,6 +179,7 @@ export async function runTui(options) {
|
|
|
160
179
|
provider: app.providers.get(activeProvider?.id ?? "")?.name,
|
|
161
180
|
cwd: shortenPath(app.workspace, homedir()),
|
|
162
181
|
session: shortId(session),
|
|
182
|
+
branch: branchName,
|
|
163
183
|
write: policy.write ? "on" : ask ? "ask" : "off",
|
|
164
184
|
process: policy.process ? "on" : ask ? "ask" : "off",
|
|
165
185
|
mcp: app.mcpRuntimePermission() === "granted",
|
|
@@ -229,7 +249,7 @@ export async function runTui(options) {
|
|
|
229
249
|
app.plugins.onStatusChange = () => tui.requestRender();
|
|
230
250
|
const pickerSlot = new Container();
|
|
231
251
|
const attachmentsBar = new AttachmentsBar(() => pendingAttachments);
|
|
232
|
-
const editor = new Editor(tui, editorTheme, { paddingX:
|
|
252
|
+
const editor = new Editor(tui, editorTheme, { paddingX: app.config.tui.paddingX });
|
|
233
253
|
const bottom = new Container();
|
|
234
254
|
bottom.addChild(pickerSlot);
|
|
235
255
|
bottom.addChild(attachmentsBar);
|
|
@@ -323,7 +343,7 @@ export async function runTui(options) {
|
|
|
323
343
|
})
|
|
324
344
|
.catch(() => { });
|
|
325
345
|
};
|
|
326
|
-
let busy = false, controller, pending, picker;
|
|
346
|
+
let busy = false, controller, pending, picker, settingsMenu;
|
|
327
347
|
const showPicker = (next) => {
|
|
328
348
|
picker = next;
|
|
329
349
|
pickerSlot.clear();
|
|
@@ -333,6 +353,7 @@ export async function runTui(options) {
|
|
|
333
353
|
};
|
|
334
354
|
const closePicker = () => {
|
|
335
355
|
picker = undefined;
|
|
356
|
+
settingsMenu = undefined;
|
|
336
357
|
pickerSlot.clear();
|
|
337
358
|
tui.setFocus(editor);
|
|
338
359
|
tui.requestRender();
|
|
@@ -476,7 +497,7 @@ export async function runTui(options) {
|
|
|
476
497
|
const endSession = async (reason) => {
|
|
477
498
|
if (!view.stats.runs || !app.plugins.hasSessionEndHooks)
|
|
478
499
|
return;
|
|
479
|
-
flashHint("Running session-end plugin hooks
|
|
500
|
+
flashHint("Running session-end plugin hooks…", 60_000);
|
|
480
501
|
tui.renderNow?.();
|
|
481
502
|
try {
|
|
482
503
|
const { failures } = await app.endSession(session, reason);
|
|
@@ -496,8 +517,12 @@ export async function runTui(options) {
|
|
|
496
517
|
return;
|
|
497
518
|
stopping = true;
|
|
498
519
|
controller?.abort(new Error("Exiting"));
|
|
499
|
-
|
|
500
|
-
|
|
520
|
+
// Exit must feel instant: an in-flight turn gets up to 3s (the abort above settles it
|
|
521
|
+
// immediately in practice), then session-end hooks get a short cap — /clear still honors
|
|
522
|
+
// the full pluginHooks.sessionEndTimeoutMs. app.close() later in the main path is itself
|
|
523
|
+
// parallel and capped, so the whole exit path is bounded end to end.
|
|
524
|
+
await bounded(pending?.catch(() => { }), EXIT_PENDING_CAP_MS);
|
|
525
|
+
await bounded(endSession("exit"), EXIT_SESSION_END_CAP_MS);
|
|
501
526
|
resolveExit();
|
|
502
527
|
};
|
|
503
528
|
const askInput = (input) => new Promise((resolve) => {
|
|
@@ -922,6 +947,125 @@ export async function runTui(options) {
|
|
|
922
947
|
};
|
|
923
948
|
openCatalog();
|
|
924
949
|
};
|
|
950
|
+
/**
|
|
951
|
+
* Navigation rows at the bottom of `/settings`: they route to the existing managers and one-shot
|
|
952
|
+
* actions exactly like the old picker, so every prior entry stays reachable. `compact` and
|
|
953
|
+
* `stats` finish here and re-open the settings list (mirroring managePlugins' openCatalog loop);
|
|
954
|
+
* rows that delegate to another manager (provider/model, connect, plugins, skills, MCP) leave
|
|
955
|
+
* the list and that manager owns its Esc/back behavior.
|
|
956
|
+
*/
|
|
957
|
+
const SETTINGS_NAVIGATION = [
|
|
958
|
+
{
|
|
959
|
+
id: "model",
|
|
960
|
+
label: "Provider & model",
|
|
961
|
+
description: "Switch the active provider and model",
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
id: "connect",
|
|
965
|
+
label: "Connect provider",
|
|
966
|
+
description: "Configure a provider and choose its active model",
|
|
967
|
+
},
|
|
968
|
+
{
|
|
969
|
+
id: "compact",
|
|
970
|
+
label: "Compact context now",
|
|
971
|
+
description: "Summarize older history with the current model, then return here",
|
|
972
|
+
},
|
|
973
|
+
{ id: "plugins", label: "Plugins", description: "Browse and manage project plugins" },
|
|
974
|
+
{ id: "skills", label: "Skills", description: "Browse and manage effective skills" },
|
|
975
|
+
{ id: "mcp", label: "MCP servers", description: "Browse and manage MCP servers" },
|
|
976
|
+
{
|
|
977
|
+
id: "stats",
|
|
978
|
+
label: "Session statistics",
|
|
979
|
+
description: "View session statistics, then return here",
|
|
980
|
+
},
|
|
981
|
+
];
|
|
982
|
+
/**
|
|
983
|
+
* Central `/settings` (`/prefs`) menu: an OpenCode-style list of REAL, wired preferences
|
|
984
|
+
* (compaction, context fallback, MCP consent, limits, editor padding) with a live type-to-search
|
|
985
|
+
* filter, Enter/Space to cycle a value, a `(n/total)` counter, and a footer describing the
|
|
986
|
+
* highlighted row. Esc returns to the editor. Every setting persists to the GLOBAL user config
|
|
987
|
+
* through `app.updateSetting` (or the MCP consent path) and is applied to the running process
|
|
988
|
+
* where supported; rows rebuild from the live config after each change.
|
|
989
|
+
*/
|
|
990
|
+
const openSettings = () => {
|
|
991
|
+
const providerName = app.providers.get(activeProvider?.id ?? "")?.name;
|
|
992
|
+
const current = view.model
|
|
993
|
+
? providerName
|
|
994
|
+
? `${providerName} · ${view.model}`
|
|
995
|
+
: view.model
|
|
996
|
+
: "";
|
|
997
|
+
const build = () => settingsMenuRows({
|
|
998
|
+
config: app.config,
|
|
999
|
+
mcpAllowPersisted: app.mcpAllowPersisted(),
|
|
1000
|
+
readOnly: !!options.readOnly,
|
|
1001
|
+
}, SETTINGS_NAVIGATION);
|
|
1002
|
+
const applySetting = (row, value) => {
|
|
1003
|
+
const settle = () => settingsMenu?.refresh(build());
|
|
1004
|
+
if (row.id === "mcp.allow") {
|
|
1005
|
+
const consent = value === true ? app.rememberGlobalMcpConsent() : app.revokeGlobalMcpConsent();
|
|
1006
|
+
void consent
|
|
1007
|
+
.then(() => notice(value === true
|
|
1008
|
+
? "MCP consent remembered globally (mcp.allow); enabled servers will auto-connect on every start. Use /mcp to connect them now."
|
|
1009
|
+
: "Global MCP consent revoked: runtime permission dropped and configured servers disconnected."))
|
|
1010
|
+
.catch(error)
|
|
1011
|
+
.finally(settle);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const stored = row.id === "limits.timeoutMs" ? Number(value) * 1000 : value;
|
|
1015
|
+
void app
|
|
1016
|
+
// Setting ids are the exact SettableSettingKey paths SETTINGS_DEFINITIONS is built from.
|
|
1017
|
+
.updateSetting(row.id, stored)
|
|
1018
|
+
.then(() => {
|
|
1019
|
+
if (row.id === "tui.paddingX")
|
|
1020
|
+
editor.setPaddingX(Number(value));
|
|
1021
|
+
// Rebuild the slash provider: the toggle gates the skill:<id> entries immediately.
|
|
1022
|
+
if (row.id === "tui.skillSlashCommands")
|
|
1023
|
+
editor.setAutocompleteProvider(buildSlashCompletionProvider());
|
|
1024
|
+
})
|
|
1025
|
+
.catch(error)
|
|
1026
|
+
.finally(settle);
|
|
1027
|
+
};
|
|
1028
|
+
const navigate = (row) => {
|
|
1029
|
+
switch (row.action) {
|
|
1030
|
+
case "model":
|
|
1031
|
+
closePicker();
|
|
1032
|
+
return void chooseModel();
|
|
1033
|
+
case "connect":
|
|
1034
|
+
closePicker();
|
|
1035
|
+
return void connect();
|
|
1036
|
+
case "compact":
|
|
1037
|
+
closePicker();
|
|
1038
|
+
void task((signal) => app.runner.compact(session, { signal }))
|
|
1039
|
+
.catch(error)
|
|
1040
|
+
.then(openSettings);
|
|
1041
|
+
return;
|
|
1042
|
+
case "plugins":
|
|
1043
|
+
closePicker();
|
|
1044
|
+
return managePlugins();
|
|
1045
|
+
case "skills":
|
|
1046
|
+
closePicker();
|
|
1047
|
+
return manageSkills();
|
|
1048
|
+
case "mcp":
|
|
1049
|
+
closePicker();
|
|
1050
|
+
return manageMcp();
|
|
1051
|
+
case "stats":
|
|
1052
|
+
closePicker();
|
|
1053
|
+
info(statsReport());
|
|
1054
|
+
openSettings();
|
|
1055
|
+
return;
|
|
1056
|
+
default:
|
|
1057
|
+
return openSettings();
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const menu = new SettingsMenu("Settings", build(), applySetting, navigate, closePicker, [
|
|
1061
|
+
current ? `Current: ${current}` : "",
|
|
1062
|
+
...(options.readOnly ? ["Read-only run: changes are not persisted"] : []),
|
|
1063
|
+
]
|
|
1064
|
+
.filter(Boolean)
|
|
1065
|
+
.join("\n") || undefined);
|
|
1066
|
+
settingsMenu = menu;
|
|
1067
|
+
showPicker(menu);
|
|
1068
|
+
};
|
|
925
1069
|
const workspaceSessions = () => app.store.list().filter((s) => s.workspace === app.workspace && s.provider === app.provider.id);
|
|
926
1070
|
const firstPrompt = (id) => {
|
|
927
1071
|
const first = app.store.messages(id).find((m) => m.role === "user" && !m.summary);
|
|
@@ -1025,6 +1169,7 @@ export async function runTui(options) {
|
|
|
1025
1169
|
"plugins",
|
|
1026
1170
|
"skills",
|
|
1027
1171
|
"mcp",
|
|
1172
|
+
"settings",
|
|
1028
1173
|
"compact",
|
|
1029
1174
|
"clear",
|
|
1030
1175
|
"resume",
|
|
@@ -1081,6 +1226,8 @@ export async function runTui(options) {
|
|
|
1081
1226
|
return manageSkills();
|
|
1082
1227
|
case "mcp":
|
|
1083
1228
|
return manageMcp();
|
|
1229
|
+
case "settings":
|
|
1230
|
+
return openSettings();
|
|
1084
1231
|
case "compact":
|
|
1085
1232
|
return await task((signal) => app.runner.compact(session, { focus: parsed.args || undefined, signal }));
|
|
1086
1233
|
case "copy": {
|
|
@@ -1149,33 +1296,46 @@ export async function runTui(options) {
|
|
|
1149
1296
|
editor.onSubmit = (text) => {
|
|
1150
1297
|
void handleSubmit(text);
|
|
1151
1298
|
};
|
|
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
|
-
|
|
1299
|
+
/**
|
|
1300
|
+
* Builds the editor slash-autocomplete provider from the live config: `tui.skillSlashCommands`
|
|
1301
|
+
* gates the standalone `skill:<id>` entries (the `/skills` manager always keeps its argument
|
|
1302
|
+
* completion), so rebuilding after a settings save takes effect without a restart.
|
|
1303
|
+
*/
|
|
1304
|
+
const buildSlashCompletionProvider = () => {
|
|
1305
|
+
const sources = [
|
|
1306
|
+
...COMMANDS,
|
|
1307
|
+
...[...app.prompts.templates.values()].map((t) => ({
|
|
1308
|
+
name: t.name,
|
|
1309
|
+
description: `${t.description} (template)`,
|
|
1310
|
+
...(t.argumentHint ? { argumentHint: t.argumentHint } : {}),
|
|
1311
|
+
})),
|
|
1312
|
+
...[...app.plugins.commandInfo.entries()]
|
|
1313
|
+
.filter(([name]) => !resolveCommand(name))
|
|
1314
|
+
.map(([name, c]) => ({
|
|
1315
|
+
name,
|
|
1316
|
+
description: c.description ?? `plugin ${c.plugin}`,
|
|
1317
|
+
...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
|
|
1318
|
+
})),
|
|
1319
|
+
];
|
|
1320
|
+
return new CombinedAutocompleteProvider(slashCompletionCommands(sources, {
|
|
1321
|
+
sessions: (prefix) => workspaceSessions()
|
|
1322
|
+
.filter((s) => s.id.startsWith(prefix))
|
|
1323
|
+
.map((s) => ({ value: s.id, label: shortId(s.id), description: s.model })),
|
|
1324
|
+
skills: app
|
|
1325
|
+
.skillCatalog()
|
|
1326
|
+
.map(({ id, name, displayId, description, scope, enabled, locked, effective }) => ({
|
|
1327
|
+
id,
|
|
1328
|
+
name,
|
|
1329
|
+
displayId,
|
|
1330
|
+
description,
|
|
1331
|
+
scope,
|
|
1332
|
+
enabled,
|
|
1333
|
+
locked,
|
|
1334
|
+
effective,
|
|
1335
|
+
})),
|
|
1336
|
+
}, { skillEntries: app.config.tui.skillSlashCommands !== false }), app.workspace);
|
|
1337
|
+
};
|
|
1338
|
+
editor.setAutocompleteProvider(buildSlashCompletionProvider());
|
|
1179
1339
|
// Interactive services for plugins: choices (e.g. worktree isolation) and session views.
|
|
1180
1340
|
app.plugins.setInteractiveUI({
|
|
1181
1341
|
// No session/label/signal on SelectRequest: queued FIFO like everything else, never withdrawn
|
|
@@ -1402,6 +1562,7 @@ export async function runTui(options) {
|
|
|
1402
1562
|
}
|
|
1403
1563
|
finally {
|
|
1404
1564
|
clearInterval(ticker);
|
|
1565
|
+
clearInterval(branchTimer);
|
|
1405
1566
|
clearTimeout(hintTimer);
|
|
1406
1567
|
process.off("SIGTERM", onSignal);
|
|
1407
1568
|
process.off("SIGHUP", onSignal);
|
package/dist/tui/components.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface HeaderInfo {
|
|
|
17
17
|
provider?: string;
|
|
18
18
|
cwd: string;
|
|
19
19
|
session: string;
|
|
20
|
+
/** Git branch (or short SHA on a detached HEAD) of the session workspace; undefined when unavailable. */
|
|
21
|
+
branch?: string;
|
|
20
22
|
write: PermissionState;
|
|
21
23
|
process: PermissionState;
|
|
22
24
|
mcp: boolean;
|
package/dist/tui/components.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Container, getCapabilities, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { terminalCapabilities } from "../banner.js";
|
|
2
3
|
import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
|
|
4
|
+
import { branchDisplay } from "./git-branch.js";
|
|
3
5
|
import { initialQuestionState, reduceQuestions, } from "./questions.js";
|
|
4
6
|
import { contextLevel, contextPercent, editSummary, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
|
|
5
7
|
import { imageTheme, levelColor, markdownTheme, style } from "./theme.js";
|
|
@@ -53,6 +55,19 @@ export class Header {
|
|
|
53
55
|
const line2 = line([
|
|
54
56
|
{ text: i.cwd, priority: 6, paint: style.cyan },
|
|
55
57
|
{ text: `session ${i.session}`, priority: 4, paint: style.gray },
|
|
58
|
+
// Priority 3 (below session's 4): the branch is ambient context, so on narrow terminals it
|
|
59
|
+
// drops first — before the session ID and permissions. At equal priority fitSegments drops
|
|
60
|
+
// the FIRST lowest-priority segment, which would mis-drop the session when branch sits
|
|
61
|
+
// after it, so the branch keeps a strictly lower priority instead.
|
|
62
|
+
...(i.branch
|
|
63
|
+
? [
|
|
64
|
+
{
|
|
65
|
+
text: branchDisplay(i.branch, terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode),
|
|
66
|
+
priority: 3,
|
|
67
|
+
paint: style.cyan,
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
: []),
|
|
56
71
|
...perms,
|
|
57
72
|
], width);
|
|
58
73
|
return fit([line1, line2, style.gray("─".repeat(Math.max(0, width)))], width);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive-exit bounds: `/exit`, double Ctrl+C on an empty input, Ctrl+D and SIGINT/SIGTERM
|
|
3
|
+
* must feel instant, so each shutdown step is raced against a cap instead of awaiting teardown
|
|
4
|
+
* without limits. The plugin host's own hook timeout (`pluginHooks.sessionEndTimeoutMs`) still
|
|
5
|
+
* applies to `/clear`; exit deliberately uses shorter user-facing caps on the same hooks.
|
|
6
|
+
*/
|
|
7
|
+
export declare const EXIT_PENDING_CAP_MS = 3000;
|
|
8
|
+
export declare const EXIT_SESSION_END_CAP_MS = 1500;
|
|
9
|
+
/** Resolves when `work` settles or after `capMs`, whichever comes first; never rejects. */
|
|
10
|
+
export declare function bounded(work: Promise<unknown> | undefined, capMs: number): Promise<void>;
|
package/dist/tui/exit.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive-exit bounds: `/exit`, double Ctrl+C on an empty input, Ctrl+D and SIGINT/SIGTERM
|
|
3
|
+
* must feel instant, so each shutdown step is raced against a cap instead of awaiting teardown
|
|
4
|
+
* without limits. The plugin host's own hook timeout (`pluginHooks.sessionEndTimeoutMs`) still
|
|
5
|
+
* applies to `/clear`; exit deliberately uses shorter user-facing caps on the same hooks.
|
|
6
|
+
*/
|
|
7
|
+
export const EXIT_PENDING_CAP_MS = 3_000;
|
|
8
|
+
export const EXIT_SESSION_END_CAP_MS = 1_500;
|
|
9
|
+
/** Resolves when `work` settles or after `capMs`, whichever comes first; never rejects. */
|
|
10
|
+
export function bounded(work, capMs) {
|
|
11
|
+
return Promise.race([
|
|
12
|
+
Promise.resolve(work).catch(() => { }),
|
|
13
|
+
new Promise((resolve) => setTimeout(resolve, capMs)),
|
|
14
|
+
]).then(() => undefined);
|
|
15
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/** Hard cap for branch names shown in the header, so a hostile/long ref never breaks layout. */
|
|
3
|
+
export declare const BRANCH_MAX_LENGTH = 24;
|
|
4
|
+
/** How long a resolved branch is reused before git is asked again (avoids spawning per frame). */
|
|
5
|
+
export declare const BRANCH_TTL_MS = 10000;
|
|
6
|
+
/** Re-check cadence; matches the cache TTL so the header follows branch switches within ~10s. */
|
|
7
|
+
export declare const BRANCH_REFRESH_MS = 10000;
|
|
8
|
+
/** Bounded spawn: a slow or hung git must never stall the TUI. */
|
|
9
|
+
export declare const BRANCH_TIMEOUT_MS = 800;
|
|
10
|
+
type Spawn = typeof execFile;
|
|
11
|
+
/**
|
|
12
|
+
* Strips ANSI escapes and control characters, then truncates to `BRANCH_MAX_LENGTH` chars with an
|
|
13
|
+
* ellipsis. Branch names come from the workspace repository, not from user input, but a hostile or
|
|
14
|
+
* corrupted ref must never inject escape sequences or overflow the header line.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sanitizeBranchName(name: string): string;
|
|
17
|
+
/** Header segment text: `⎇ main` on unicode terminals, `branch main` otherwise. */
|
|
18
|
+
export declare function branchDisplay(branch: string, unicode: boolean): string;
|
|
19
|
+
export interface ReadBranchOptions {
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Injectable for tests; defaults to node:child_process execFile. */
|
|
22
|
+
spawn?: Spawn;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads the branch of the git repository at `workspace` (read-only ref query, safe under
|
|
26
|
+
* `--read-only`). Returns the branch name, the short commit SHA on a detached HEAD, or `undefined`
|
|
27
|
+
* when the directory is missing, is not a git repository, git itself is unavailable, or the call
|
|
28
|
+
* times out. All failures are silent: stderr is captured and never printed.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readGitBranch(workspace: string, options?: ReadBranchOptions): Promise<string | undefined>;
|
|
31
|
+
export interface BranchCache {
|
|
32
|
+
/** Cached branch for `workspace`, or a fresh read when the entry expired; dedupes in-flight reads. */
|
|
33
|
+
read(workspace: string): Promise<string | undefined>;
|
|
34
|
+
clear(workspace: string): void;
|
|
35
|
+
}
|
|
36
|
+
export interface BranchCacheOptions {
|
|
37
|
+
ttlMs?: number;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
spawn?: Spawn;
|
|
40
|
+
/** Injectable clock for expiry tests. */
|
|
41
|
+
now?: () => number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* TTL cache keyed by workspace so the TUI never spawns git on every frame: one read per workspace
|
|
45
|
+
* per `ttlMs` (hits and misses are both cached), with a single in-flight promise per key.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createBranchCache(options?: BranchCacheOptions): BranchCache;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
/** Hard cap for branch names shown in the header, so a hostile/long ref never breaks layout. */
|
|
4
|
+
export const BRANCH_MAX_LENGTH = 24;
|
|
5
|
+
/** How long a resolved branch is reused before git is asked again (avoids spawning per frame). */
|
|
6
|
+
export const BRANCH_TTL_MS = 10_000;
|
|
7
|
+
/** Re-check cadence; matches the cache TTL so the header follows branch switches within ~10s. */
|
|
8
|
+
export const BRANCH_REFRESH_MS = BRANCH_TTL_MS;
|
|
9
|
+
/** Bounded spawn: a slow or hung git must never stall the TUI. */
|
|
10
|
+
export const BRANCH_TIMEOUT_MS = 800;
|
|
11
|
+
/**
|
|
12
|
+
* Strips ANSI escapes and control characters, then truncates to `BRANCH_MAX_LENGTH` chars with an
|
|
13
|
+
* ellipsis. Branch names come from the workspace repository, not from user input, but a hostile or
|
|
14
|
+
* corrupted ref must never inject escape sequences or overflow the header line.
|
|
15
|
+
*/
|
|
16
|
+
export function sanitizeBranchName(name) {
|
|
17
|
+
const clean = name
|
|
18
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
|
|
19
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
20
|
+
.trim();
|
|
21
|
+
const chars = [...clean];
|
|
22
|
+
return chars.length > BRANCH_MAX_LENGTH
|
|
23
|
+
? `${chars.slice(0, BRANCH_MAX_LENGTH - 1).join("")}…`
|
|
24
|
+
: clean;
|
|
25
|
+
}
|
|
26
|
+
/** Header segment text: `⎇ main` on unicode terminals, `branch main` otherwise. */
|
|
27
|
+
export function branchDisplay(branch, unicode) {
|
|
28
|
+
return `${unicode ? "⎇" : "branch"} ${branch}`;
|
|
29
|
+
}
|
|
30
|
+
function runGit(spawn, args, cwd, timeoutMs) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
spawn("git", args, { cwd, timeout: timeoutMs, windowsHide: true }, (error, stdout) => {
|
|
33
|
+
if (error)
|
|
34
|
+
reject(error);
|
|
35
|
+
else
|
|
36
|
+
resolve(stdout);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reads the branch of the git repository at `workspace` (read-only ref query, safe under
|
|
42
|
+
* `--read-only`). Returns the branch name, the short commit SHA on a detached HEAD, or `undefined`
|
|
43
|
+
* when the directory is missing, is not a git repository, git itself is unavailable, or the call
|
|
44
|
+
* times out. All failures are silent: stderr is captured and never printed.
|
|
45
|
+
*/
|
|
46
|
+
export async function readGitBranch(workspace, options = {}) {
|
|
47
|
+
const timeoutMs = options.timeoutMs ?? BRANCH_TIMEOUT_MS;
|
|
48
|
+
if (!existsSync(workspace))
|
|
49
|
+
return undefined;
|
|
50
|
+
const spawn = options.spawn ?? execFile;
|
|
51
|
+
try {
|
|
52
|
+
const ref = (await runGit(spawn, ["rev-parse", "--abbrev-ref", "HEAD"], workspace, timeoutMs)).trim();
|
|
53
|
+
if (ref && ref !== "HEAD")
|
|
54
|
+
return sanitizeBranchName(ref);
|
|
55
|
+
if (ref === "HEAD") {
|
|
56
|
+
const sha = (await runGit(spawn, ["rev-parse", "--short", "HEAD"], workspace, timeoutMs)).trim();
|
|
57
|
+
return sha ? sanitizeBranchName(sha) : undefined;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Not a repository, git missing, or timed out: the header simply shows no branch segment.
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* TTL cache keyed by workspace so the TUI never spawns git on every frame: one read per workspace
|
|
68
|
+
* per `ttlMs` (hits and misses are both cached), with a single in-flight promise per key.
|
|
69
|
+
*/
|
|
70
|
+
export function createBranchCache(options = {}) {
|
|
71
|
+
const ttlMs = options.ttlMs ?? BRANCH_TTL_MS;
|
|
72
|
+
const now = options.now ?? Date.now;
|
|
73
|
+
const entries = new Map();
|
|
74
|
+
const inflight = new Map();
|
|
75
|
+
const read = (workspace) => {
|
|
76
|
+
const hit = entries.get(workspace);
|
|
77
|
+
if (hit && hit.expires > now())
|
|
78
|
+
return Promise.resolve(hit.branch);
|
|
79
|
+
const pending = inflight.get(workspace);
|
|
80
|
+
if (pending)
|
|
81
|
+
return pending;
|
|
82
|
+
const promise = readGitBranch(workspace, {
|
|
83
|
+
timeoutMs: options.timeoutMs,
|
|
84
|
+
spawn: options.spawn,
|
|
85
|
+
})
|
|
86
|
+
.then((branch) => {
|
|
87
|
+
entries.set(workspace, { branch, expires: now() + ttlMs });
|
|
88
|
+
inflight.delete(workspace);
|
|
89
|
+
return branch;
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {
|
|
92
|
+
inflight.delete(workspace);
|
|
93
|
+
return undefined;
|
|
94
|
+
});
|
|
95
|
+
inflight.set(workspace, promise);
|
|
96
|
+
return promise;
|
|
97
|
+
};
|
|
98
|
+
return { read, clear: (workspace) => void entries.delete(workspace) };
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|