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,179 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, type Focusable, type TUI } from "@earendil-works/pi-tui";
3
+ import { FormField, type Field } from "./form-field.js";
4
+ import { Frame } from "./frame.js";
5
+ import { Lines } from "./lines.js";
6
+
7
+ export type FormConfig<TSubmit = Record<string, string>> = {
8
+ title: string;
9
+ fields: Field<string | number | boolean>[];
10
+ emptyMessage?: string;
11
+ parseOnSave?: (values: Record<string, string>) => TSubmit;
12
+ };
13
+
14
+ export class Form<TSubmit = Record<string, string>> extends Frame implements Focusable {
15
+ onCancel?: () => void;
16
+ onSave?: (values: TSubmit) => Promise<void> | void;
17
+ onSaveError?: (error: unknown) => void;
18
+
19
+ private readonly fields: FormField<string | number | boolean>[];
20
+ private readonly keys: string[];
21
+ private readonly title: string;
22
+ private readonly emptyMessage: string;
23
+ private readonly parseOnSave?: (values: Record<string, string>) => TSubmit;
24
+
25
+ private cursor = 0;
26
+ private editing = false;
27
+ private saving = false;
28
+ private _focused = false;
29
+
30
+ constructor(
31
+ tui: TUI,
32
+ keybindings: KeybindingsManager,
33
+ theme: Theme,
34
+ config: FormConfig<TSubmit>,
35
+ ) {
36
+ super(theme);
37
+
38
+ this.title = config.title;
39
+ this.emptyMessage = config.emptyMessage ?? "No fields available for editing.";
40
+ this.parseOnSave = config.parseOnSave;
41
+ this.keys = config.fields.map((field) => field.key);
42
+ this.fields = config.fields.map((field) => {
43
+ const option = new FormField<string | number | boolean>(tui, keybindings, theme, field);
44
+
45
+ option.onInputSubmit = (inputValue) => {
46
+ option.setText(inputValue);
47
+ this.editing = false;
48
+ this.syncFocus();
49
+ return true;
50
+ };
51
+
52
+ option.onInputCancel = () => {
53
+ this.editing = false;
54
+ this.syncFocus();
55
+ };
56
+
57
+ return option;
58
+ });
59
+
60
+ this.registerKeybindings([
61
+ { key: Key.escape, handler: () => this.onCancel?.() },
62
+ { key: Key.ctrl("s"), hint: "save", handler: () => void this.save() },
63
+ {
64
+ key: { navigation: "vertical" },
65
+ hint: "navigate",
66
+ navigate: (keyId) => {
67
+ if (this.editing) return;
68
+ if (keyId === Key.up) {
69
+ this.cursor = Math.max(0, this.cursor - 1);
70
+ } else {
71
+ this.cursor = Math.min(this.fields.length - 1, this.cursor + 1);
72
+ }
73
+ this.syncFocus();
74
+ },
75
+ },
76
+ {
77
+ key: Key.enter,
78
+ handler: () => {
79
+ const selected = this.fields[this.cursor];
80
+ if (!selected || this.editing) return;
81
+ if (selected.modeType === "toggle") {
82
+ selected.handleInput(Key.enter);
83
+ return;
84
+ }
85
+
86
+ this.editing = true;
87
+ selected.startEditing();
88
+ this.syncFocus();
89
+ },
90
+ },
91
+ ]);
92
+
93
+ this.syncFocus();
94
+ }
95
+
96
+ get focused(): boolean {
97
+ return this._focused;
98
+ }
99
+
100
+ set focused(value: boolean) {
101
+ this._focused = value;
102
+ this.syncFocus();
103
+ }
104
+
105
+ override invalidate() {
106
+ super.invalidate();
107
+ for (const field of this.fields) {
108
+ field.invalidate();
109
+ }
110
+ }
111
+
112
+ protected override children(width: number): string[] {
113
+ const lines = new Lines(width);
114
+ lines.add(this.theme.bold(this.title));
115
+ lines.space();
116
+
117
+ if (this.fields.length === 0) {
118
+ lines.add(this.theme.fg("warning", this.emptyMessage));
119
+ return lines.get();
120
+ }
121
+
122
+ for (let index = 0; index < this.fields.length; index++) {
123
+ const field = this.fields[index]!;
124
+ const isSelected = index === this.cursor;
125
+ field.highlighted = isSelected;
126
+
127
+ const marker = isSelected ? this.theme.fg("accent", "→") : " ";
128
+ const rendered = field.render(width - 2);
129
+ lines.add(`${marker} ${rendered[0] ?? ""}`);
130
+ }
131
+
132
+ return lines.get();
133
+ }
134
+
135
+ handleInput(data: string) {
136
+ const selected = this.fields[this.cursor];
137
+ if (this.editing && selected?.modeType === "input") {
138
+ selected.handleInput(data);
139
+ return;
140
+ }
141
+ this.handleKb(data);
142
+ }
143
+
144
+ private syncFocus() {
145
+ this.setHint(Key.enter, this.editing ? "finish editing" : "edit/toggle");
146
+ this.setHint(Key.escape, this.editing ? "cancel editing" : "cancel");
147
+
148
+ for (let index = 0; index < this.fields.length; index++) {
149
+ const field = this.fields[index]!;
150
+ field.focused = this._focused && this.editing && index === this.cursor;
151
+ }
152
+ }
153
+
154
+ private serializeValues(): Record<string, string> {
155
+ const values: Record<string, string> = {};
156
+ for (let index = 0; index < this.fields.length; index++) {
157
+ const field = this.fields[index]!;
158
+ const key = this.keys[index];
159
+ if (key) {
160
+ values[key] = field.getText();
161
+ }
162
+ }
163
+ return values;
164
+ }
165
+
166
+ private async save() {
167
+ if (this.saving) return;
168
+ this.saving = true;
169
+ try {
170
+ const serialized = this.serializeValues();
171
+ const submitValues = this.parseOnSave?.(serialized) ?? (serialized as TSubmit);
172
+ await this.onSave?.(submitValues);
173
+ } catch (error) {
174
+ this.onSaveError?.(error);
175
+ } finally {
176
+ this.saving = false;
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,60 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { Keybound } from "./keybound.js";
4
+ import { Lines } from "./lines.js";
5
+
6
+ export class Frame extends Keybound implements Component {
7
+ private readonly border: DynamicBorder;
8
+ private readonly custom: Component[] = [];
9
+
10
+ protected children(_width: number): string[] {
11
+ return [];
12
+ }
13
+
14
+ constructor(protected theme: Theme) {
15
+ super();
16
+ this.border = new DynamicBorder((segment) => theme.fg("accent", segment));
17
+ }
18
+
19
+ addCustom(child: Component) {
20
+ this.custom.push(child);
21
+ }
22
+
23
+ invalidate() {
24
+ this.border.invalidate();
25
+ }
26
+
27
+ render(width: number): string[] {
28
+ const lines = new Lines(width);
29
+ lines.add(this.border.render(width)[0]!);
30
+ lines.space();
31
+
32
+ if (!this.custom.length) {
33
+ for (const child of this.children(width - 1)) {
34
+ lines.add(child, 1);
35
+ }
36
+ } else {
37
+ for (const item of this.custom) {
38
+ for (const line of item.render(width - 1)) {
39
+ lines.add(line, 1);
40
+ }
41
+ }
42
+ }
43
+
44
+ if (this.hints.length > 0) {
45
+ lines.space();
46
+ lines.add(this.renderHints(), 1);
47
+ }
48
+
49
+ lines.space();
50
+ lines.add(this.border.render(width)[0]!);
51
+ return lines.get();
52
+ }
53
+
54
+ private renderHints(): string {
55
+ return this.hints
56
+ .filter(([_key, action]) => Boolean(action))
57
+ .map(([key, action]) => this.theme.fg("dim", key) + " " + action)
58
+ .join(this.theme.fg("muted", " • "));
59
+ }
60
+ }
@@ -0,0 +1,64 @@
1
+ import { type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
+ import { BashModeEditor } from "./bash-mode.js";
4
+
5
+ export default class ModeIndicatorEditor extends BashModeEditor {
6
+ private actualPaddingX = 0;
7
+
8
+ constructor(
9
+ tui: TUI,
10
+ editorTheme: EditorTheme,
11
+ keybindings: KeybindingsManager,
12
+ private readonly uiTheme: Theme,
13
+ ) {
14
+ super(tui, editorTheme, keybindings);
15
+ }
16
+
17
+ private getIndicator(): string {
18
+ if (this.isInBashMode()) {
19
+ return this.uiTheme.fg("warning", "!");
20
+ }
21
+ return this.uiTheme.fg("accent", "❯");
22
+ }
23
+
24
+ private applyModeBorderColor() {
25
+ if (this.getMode() === "bash-included") {
26
+ this.borderColor = (text) => this.uiTheme.fg("bashMode", text);
27
+ return;
28
+ }
29
+ if (this.getMode() === "bash-excluded") {
30
+ this.borderColor = (text) => this.uiTheme.fg("dim", text);
31
+ }
32
+ }
33
+
34
+ override render(width: number): string[] {
35
+ this.applyModeBorderColor();
36
+ const lines = super.render(width);
37
+ if (lines.length === 0) return lines;
38
+
39
+ const contentLineIndex = lines.length >= 3 ? 1 : 0;
40
+ const indicator = this.getIndicator();
41
+ const prefix = width <= 1 ? indicator : `${indicator} `;
42
+ const prefixWidth = visibleWidth(prefix);
43
+ const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
44
+ const totalPadding = Math.min(super.getPaddingX(), maxPadding);
45
+ const effectivePrefixWidth = Math.min(prefixWidth, totalPadding);
46
+ const effectiveUserPadding = Math.max(0, totalPadding - effectivePrefixWidth);
47
+ const renderedPrefix = truncateToWidth(prefix, effectivePrefixWidth, "");
48
+ const firstContentLine = lines[contentLineIndex] ?? "";
49
+
50
+ lines[contentLineIndex] =
51
+ " ".repeat(effectiveUserPadding) + renderedPrefix + firstContentLine.slice(totalPadding);
52
+
53
+ return lines;
54
+ }
55
+
56
+ override getPaddingX(): number {
57
+ return this.actualPaddingX;
58
+ }
59
+
60
+ override setPaddingX(padding: number) {
61
+ this.actualPaddingX = Math.max(0, Math.floor(padding));
62
+ super.setPaddingX(this.actualPaddingX + 2); // Indicator takes up width of 2
63
+ }
64
+ }
@@ -0,0 +1,150 @@
1
+ import { Key, matchesKey, type KeyId } from "@earendil-works/pi-tui";
2
+
3
+ type NavigationKey = {
4
+ navigation: "vertical" | "horizontal" | "page";
5
+ metakey?:
6
+ | typeof Key.ctrl
7
+ | typeof Key.shift
8
+ | typeof Key.alt
9
+ | typeof Key.super
10
+ | typeof Key.ctrlShift
11
+ | typeof Key.shiftCtrl
12
+ | typeof Key.ctrlAlt
13
+ | typeof Key.altCtrl
14
+ | typeof Key.shiftAlt
15
+ | typeof Key.altShift
16
+ | typeof Key.ctrlSuper
17
+ | typeof Key.superCtrl
18
+ | typeof Key.shiftSuper
19
+ | typeof Key.superShift
20
+ | typeof Key.altSuper
21
+ | typeof Key.superAlt
22
+ | typeof Key.ctrlShiftAlt
23
+ | typeof Key.ctrlShiftSuper;
24
+ };
25
+
26
+ type KeyboundBinding = {
27
+ handler: (data: string) => void;
28
+ hintIdx: number;
29
+ consumable: boolean;
30
+ hinted: boolean;
31
+ };
32
+
33
+ export type Keybindings = Array<
34
+ | { key: KeyId; handler: (data: string) => void; hint?: string }
35
+ | { key: NavigationKey; navigate: (data: string) => void; hint?: string }
36
+ >;
37
+
38
+ export class Keybound {
39
+ private bindings = new Map<KeyId, KeyboundBinding>();
40
+ private keyHints: [string, string][] = [];
41
+
42
+ private getKbConfig(key: string, hint?: string) {
43
+ const config = { hintIdx: this.keyHints.length, consumable: true, hinted: !!hint };
44
+ this.keyHints.push([key, hint ?? ""]);
45
+ return config;
46
+ }
47
+
48
+ private getArrowKeyIds(key: NavigationKey) {
49
+ const isVertical = key.navigation === "vertical";
50
+ const isHorizontal = key.navigation === "horizontal";
51
+ const firstNav = isVertical ? "up" : isHorizontal ? "left" : "pageUp";
52
+ const secondNav = isVertical ? "down" : isHorizontal ? "right" : "pageDown";
53
+ return [firstNav, secondNav] as [typeof firstNav, typeof secondNav];
54
+ }
55
+
56
+ private registerNormalKb(key: KeyId, handler: (data: string) => void, hint?: string) {
57
+ if (this.bindings.has(key)) {
58
+ throw new Error(`key already registered: ${key}`);
59
+ }
60
+ this.bindings.set(key, { handler, ...this.getKbConfig(key, hint) });
61
+ this.keyHints.push([key, hint ?? ""]);
62
+ }
63
+
64
+ private registerArrowKb(key: NavigationKey, navigate: (data: string) => void, hint?: string) {
65
+ const [firstNav, secondNav] = this.getArrowKeyIds(key);
66
+ const first = key.metakey ? key.metakey(Key[firstNav]) : Key[firstNav];
67
+ const second = key.metakey ? key.metakey(Key[secondNav]) : Key[secondNav];
68
+ const firstArrow = firstNav === "up" ? "↑" : firstNav === "left" ? "←" : "pgUp";
69
+ const secondArrow = secondNav === "down" ? "↓" : secondNav === "right" ? "→" : "pgDown";
70
+
71
+ const keyParts = first.split("+");
72
+ const metakey = keyParts.slice(0, -1).join("+");
73
+ const hintKey = metakey
74
+ ? `${metakey}+${firstArrow}/${secondArrow}`
75
+ : `${firstArrow}/${secondArrow}`;
76
+ const config = this.getKbConfig(hintKey, hint);
77
+
78
+ this.bindings.set(first, { handler: () => navigate(firstNav), ...config });
79
+ this.bindings.set(second, { handler: () => navigate(secondNav), ...config });
80
+ }
81
+
82
+ protected registerKeybindings(keybindings: Keybindings) {
83
+ for (const keybinding of keybindings) {
84
+ if ("handler" in keybinding) {
85
+ this.registerNormalKb(keybinding.key, keybinding.handler, keybinding.hint);
86
+ continue;
87
+ }
88
+ this.registerArrowKb(keybinding.key, keybinding.navigate, keybinding.hint);
89
+ }
90
+ }
91
+
92
+ protected setKeyAccess(key: KeyId, access: boolean | { consumable?: boolean; hinted?: boolean }) {
93
+ const binding = this.bindings.get(key);
94
+ if (!binding) {
95
+ throw new Error(`key not registered: ${key}`);
96
+ }
97
+ binding.consumable =
98
+ typeof access === "boolean" ? access : (access.consumable ?? binding.consumable);
99
+ binding.hinted = typeof access === "boolean" ? access : (access.hinted ?? binding.hinted);
100
+ }
101
+
102
+ protected setArrowKeyAccess(
103
+ key: NavigationKey,
104
+ access: boolean | { consumable?: boolean; hinted?: boolean },
105
+ ) {
106
+ const [firstNav, secondNav] = this.getArrowKeyIds(key);
107
+ const first = key.metakey ? key.metakey(Key[firstNav]) : Key[firstNav];
108
+ const second = key.metakey ? key.metakey(Key[secondNav]) : Key[secondNav];
109
+ this.setKeyAccess(first, access);
110
+ this.setKeyAccess(second, access);
111
+ }
112
+
113
+ protected setHint(key: KeyId, hint: string) {
114
+ const binding = this.bindings.get(key);
115
+ if (!binding) {
116
+ throw new Error(`key not registered: ${key}`);
117
+ }
118
+
119
+ const hintEntry = this.keyHints[binding.hintIdx];
120
+ if (!hintEntry) {
121
+ throw new Error(`hint index out of bounds for key: ${key}`);
122
+ }
123
+
124
+ this.keyHints[binding.hintIdx] = [hintEntry[0], hint];
125
+ }
126
+
127
+ protected handleKb(data: string): boolean {
128
+ for (const [keyId, binding] of this.bindings) {
129
+ if (!binding.consumable) continue;
130
+ if (matchesKey(data, keyId)) {
131
+ binding.handler(data);
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
138
+ protected get hints(): [string, string][] {
139
+ const visible: [string, string][] = [];
140
+ const visibleIndexes = new Set<number>();
141
+ for (const binding of this.bindings.values()) {
142
+ if (!binding.hinted || visibleIndexes.has(binding.hintIdx)) continue;
143
+ const hintEntry = this.keyHints[binding.hintIdx];
144
+ if (!hintEntry) continue;
145
+ visibleIndexes.add(binding.hintIdx);
146
+ visible.push(hintEntry);
147
+ }
148
+ return visible;
149
+ }
150
+ }
@@ -0,0 +1,27 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ /**
4
+ * Accumulates rendered lines for a fixed-width TUI component.
5
+ */
6
+ export class Lines {
7
+ private readonly lines: string[];
8
+
9
+ constructor(
10
+ private readonly width: number,
11
+ lines?: string[],
12
+ ) {
13
+ this.lines = lines ?? [];
14
+ }
15
+
16
+ add(line: string, pl = 0) {
17
+ this.lines.push(truncateToWidth(" ".repeat(pl) + line, this.width));
18
+ }
19
+
20
+ space() {
21
+ this.lines.push("");
22
+ }
23
+
24
+ get(): string[] {
25
+ return this.lines;
26
+ }
27
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ CustomEditor,
3
+ type KeybindingsManager,
4
+ type Theme,
5
+ type ThemeColor,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { CURSOR_MARKER, truncateToWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
8
+
9
+ export class PlaceholderInput extends CustomEditor {
10
+ constructor(
11
+ tui: TUI,
12
+ keybindings: KeybindingsManager,
13
+ private readonly uiTheme: Theme,
14
+ private readonly placeholder: string,
15
+ private readonly customBorderColor?: ThemeColor,
16
+ ) {
17
+ const editorTheme: EditorTheme = {
18
+ borderColor: (text) => uiTheme.fg(customBorderColor ?? "dim", text),
19
+ selectList: {
20
+ selectedPrefix: (text) => uiTheme.fg("accent", text),
21
+ selectedText: (text) => uiTheme.fg("accent", text),
22
+ description: (text) => uiTheme.fg("muted", text),
23
+ scrollInfo: (text) => uiTheme.fg("dim", text),
24
+ noMatch: (text) => uiTheme.fg("warning", text),
25
+ },
26
+ };
27
+ super(tui, editorTheme, keybindings);
28
+ }
29
+
30
+ override render(width: number): string[] {
31
+ const lines = super.render(width);
32
+
33
+ if (super.getText() === "") {
34
+ const placeholderLines = this.placeholder.split("\n").map((line, i) => {
35
+ if (i === 0 && this.focused) {
36
+ const firstChar = line.charAt(0) || " ";
37
+ const rest = line.slice(1);
38
+ // Visible cursor requires BOTH the marker (IME positioning) AND the
39
+ // reverse-video block — the TUI hides the hardware cursor by default.
40
+ const cursor = CURSOR_MARKER + `\x1b[7m${firstChar}\x1b[0m`;
41
+ const dimRest = rest.length > 0 ? this.uiTheme.fg("dim", rest) : "";
42
+ return truncateToWidth(cursor + dimRest, width);
43
+ }
44
+ return truncateToWidth(this.uiTheme.fg("dim", line), width);
45
+ });
46
+
47
+ if (this.customBorderColor) {
48
+ return [lines[0]!, ...placeholderLines, lines[lines.length - 1]!];
49
+ }
50
+ return placeholderLines;
51
+ }
52
+
53
+ const contentLines = lines.slice(1, lines.length - 1);
54
+ if (this.customBorderColor) {
55
+ return [lines[0]!, ...contentLines, lines[lines.length - 1]!];
56
+ }
57
+ return contentLines;
58
+ }
59
+ }
@@ -0,0 +1,78 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Focusable, Input, Key, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { Lines } from "./lines.js";
4
+ import { Frame } from "./frame.js";
5
+
6
+ export type ScopeItem = { label: string; value: string };
7
+ export type ScopedInputResult = { scope: string; value: string };
8
+
9
+ const DEFAULT_SCOPES: ScopeItem[] = [
10
+ { label: "project", value: "project" },
11
+ { label: "global", value: "global" },
12
+ ];
13
+
14
+ export class ScopedInput extends Frame implements Focusable {
15
+ private readonly input = new Input();
16
+ private readonly scopes: ScopeItem[];
17
+ private scopeIndex = 0;
18
+
19
+ onSubmit?: (result: ScopedInputResult) => void;
20
+ onCancel?: () => void;
21
+
22
+ get focused(): boolean {
23
+ return this.input.focused;
24
+ }
25
+
26
+ set focused(value: boolean) {
27
+ this.input.focused = value;
28
+ }
29
+
30
+ constructor(
31
+ theme: Theme,
32
+ private readonly title: string,
33
+ scopes: ScopeItem[] = DEFAULT_SCOPES,
34
+ ) {
35
+ super(theme);
36
+ this.scopes = scopes;
37
+ this.input.focused = true;
38
+
39
+ this.registerKeybindings([
40
+ {
41
+ key: Key.shift("tab"),
42
+ hint: "switch scope",
43
+ handler: () => (this.scopeIndex = (this.scopeIndex + 1) % this.scopes.length),
44
+ },
45
+ {
46
+ key: Key.enter,
47
+ hint: "create",
48
+ handler: () => {
49
+ const value = this.input.getValue().trim();
50
+ if (value) {
51
+ this.onSubmit?.({ scope: this.scopes[this.scopeIndex]!.value, value });
52
+ }
53
+ },
54
+ },
55
+ { key: Key.escape, hint: "cancel", handler: () => this.onCancel?.() },
56
+ ]);
57
+ }
58
+
59
+ protected override children(width: number): string[] {
60
+ const lines = new Lines(width - 1);
61
+ lines.add(this.theme.bold(this.title));
62
+ lines.space();
63
+ const prefix = this.theme.fg("muted", `(${this.scopes[this.scopeIndex]!.label}) `);
64
+ const inputLine = this.input.render(width - 1 - visibleWidth(prefix))[0]!.slice(2);
65
+ lines.add(prefix + inputLine);
66
+ return lines.get();
67
+ }
68
+
69
+ override invalidate() {
70
+ super.invalidate();
71
+ this.input.invalidate();
72
+ }
73
+
74
+ handleInput(data: string) {
75
+ if (this.handleKb(data)) return;
76
+ this.input.handleInput(data);
77
+ }
78
+ }