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,281 @@
1
+ import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings, matchesKey, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
+
4
+ export type EditorMode = "prompt" | "bash-included" | "bash-excluded";
5
+
6
+ export type ParsedText = {
7
+ mode: EditorMode;
8
+ displayText: string;
9
+ };
10
+
11
+ export class BashModeEditor extends CustomEditor {
12
+ protected mode: EditorMode = "prompt";
13
+ private persistent = false;
14
+ private suppressWrappedChange = false;
15
+ private externalOnSubmit: ((text: string) => void) | undefined;
16
+ private externalOnChange: ((text: string) => void) | undefined;
17
+
18
+ constructor(tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) {
19
+ super(tui, editorTheme, keybindings);
20
+
21
+ this.installWrappedCallbacks();
22
+ const parsed = this.parseActualText(super.getText());
23
+ this.mode = parsed.mode;
24
+
25
+ if (parsed.displayText !== super.getText()) {
26
+ this.withSuppressedChange(() => super.setText(parsed.displayText));
27
+ }
28
+ }
29
+
30
+ protected getMode(): EditorMode {
31
+ return this.mode;
32
+ }
33
+
34
+ protected isInBashMode(): boolean {
35
+ return this.isBashMode(this.mode);
36
+ }
37
+
38
+ private getNextMode(): EditorMode {
39
+ switch (this.mode) {
40
+ case "prompt":
41
+ return "bash-included";
42
+ case "bash-included":
43
+ return "bash-excluded";
44
+ case "bash-excluded":
45
+ return "prompt";
46
+ }
47
+ }
48
+
49
+ private isBashMode(mode: EditorMode): boolean {
50
+ return mode === "bash-included" || mode === "bash-excluded";
51
+ }
52
+
53
+ private parseActualText(text: string): ParsedText {
54
+ const excludedMatch = text.match(/^(\s*)!!(.*)$/s);
55
+ if (excludedMatch) {
56
+ return {
57
+ mode: "bash-excluded",
58
+ displayText: `${excludedMatch[1] ?? ""}${excludedMatch[2] ?? ""}`,
59
+ };
60
+ }
61
+
62
+ const includedMatch = text.match(/^(\s*)!(.*)$/s);
63
+ if (includedMatch) {
64
+ return {
65
+ mode: "bash-included",
66
+ displayText: `${includedMatch[1] ?? ""}${includedMatch[2] ?? ""}`,
67
+ };
68
+ }
69
+
70
+ return { mode: "prompt", displayText: text };
71
+ }
72
+
73
+ private toActualText(text: string, mode: EditorMode): string {
74
+ if (!this.isBashMode(mode)) {
75
+ return text;
76
+ }
77
+
78
+ const match = text.match(/^(\s*)(.*)$/s);
79
+ const bangPrefix = mode === "bash-excluded" ? "!!" : "!";
80
+ return `${match?.[1] ?? ""}${bangPrefix}${match?.[2] ?? ""}`;
81
+ }
82
+
83
+ private installWrappedCallbacks() {
84
+ Object.defineProperty(this, "onSubmit", {
85
+ configurable: true,
86
+ enumerable: true,
87
+ get: () => (this.externalOnSubmit ? this.submitProxy : undefined),
88
+ set: (value: ((text: string) => void) | undefined) => {
89
+ this.externalOnSubmit = value;
90
+ },
91
+ });
92
+
93
+ Object.defineProperty(this, "onChange", {
94
+ configurable: true,
95
+ enumerable: true,
96
+ get: () => (this.externalOnChange ? this.changeProxy : undefined),
97
+ set: (value: ((text: string) => void) | undefined) => {
98
+ this.externalOnChange = value;
99
+ },
100
+ });
101
+ }
102
+
103
+ private readonly submitProxy = (text: string) => {
104
+ const parsed = this.parseActualText(text);
105
+ const actualText = this.isBashMode(parsed.mode) ? text : this.toActualText(text, this.mode);
106
+ this.externalOnSubmit?.(actualText);
107
+ };
108
+
109
+ private readonly changeProxy = (text: string) => {
110
+ if (this.suppressWrappedChange) return;
111
+ this.externalOnChange?.(this.toActualText(text, this.mode));
112
+ };
113
+
114
+ private withSuppressedChange(fn: () => void) {
115
+ this.suppressWrappedChange = true;
116
+ try {
117
+ fn();
118
+ } finally {
119
+ this.suppressWrappedChange = false;
120
+ }
121
+ }
122
+
123
+ private emitChange() {
124
+ this.externalOnChange?.(this.toActualText(super.getText(), this.mode));
125
+ }
126
+
127
+ private isHistoryNavInput(data: string): boolean {
128
+ const keybindings = getKeybindings();
129
+ return (
130
+ keybindings.matches(data, "tui.editor.cursorUp") ||
131
+ keybindings.matches(data, "tui.editor.cursorDown")
132
+ );
133
+ }
134
+
135
+ private setDisplayText(displayText: string) {
136
+ const editorInternals = this as unknown as { setTextInternal: (text: string) => void };
137
+ editorInternals.setTextInternal(displayText);
138
+ }
139
+
140
+ private handleHistoryNavInput(data: string) {
141
+ const text = this.toActualText(super.getText(), this.mode);
142
+ const rawBeforeNav = super.getText();
143
+
144
+ this.withSuppressedChange(() => super.handleInput(data));
145
+
146
+ const rawAfterNav = super.getText();
147
+ if (rawAfterNav === rawBeforeNav) return;
148
+
149
+ const parsedHistoryText = this.parseActualText(rawAfterNav);
150
+ this.mode = parsedHistoryText.mode;
151
+ this.persistent = false;
152
+
153
+ if (parsedHistoryText.displayText !== rawAfterNav) {
154
+ this.withSuppressedChange(() => this.setDisplayText(parsedHistoryText.displayText));
155
+ }
156
+
157
+ const actual = this.toActualText(super.getText(), this.mode);
158
+ if (actual !== text) {
159
+ this.externalOnChange?.(actual);
160
+ }
161
+ }
162
+
163
+ private applyMode(mode: EditorMode, persistent = this.persistent) {
164
+ this.mode = mode;
165
+ this.persistent = persistent;
166
+ this.emitChange();
167
+ }
168
+
169
+ private shouldHandleSubmit(data: string): boolean {
170
+ const keybindings = getKeybindings();
171
+
172
+ if (
173
+ this.disableSubmit ||
174
+ this.isShowingAutocomplete() ||
175
+ keybindings.matches(data, "tui.input.newLine") ||
176
+ data === "\n"
177
+ ) {
178
+ return false;
179
+ }
180
+ if (!keybindings.matches(data, "tui.input.submit")) {
181
+ return false;
182
+ }
183
+
184
+ const cursor = super.getCursor();
185
+ const currentLine = super.getLines()[cursor.line] ?? "";
186
+ return !(cursor.col > 0 && currentLine[cursor.col - 1] === "\\");
187
+ }
188
+
189
+ private submitCurrentValue() {
190
+ const actualText = this.toActualText(super.getExpandedText(), this.mode).trim();
191
+ this.withSuppressedChange(() => super.setText(""));
192
+
193
+ if (!this.persistent) {
194
+ this.mode = "prompt";
195
+ }
196
+
197
+ this.emitChange();
198
+ this.externalOnSubmit?.(actualText);
199
+ }
200
+
201
+ cycleMode() {
202
+ if (this.mode !== "prompt" && !this.persistent) return;
203
+ const nextMode = this.getNextMode();
204
+ this.applyMode(nextMode, nextMode !== "prompt");
205
+ }
206
+
207
+ override handleInput(data: string) {
208
+ if (this.isHistoryNavInput(data)) {
209
+ this.handleHistoryNavInput(data);
210
+ return;
211
+ }
212
+
213
+ const displayText = super.getText();
214
+
215
+ if (!this.persistent && this.mode === "prompt" && data === "!" && /^\s*$/.test(displayText)) {
216
+ this.applyMode("bash-included", false);
217
+ return;
218
+ }
219
+ if (
220
+ !this.persistent &&
221
+ this.mode === "bash-included" &&
222
+ data === "!" &&
223
+ /^\s*$/.test(displayText)
224
+ ) {
225
+ this.applyMode("bash-excluded", false);
226
+ return;
227
+ }
228
+ if (
229
+ !this.persistent &&
230
+ this.mode === "bash-excluded" &&
231
+ displayText === "" &&
232
+ matchesKey(data, "backspace")
233
+ ) {
234
+ this.applyMode("bash-included", false);
235
+ return;
236
+ }
237
+ if (
238
+ !this.persistent &&
239
+ this.mode === "bash-included" &&
240
+ displayText === "" &&
241
+ matchesKey(data, "backspace")
242
+ ) {
243
+ this.applyMode("prompt", false);
244
+ return;
245
+ }
246
+ if (this.shouldHandleSubmit(data)) {
247
+ this.submitCurrentValue();
248
+ return;
249
+ }
250
+
251
+ super.handleInput(data);
252
+ }
253
+
254
+ override getText(): string {
255
+ return this.toActualText(super.getText(), this.mode);
256
+ }
257
+
258
+ override getExpandedText(): string {
259
+ return this.toActualText(super.getExpandedText(), this.mode);
260
+ }
261
+
262
+ override setText(text: string) {
263
+ const parsed = this.parseActualText(text);
264
+ this.mode = parsed.mode;
265
+ this.persistent = false;
266
+ super.setText(parsed.displayText);
267
+ }
268
+
269
+ override insertTextAtCursor(text: string) {
270
+ const parsed = this.parseActualText(text);
271
+
272
+ if (this.mode === "prompt" && this.isBashMode(parsed.mode) && /^\s*$/.test(super.getText())) {
273
+ this.mode = parsed.mode;
274
+ this.persistent = false;
275
+ super.insertTextAtCursor(parsed.displayText);
276
+ return;
277
+ }
278
+
279
+ super.insertTextAtCursor(text);
280
+ }
281
+ }
@@ -0,0 +1,166 @@
1
+ import { getSelectListTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, SelectList, type KeyId, type SelectItem } from "@earendil-works/pi-tui";
3
+ import { Frame } from "./frame.js";
4
+ import { Lines } from "./lines.js";
5
+
6
+ export type SelectEntry<TData = unknown> = SelectItem & {
7
+ data?: TData;
8
+ };
9
+
10
+ type Options<TData = unknown> = {
11
+ title: string;
12
+ items: SelectEntry<TData>[];
13
+ addLabel?: string;
14
+ maxVisibleRows?: number;
15
+ canDelete?: (item: SelectEntry<TData>) => boolean;
16
+ };
17
+
18
+ export class ExtendedSelectList<TData = unknown> extends Frame {
19
+ onAdd?: () => void;
20
+ onSelect?: (item: SelectEntry<TData>) => void;
21
+ onDelete?: (item: SelectEntry<TData>) => void;
22
+ onDeleteBlocked?: (item: SelectEntry<TData>) => void;
23
+ onCancel?: () => void;
24
+
25
+ private readonly title: string;
26
+ private readonly items: [SelectItem, ...SelectEntry<TData>[]] | SelectEntry<TData>[];
27
+ private readonly canDeleteItem: (item: SelectEntry<TData>) => boolean;
28
+
29
+ private selectList: SelectList;
30
+ private deleteArmed = false;
31
+
32
+ constructor(theme: Theme, options: Options<TData>) {
33
+ super(theme);
34
+
35
+ this.title = options.title;
36
+ this.items = options.items;
37
+ if (options.addLabel) this.items.unshift({ value: "__add__", label: `[${options.addLabel}]` });
38
+ this.canDeleteItem = options.canDelete ?? (() => true);
39
+ this.selectList = new SelectList(this.items, options.maxVisibleRows ?? 7, getSelectListTheme());
40
+
41
+ this.registerKeybindings([
42
+ {
43
+ key: "esc",
44
+ hint: "cancel",
45
+ handler: () => {
46
+ this.deleteArmed = false;
47
+ this.onCancel?.();
48
+ },
49
+ },
50
+ { key: "enter", hint: "select", handler: (data) => this.confirmSelection(data) },
51
+ { key: Key.ctrl("d"), hint: "delete", handler: () => this.armDeletion() },
52
+ ]);
53
+ }
54
+
55
+ override invalidate() {
56
+ super.invalidate();
57
+ this.selectList.invalidate();
58
+ }
59
+
60
+ handleInput(data: string) {
61
+ if (this.handleKb(data)) return;
62
+ if (this.deleteArmed) {
63
+ this.deleteArmed = false;
64
+ }
65
+ this.selectList.handleInput(data);
66
+ }
67
+
68
+ override get hints(): [string, string][] {
69
+ return [["↑↓", "navigate"], ...super.hints];
70
+ }
71
+
72
+ protected override children(width: number): string[] {
73
+ const lines = new Lines(width);
74
+
75
+ lines.add(this.theme.bold(this.title));
76
+ lines.space();
77
+
78
+ for (const renderedLine of this.selectList.render(width)) {
79
+ lines.add(renderedLine);
80
+ }
81
+
82
+ if (this.deleteArmed) {
83
+ lines.space();
84
+ lines.add(this.theme.fg("warning", "Delete selected item: press Enter to confirm"));
85
+ }
86
+
87
+ return lines.get();
88
+ }
89
+
90
+ private confirmSelection(data: string) {
91
+ if (data === "\n") return;
92
+ const selected = this.selectList.getSelectedItem() as SelectEntry<TData>;
93
+ if (!selected) return;
94
+
95
+ if (this.deleteArmed) {
96
+ this.confirmDeletion(selected);
97
+ return;
98
+ }
99
+
100
+ this.deleteArmed = false;
101
+ if (this.isAddRow(selected)) {
102
+ this.onAdd?.();
103
+ return;
104
+ }
105
+
106
+ this.onSelect?.(selected);
107
+ }
108
+
109
+ private armDeletion() {
110
+ const selected = this.selectList.getSelectedItem() as SelectEntry<TData>;
111
+ if (!selected || this.isAddRow(selected)) return;
112
+ this.setHint(Key.enter, "confirm delete");
113
+
114
+ if (!this.canDeleteItem(selected)) {
115
+ this.onDeleteBlocked?.(selected);
116
+ return;
117
+ }
118
+
119
+ this.deleteArmed = true;
120
+ }
121
+
122
+ private confirmDeletion(selected: SelectEntry<TData>) {
123
+ if (this.isAddRow(selected)) {
124
+ this.deleteArmed = false;
125
+ return;
126
+ }
127
+ if (!this.canDeleteItem(selected)) {
128
+ this.deleteArmed = false;
129
+ this.onDeleteBlocked?.(selected);
130
+ return;
131
+ }
132
+
133
+ const selectedItemIndex = this.items.findIndex((item) => item === selected);
134
+ if (selectedItemIndex < 0) {
135
+ this.deleteArmed = false;
136
+ return;
137
+ }
138
+
139
+ const deletedItem = this.items[selectedItemIndex] as SelectEntry<TData>;
140
+ if (!deletedItem) {
141
+ this.deleteArmed = false;
142
+ return;
143
+ }
144
+
145
+ this.items.splice(selectedItemIndex, 1);
146
+ this.deleteArmed = false;
147
+ this.onDelete?.(deletedItem);
148
+ }
149
+
150
+ private isAddRow(item: SelectEntry<TData>): boolean {
151
+ return item.value === "__add__";
152
+ }
153
+
154
+ extendKb(key: KeyId, customHandler: (selected: SelectEntry<TData>) => void, hint?: string) {
155
+ this.registerKeybindings([
156
+ {
157
+ key,
158
+ handler: () => {
159
+ const selected = this.selectList.getSelectedItem() as SelectEntry<TData>;
160
+ customHandler(selected);
161
+ },
162
+ hint,
163
+ },
164
+ ]);
165
+ }
166
+ }
@@ -0,0 +1,184 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, type Focusable, type TUI } from "@earendil-works/pi-tui";
3
+ import { PlaceholderInput } from "./placeholder-input.js";
4
+
5
+ export type FormFieldMode<TValue> =
6
+ | { type: "input"; placeholder: string; text?: string; startEditing?: boolean }
7
+ | { type: "toggle"; values: readonly TValue[]; initialIndex?: number };
8
+
9
+ export type Field<TValue> = {
10
+ key: string;
11
+ label?: string;
12
+ mode: FormFieldMode<TValue>;
13
+ labelWidth?: number;
14
+ };
15
+
16
+ export class FormField<TValue = string> implements Focusable {
17
+ onInputSubmit?: (value: string) => boolean | void;
18
+ onInputCancel?: () => void;
19
+ onToggle?: (value: TValue, index: number) => void;
20
+
21
+ private readonly labelWidth: number;
22
+ private readonly mode: FormFieldMode<TValue>;
23
+ private readonly input?: PlaceholderInput;
24
+
25
+ private label: string;
26
+ private inputText = "";
27
+ private toggleIndex = 0;
28
+
29
+ private _focused = false;
30
+ private _highlighted = false;
31
+ private _editing = false;
32
+
33
+ constructor(
34
+ tui: TUI,
35
+ keybindings: KeybindingsManager,
36
+ private readonly theme: Theme,
37
+ field: Field<TValue>,
38
+ ) {
39
+ this.mode = field.mode;
40
+ this.label = field.label ?? field.key;
41
+ this.labelWidth = field.labelWidth ?? 40;
42
+
43
+ if (this.mode.type === "input") {
44
+ this.input = new PlaceholderInput(tui, keybindings, theme, this.mode.placeholder);
45
+ this.inputText = this.mode.text ?? "";
46
+ this._editing = this.mode.startEditing ?? false;
47
+ if (this._editing) {
48
+ this.input.setText(this.inputText);
49
+ }
50
+ this.syncInputFocus();
51
+ return;
52
+ }
53
+
54
+ if (this.mode.values.length === 0) {
55
+ throw new Error("FormField toggle mode requires at least one value.");
56
+ }
57
+
58
+ const initialIndex = this.mode.initialIndex ?? 0;
59
+ this.toggleIndex = Math.max(0, Math.min(this.mode.values.length - 1, initialIndex));
60
+ }
61
+
62
+ get focused(): boolean {
63
+ return this._focused;
64
+ }
65
+
66
+ set focused(value: boolean) {
67
+ this._focused = value;
68
+ this.syncInputFocus();
69
+ }
70
+
71
+ get editing(): boolean {
72
+ return this._editing;
73
+ }
74
+
75
+ get modeType(): "input" | "toggle" {
76
+ return this.mode.type;
77
+ }
78
+
79
+ set highlighted(value: boolean) {
80
+ this._highlighted = value && !this._editing;
81
+ }
82
+
83
+ setLabel(label: string) {
84
+ this.label = label;
85
+ }
86
+
87
+ setText(value: string) {
88
+ this.inputText = value;
89
+ if (!this._editing) return;
90
+ this.input?.setText(value);
91
+ }
92
+
93
+ getText(): string {
94
+ if (this.mode.type === "input") return this.inputText;
95
+ const currentValue = this.mode.values[this.toggleIndex]!;
96
+ return String(currentValue);
97
+ }
98
+
99
+ startEditing() {
100
+ if (this.mode.type !== "input") return;
101
+ this._editing = true;
102
+ this.input?.setText(this.inputText);
103
+ this.syncInputFocus();
104
+ }
105
+
106
+ invalidate() {
107
+ this.input?.invalidate();
108
+ }
109
+
110
+ render(width: number): string[] {
111
+ const label = this.label + " ".repeat(this.labelWidth - this.label.length);
112
+ const prefix = truncateToWidth(this.theme.fg("dim", label), this.labelWidth);
113
+
114
+ if (this.mode.type === "input") {
115
+ if (this._editing) {
116
+ const inputLine = this.input!.render(Math.max(1, width - this.labelWidth))[0];
117
+ return [truncateToWidth(prefix + inputLine, width)];
118
+ }
119
+
120
+ const input = this.inputText
121
+ ? this.theme.fg(this._highlighted ? "accent" : "text", this.inputText)
122
+ : this.theme.fg(this._highlighted ? "muted" : "dim", this.mode.placeholder);
123
+ return [truncateToWidth(prefix + input, width)];
124
+ }
125
+
126
+ const currentValue = this.mode.values[this.toggleIndex]!;
127
+ const valueLabel = String(currentValue);
128
+ const color = this._highlighted ? "accent" : "text";
129
+ return [truncateToWidth(prefix + this.theme.fg(color, valueLabel), width)];
130
+ }
131
+
132
+ handleInput(data: string) {
133
+ if (this.mode.type === "toggle") {
134
+ if (matchesKey(data, Key.enter)) {
135
+ this.toggleIndex = (this.toggleIndex + 1) % this.mode.values.length;
136
+ const currentValue = this.mode.values[this.toggleIndex]!;
137
+ this.onToggle?.(currentValue, this.toggleIndex);
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (!this._editing) return;
143
+
144
+ if (matchesKey(data, Key.escape)) {
145
+ this.stopEditing();
146
+ this.onInputCancel?.();
147
+ return;
148
+ }
149
+
150
+ if (matchesKey(data, Key.enter)) {
151
+ const value = this.input?.getText() ?? "";
152
+ const hasSubmitHandler = this.onInputSubmit !== undefined;
153
+ const submitResult = this.onInputSubmit?.(value);
154
+ if (submitResult === false) return;
155
+
156
+ if (!hasSubmitHandler) {
157
+ this.inputText = value;
158
+ }
159
+ this.stopEditing();
160
+ return;
161
+ }
162
+
163
+ this.input?.handleInput(data);
164
+ }
165
+
166
+ getToggleValue(): TValue | undefined {
167
+ if (this.mode.type !== "toggle") return;
168
+ return this.mode.values[this.toggleIndex];
169
+ }
170
+
171
+ private stopEditing() {
172
+ this._editing = false;
173
+ if (this.mode.type === "input") {
174
+ this.input?.setText(this.inputText);
175
+ }
176
+ this.syncInputFocus();
177
+ }
178
+
179
+ private syncInputFocus() {
180
+ if (this.mode.type !== "input") return;
181
+ if (!this.input) return;
182
+ this.input.focused = this._focused && this._editing;
183
+ }
184
+ }