surgent 0.7.0-alpha.0

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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,34 @@
1
+ import { getLastEntryId, readSessionEntries, writeSessionEntries } from "../entries.js";
2
+ import { removeEntries } from "./cleanup.js";
3
+ import type { PruneEntriesResult } from "./types.js";
4
+
5
+ function pruneEntries(
6
+ entries: Record<string, unknown>[],
7
+ leafId: string | null,
8
+ ): PruneEntriesResult {
9
+ const entryRemoval = removeEntries(entries);
10
+ let activeLeafId = leafId;
11
+ while (activeLeafId && entryRemoval.replacementParents.has(activeLeafId)) {
12
+ activeLeafId = entryRemoval.replacementParents.get(activeLeafId) ?? null;
13
+ }
14
+
15
+ return {
16
+ activeLeafId,
17
+ changed: entryRemoval.changed,
18
+ entries: entryRemoval.entries,
19
+ };
20
+ }
21
+
22
+ export function rewritePrunedSessionFile(
23
+ sessionFile: string,
24
+ leafId: string | null,
25
+ fallbackToLastEntry: boolean,
26
+ ) {
27
+ const entries = readSessionEntries(sessionFile);
28
+ if (!entries) return;
29
+
30
+ const effectiveLeafId = leafId ?? (fallbackToLastEntry ? getLastEntryId(entries) : null);
31
+ const pruned = pruneEntries(entries, effectiveLeafId);
32
+ if (!pruned.changed) return;
33
+ writeSessionEntries(sessionFile, pruned.entries);
34
+ }
@@ -0,0 +1,18 @@
1
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface ContextPruneResult {
4
+ changed: boolean;
5
+ messages: ContextEvent["messages"];
6
+ }
7
+
8
+ export interface RemovedEntryRemoval {
9
+ changed: boolean;
10
+ entries: Record<string, unknown>[];
11
+ replacementParents: Map<string, string | null>;
12
+ }
13
+
14
+ export interface PruneEntriesResult {
15
+ activeLeafId: string | null;
16
+ changed: boolean;
17
+ entries: Record<string, unknown>[];
18
+ }
@@ -0,0 +1,124 @@
1
+ import Parser, { type SyntaxNode } from "tree-sitter";
2
+ import Bash from "tree-sitter-bash";
3
+ import type { BashCommand } from "./types.js";
4
+
5
+ const parser = new Parser();
6
+ parser.setLanguage(Bash as unknown as Parser.Language);
7
+
8
+ const DYNAMIC_TYPES = new Set([
9
+ "ansi_c_string",
10
+ "arithmetic_expansion",
11
+ "command_substitution",
12
+ "expansion",
13
+ "process_substitution",
14
+ "simple_expansion",
15
+ ]);
16
+
17
+ function containsDynamic(node: SyntaxNode): boolean {
18
+ if (DYNAMIC_TYPES.has(node.type)) return true;
19
+ for (const child of node.namedChildren) {
20
+ if (containsDynamic(child)) return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ function decodeCommandName(value: string): string | null {
26
+ if (value.startsWith("$'")) return null;
27
+
28
+ let result = "";
29
+ let quote: "single" | "double" | null = null;
30
+
31
+ for (let index = 0; index < value.length; index += 1) {
32
+ const char = value[index]!;
33
+ if (quote === "single") {
34
+ if (char === "'") quote = null;
35
+ else result += char;
36
+ continue;
37
+ }
38
+ if (quote === "double") {
39
+ if (char === '"') {
40
+ quote = null;
41
+ } else if (char === "\\" && index + 1 < value.length) {
42
+ const escaped = value[index + 1]!;
43
+ if ('$`"\\\n'.includes(escaped)) {
44
+ result += escaped;
45
+ index += 1;
46
+ } else {
47
+ result += char;
48
+ }
49
+ } else {
50
+ result += char;
51
+ }
52
+ continue;
53
+ }
54
+ if (char === "'") {
55
+ quote = "single";
56
+ } else if (char === '"') {
57
+ quote = "double";
58
+ } else if (char === "\\" && index + 1 < value.length) {
59
+ result += value[index + 1]!;
60
+ index += 1;
61
+ } else {
62
+ result += char;
63
+ }
64
+ }
65
+
66
+ return quote === null ? result : null;
67
+ }
68
+
69
+ function commandFromNode(node: SyntaxNode): BashCommand {
70
+ const nameNode = node.childForFieldName("name");
71
+ if (!nameNode) {
72
+ return { text: node.text.trim(), unresolved: true };
73
+ }
74
+
75
+ const args = node.childrenForFieldName("argument").map((argument) => argument.text);
76
+ return {
77
+ text: [nameNode.text, ...args].join(" "),
78
+ unresolved: containsDynamic(nameNode) || decodeCommandName(nameNode.text) === null,
79
+ };
80
+ }
81
+
82
+ function patternFromNode(node: SyntaxNode): string {
83
+ const nameNode = node.childForFieldName("name");
84
+ if (!nameNode) return node.text.trim();
85
+
86
+ const commandName = decodeCommandName(nameNode.text) ?? nameNode.text;
87
+ const args = node.childrenForFieldName("argument");
88
+ if (!args.length) return commandName;
89
+
90
+ if (commandName === "git" && !containsDynamic(args[0]!)) {
91
+ return `${commandName} ${args[0]!.text} *`;
92
+ }
93
+ return `${commandName} *`;
94
+ }
95
+
96
+ export function extractBashCommands(source: string): BashCommand[] {
97
+ const trimmed = source.trim();
98
+ if (!trimmed) return [];
99
+
100
+ const tree = parser.parse(trimmed);
101
+ if (tree.rootNode.hasError) {
102
+ return [{ text: trimmed, unresolved: true }];
103
+ }
104
+
105
+ const commands = new Map<string, BashCommand>();
106
+ for (const node of tree.rootNode.descendantsOfType("command")) {
107
+ const command = commandFromNode(node);
108
+ const existing = commands.get(command.text);
109
+ if (!existing || command.unresolved) commands.set(command.text, command);
110
+ }
111
+
112
+ return [...commands.values()];
113
+ }
114
+
115
+ export function bashToPattern(source: string): string {
116
+ const trimmed = source.trim();
117
+ if (!trimmed) return "";
118
+
119
+ const tree = parser.parse(trimmed);
120
+ if (tree.rootNode.hasError) return trimmed;
121
+
122
+ const commandNode = tree.rootNode.descendantsOfType("command")[0];
123
+ return commandNode ? patternFromNode(commandNode) : trimmed;
124
+ }
@@ -0,0 +1,111 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
3
+ import type { Category, DisplayRule, FileAccess, PermissionRule } from "./types.js";
4
+ import { getRulesForDisplay, addRules, readRules, writeRules } from "./storage.js";
5
+ import PermissionRulesList from "./components/rules-list.js";
6
+ import { Frame } from "../ui/components/frame.js";
7
+ import { FormField } from "../ui/components/form-field.js";
8
+ import {
9
+ formatRuleOptionLabel,
10
+ getRulePatternPlaceholder,
11
+ cycleRuleScope,
12
+ cycleRuleValue,
13
+ } from "./helpers.js";
14
+
15
+ async function persistRules(
16
+ ctx: ExtensionCommandContext,
17
+ sessionId: string,
18
+ data: { session: PermissionRule; project: PermissionRule; global: PermissionRule },
19
+ ): Promise<void> {
20
+ const local = await readRules(ctx.cwd);
21
+ local[sessionId] = data.session;
22
+ local.project = data.project;
23
+
24
+ if (Object.keys(data.session).length > 0 || Object.keys(data.project).length > 0) {
25
+ await writeRules(local, ctx.cwd);
26
+ }
27
+ if (Object.keys(data.global).length > 0) {
28
+ await writeRules(data.global);
29
+ }
30
+ }
31
+
32
+ function notifyError(ctx: ExtensionContext, error: unknown, done?: () => void) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ ctx.ui.notify(`Failed to save permission rule: ${message}`, "error");
35
+ done?.();
36
+ }
37
+
38
+ export async function handlePermissionsCommand(ctx: ExtensionCommandContext) {
39
+ if (!ctx.hasUI) {
40
+ ctx.ui.notify("The /permissions command requires an interactive UI.", "error");
41
+ return;
42
+ }
43
+
44
+ const sessionId = ctx.sessionManager.getSessionId();
45
+ while (true) {
46
+ const rules = await getRulesForDisplay(ctx.cwd, sessionId);
47
+ const action = await ctx.ui.custom<"exit" | "add">((tui, theme, keybindings, done) => {
48
+ const component = new PermissionRulesList(tui, keybindings, theme, rules);
49
+ component.onDone = done;
50
+ component.onSave = async (data) => persistRules(ctx, sessionId, data);
51
+ component.onSaveErr = (error) => notifyError(ctx, error);
52
+ return component;
53
+ });
54
+ if (action !== "add") break;
55
+
56
+ const categoryLabel = await ctx.ui.select("Category", ["File", "Web", "Bash", "MCP"]);
57
+ if (!categoryLabel) continue;
58
+
59
+ const category = categoryLabel.toLowerCase() as Category;
60
+ const defaultValue: FileAccess | boolean = category === "file" ? "read" : true;
61
+ const toAdd: DisplayRule = { pattern: "", value: defaultValue, scope: "session", category };
62
+
63
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
64
+ const frame = new Frame(theme);
65
+ const option = new FormField(tui, keybindings, theme, {
66
+ key: "pattern",
67
+ label: formatRuleOptionLabel(toAdd.scope, toAdd.value),
68
+ mode: {
69
+ type: "input",
70
+ placeholder: getRulePatternPlaceholder(category),
71
+ startEditing: true,
72
+ },
73
+ });
74
+
75
+ option.focused = true;
76
+ option.onInputSubmit = (inputValue) => {
77
+ const nextPattern = inputValue.trim();
78
+ if (!nextPattern) return false;
79
+
80
+ const rules = new Map([[nextPattern, toAdd.value]]);
81
+ void addRules(ctx.cwd, sessionId, toAdd.scope, category, rules)
82
+ .then(done)
83
+ .catch((error) => notifyError(ctx, error, done));
84
+ return true;
85
+ };
86
+ option.onInputCancel = done;
87
+ frame.addCustom(option);
88
+
89
+ return {
90
+ render: (width) => frame.render(width),
91
+ invalidate: () => {
92
+ frame.invalidate();
93
+ option.invalidate();
94
+ },
95
+ handleInput: (data: string) => {
96
+ if (matchesKey(data, Key.shift("tab"))) {
97
+ cycleRuleScope(toAdd);
98
+ option.setLabel(formatRuleOptionLabel(toAdd.scope, toAdd.value));
99
+ return;
100
+ }
101
+ if (matchesKey(data, Key.tab)) {
102
+ cycleRuleValue(toAdd);
103
+ option.setLabel(formatRuleOptionLabel(toAdd.scope, toAdd.value));
104
+ return;
105
+ }
106
+ option.handleInput(data);
107
+ },
108
+ };
109
+ });
110
+ }
111
+ }
@@ -0,0 +1,255 @@
1
+ import { Input, Key, visibleWidth, wrapTextWithAnsi, type Focusable } from "@earendil-works/pi-tui";
2
+ import type { PromptDecision, PermissionCheck, PromptOptions, FileAccess } from "../types.js";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { Frame } from "../../ui/components/frame.js";
5
+ import { SCOPES } from "../constants.js";
6
+ import { Lines } from "../../ui/components/lines.js";
7
+ import { getScopeLabel, mapToRules } from "../helpers.js";
8
+ import { addRules } from "../storage.js";
9
+ import { toPattern } from "../pattern.js";
10
+ import { unique } from "../../utils.js";
11
+
12
+ export default class PermissionPrompt extends Frame implements Focusable {
13
+ private cursor: number = 0;
14
+ private amending: boolean = false;
15
+ private readonly input = new Input();
16
+ private scopeIdx = 0;
17
+ private options: PromptOptions[] = [];
18
+ private _focused = false;
19
+ private cachedLines: string[] | undefined;
20
+ private inputError: string | undefined;
21
+ private readonly patterns: string[] = [];
22
+
23
+ onDone?: (decision: PromptDecision) => void;
24
+
25
+ constructor(
26
+ protected theme: Theme,
27
+ private readonly check: PermissionCheck,
28
+ private readonly cwd: string,
29
+ ) {
30
+ super(theme);
31
+ if (!check.uncertainty) {
32
+ this.patterns = unique(
33
+ check.extracted.map((item) => toPattern(check.toolName, item)).filter(Boolean),
34
+ );
35
+ }
36
+
37
+ this.setOptions();
38
+ this.registerKeybindings([
39
+ {
40
+ key: Key.escape,
41
+ hint: "dismiss",
42
+ handler: () => {
43
+ if (this.amending) {
44
+ this.setAmending(false);
45
+ return;
46
+ }
47
+ this.onDone?.({ allowed: false });
48
+ this.cachedLines = undefined;
49
+ },
50
+ },
51
+ {
52
+ key: Key.shift("tab"),
53
+ hint: "cycle scope",
54
+ handler: () => {
55
+ this.scopeIdx = (this.scopeIdx + 1) % SCOPES.length;
56
+ this.setOptions();
57
+ this.cachedLines = undefined;
58
+ },
59
+ },
60
+ {
61
+ key: { navigation: "vertical" },
62
+ hint: "navigate",
63
+ navigate: (keyId) => {
64
+ if (keyId === Key.up) {
65
+ this.cursor = Math.max(0, this.cursor - 1);
66
+ } else {
67
+ this.cursor = Math.min(this.options.length - 1, this.cursor + 1);
68
+ }
69
+ this.setAmending(false);
70
+ this.cachedLines = undefined;
71
+ },
72
+ },
73
+ {
74
+ key: Key.tab,
75
+ hint: "amend",
76
+ handler: () => {
77
+ if (this.amending) return;
78
+ const option = this.options[this.cursor];
79
+ if (option) {
80
+ this.input.setValue(option.defaultText ?? "");
81
+ this.setAmending(true);
82
+ }
83
+ this.cachedLines = undefined;
84
+ },
85
+ },
86
+ {
87
+ key: Key.enter,
88
+ handler: () => {
89
+ this.commitSelection();
90
+ this.cachedLines = undefined;
91
+ },
92
+ },
93
+ {
94
+ key: Key.backspace,
95
+ handler: () => {
96
+ if (this.input.getValue() !== "" || !this.amending) return;
97
+ this.setAmending(false);
98
+ },
99
+ },
100
+ ]);
101
+ }
102
+
103
+ get focused(): boolean {
104
+ return this._focused;
105
+ }
106
+
107
+ set focused(value: boolean) {
108
+ this._focused = value;
109
+ this.input.focused = value && this.amending;
110
+ }
111
+
112
+ override invalidate() {
113
+ this.cachedLines = undefined;
114
+ super.invalidate();
115
+ this.input.invalidate();
116
+ }
117
+
118
+ protected override children(width: number): string[] {
119
+ if (this.cachedLines) return this.cachedLines;
120
+
121
+ const lines = new Lines(width);
122
+ const { toolName, extracted, uncertainty } = this.check;
123
+ const uncertaintyNote = uncertainty ? `${uncertainty} detected. ` : "";
124
+
125
+ lines.add(this.theme.italic(`${uncertaintyNote}Allow agent to use '${toolName}' tool?`));
126
+ for (const line of wrapTextWithAnsi(this.check.purpose, width)) {
127
+ lines.add(this.theme.fg("muted", line));
128
+ }
129
+
130
+ lines.space();
131
+ this.addRawLines(lines, extracted.join(", "), width);
132
+ lines.space();
133
+
134
+ for (const [i, option] of this.options.entries()) {
135
+ const isSelected = i === this.cursor;
136
+ const label = `${isSelected ? "→" : " "} ${i + 1}. ${option.label}`;
137
+
138
+ if (isSelected && this.amending) {
139
+ const fullLabel = this.theme.fg("accent", `${label}${option.separator}`);
140
+ const inputWidth = width - visibleWidth(fullLabel);
141
+ const inputLine = this.input.render(Math.max(4, inputWidth))[0]!.slice(1);
142
+ lines.add(fullLabel + inputLine);
143
+ } else {
144
+ const suffix = option.defaultText ? `${option.separator} ${option.defaultText}` : "";
145
+ lines.add(this.theme.fg(isSelected ? "accent" : "text", label + suffix));
146
+ }
147
+ }
148
+
149
+ if (this.inputError) {
150
+ lines.space();
151
+ lines.add(this.theme.fg("warning", this.inputError));
152
+ }
153
+
154
+ this.cachedLines = lines.get();
155
+ return this.cachedLines;
156
+ }
157
+
158
+ handleInput(data: string) {
159
+ if (this.handleKb(data) || !this.amending) return;
160
+ this.cachedLines = undefined;
161
+ this.inputError = undefined;
162
+ this.input.handleInput(data);
163
+ }
164
+
165
+ private addRawLines(lines: Lines, raw: string, width: number) {
166
+ const normalized = raw.replace(/\r\n?/g, "\n");
167
+ const truncated = normalized.length > 100 ? `${normalized.slice(0, 100)}…` : normalized;
168
+
169
+ for (const line of truncated.split("\n")) {
170
+ if (!line) {
171
+ lines.space();
172
+ continue;
173
+ }
174
+ for (const wrapped of wrapTextWithAnsi(this.theme.bold(line), width)) {
175
+ lines.add(wrapped);
176
+ }
177
+ }
178
+ }
179
+
180
+ private setAmending(value: boolean) {
181
+ this.amending = value;
182
+ this.inputError = undefined;
183
+ this.input.focused = this._focused && value;
184
+ this.setKeyAccess(Key.backspace, !value);
185
+ if (!value) this.input.setValue("");
186
+ }
187
+
188
+ private commitSelection() {
189
+ const option = this.options[this.cursor];
190
+ if (!option) return;
191
+
192
+ const inputText = this.amending ? this.input.getValue().trim() : "";
193
+ if (option.persists) {
194
+ let patterns = this.patterns;
195
+ if (inputText) {
196
+ try {
197
+ const parsed: unknown[] = JSON.parse(`[${inputText}]`);
198
+ patterns = unique(
199
+ parsed
200
+ .map((pattern) => (typeof pattern === "string" ? pattern.trim() : ""))
201
+ .filter(Boolean),
202
+ );
203
+ if (parsed.length === 0) throw new Error();
204
+ } catch {
205
+ this.inputError = "Enter non-empty patterns in double quotes, separated by commas";
206
+ return;
207
+ }
208
+ }
209
+
210
+ const rules = mapToRules(patterns, this.check.category, option.value.allowed);
211
+ this.setAmending(false);
212
+ void addRules(
213
+ this.cwd,
214
+ this.check.sessionId,
215
+ SCOPES[this.scopeIdx]!,
216
+ this.check.category,
217
+ rules,
218
+ );
219
+ this.onDone?.({ allowed: option.value.allowed });
220
+ return;
221
+ }
222
+
223
+ const decision: PromptDecision = { ...option.value };
224
+ if (inputText) decision.amended = inputText;
225
+ this.onDone?.(decision);
226
+ }
227
+
228
+ private setOptions() {
229
+ const { toolName } = this.check;
230
+ const scopeLabel = getScopeLabel(SCOPES[this.scopeIdx]!);
231
+ this.options = [
232
+ { label: "Yes", separator: ",", value: { allowed: true }, persists: false },
233
+ { label: "No", separator: ",", value: { allowed: false }, persists: false },
234
+ ];
235
+ if (this.check.uncertainty) return;
236
+
237
+ const defaultText = this.patterns.map((pattern) => JSON.stringify(pattern)).join(", ");
238
+ this.options.push(
239
+ {
240
+ label: `Yes, allow ${toolName} tool call ${scopeLabel} for`,
241
+ defaultText,
242
+ separator: ":",
243
+ value: { allowed: true },
244
+ persists: true,
245
+ },
246
+ {
247
+ label: `No, disallow ${toolName} tool call ${scopeLabel} for`,
248
+ defaultText,
249
+ separator: ":",
250
+ value: { allowed: false },
251
+ persists: true,
252
+ },
253
+ );
254
+ }
255
+ }