@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 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: 1 });
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 (bounded by pluginHooks.sessionEndTimeoutMs)…", 60_000);
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
- await Promise.race([pending?.catch(() => { }), new Promise((r) => setTimeout(r, 3000))]);
500
- await endSession("exit");
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
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
1153
- ...COMMANDS,
1154
- ...[...app.prompts.templates.values()].map((t) => ({
1155
- name: t.name,
1156
- description: `${t.description} (template)`,
1157
- ...(t.argumentHint ? { argumentHint: t.argumentHint } : {}),
1158
- })),
1159
- ...[...app.plugins.commandInfo.entries()]
1160
- .filter(([name]) => !resolveCommand(name))
1161
- .map(([name, c]) => ({
1162
- name,
1163
- description: c.description ?? `plugin ${c.plugin}`,
1164
- ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
1165
- })),
1166
- ].map((c) => ({
1167
- name: c.name,
1168
- description: c.description,
1169
- ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
1170
- ...(c.name === "resume"
1171
- ? {
1172
- getArgumentCompletions: (prefix) => workspaceSessions()
1173
- .filter((s) => s.id.startsWith(prefix))
1174
- .slice(0, 20)
1175
- .map((s) => ({ value: s.id, label: shortId(s.id), description: s.model })),
1176
- }
1177
- : {}),
1178
- })), app.workspace));
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);
@@ -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;
@@ -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>;
@@ -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
+ }