horizon-code 0.1.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.
- package/assets/python/highlights.scm +137 -0
- package/assets/python/tree-sitter-python.wasm +0 -0
- package/bin/horizon.js +2 -0
- package/package.json +40 -0
- package/src/ai/client.ts +369 -0
- package/src/ai/system-prompt.ts +86 -0
- package/src/app.ts +1454 -0
- package/src/chat/messages.ts +48 -0
- package/src/chat/renderer.ts +243 -0
- package/src/chat/types.ts +18 -0
- package/src/components/code-panel.ts +329 -0
- package/src/components/footer.ts +72 -0
- package/src/components/hooks-panel.ts +224 -0
- package/src/components/input-bar.ts +193 -0
- package/src/components/mode-bar.ts +245 -0
- package/src/components/session-panel.ts +294 -0
- package/src/components/settings-panel.ts +372 -0
- package/src/components/splash.ts +156 -0
- package/src/components/strategy-panel.ts +489 -0
- package/src/components/tab-bar.ts +112 -0
- package/src/components/tutorial-panel.ts +680 -0
- package/src/components/widgets/progress-bar.ts +38 -0
- package/src/components/widgets/sparkline.ts +57 -0
- package/src/hooks/executor.ts +109 -0
- package/src/index.ts +22 -0
- package/src/keys/handler.ts +198 -0
- package/src/platform/auth.ts +36 -0
- package/src/platform/client.ts +159 -0
- package/src/platform/config.ts +121 -0
- package/src/platform/session-sync.ts +158 -0
- package/src/platform/supabase.ts +376 -0
- package/src/platform/sync.ts +149 -0
- package/src/platform/tiers.ts +103 -0
- package/src/platform/tools.ts +163 -0
- package/src/platform/types.ts +86 -0
- package/src/platform/usage.ts +224 -0
- package/src/research/apis.ts +367 -0
- package/src/research/tools.ts +205 -0
- package/src/research/widgets.ts +523 -0
- package/src/state/store.ts +256 -0
- package/src/state/types.ts +109 -0
- package/src/strategy/ascii-chart.ts +74 -0
- package/src/strategy/code-stream.ts +146 -0
- package/src/strategy/dashboard.ts +140 -0
- package/src/strategy/persistence.ts +82 -0
- package/src/strategy/prompts.ts +626 -0
- package/src/strategy/sandbox.ts +137 -0
- package/src/strategy/tools.ts +764 -0
- package/src/strategy/validator.ts +216 -0
- package/src/strategy/widgets.ts +270 -0
- package/src/syntax/setup.ts +54 -0
- package/src/theme/colors.ts +107 -0
- package/src/theme/icons.ts +27 -0
- package/src/util/hyperlink.ts +21 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
|
|
2
|
+
import { COLORS } from "../theme/colors.ts";
|
|
3
|
+
import { store } from "../state/store.ts";
|
|
4
|
+
|
|
5
|
+
const BRAILLE = ["\u2801", "\u2803", "\u2807", "\u280f", "\u281f", "\u283f", "\u287f", "\u28ff", "\u28fe", "\u28fc", "\u28f8", "\u28f0", "\u28e0", "\u28c0", "\u2880", "\u2800"];
|
|
6
|
+
|
|
7
|
+
export class Footer {
|
|
8
|
+
private container: BoxRenderable;
|
|
9
|
+
private streamingText: TextRenderable;
|
|
10
|
+
private hintsText: TextRenderable;
|
|
11
|
+
private _privacy = false;
|
|
12
|
+
private spinnerFrame = 0;
|
|
13
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(private renderer: CliRenderer) {
|
|
16
|
+
this.container = new BoxRenderable(renderer, {
|
|
17
|
+
id: "footer",
|
|
18
|
+
height: 1,
|
|
19
|
+
width: "100%",
|
|
20
|
+
backgroundColor: COLORS.bgDarker,
|
|
21
|
+
flexDirection: "row",
|
|
22
|
+
alignItems: "center",
|
|
23
|
+
paddingLeft: 2,
|
|
24
|
+
paddingRight: 2,
|
|
25
|
+
flexShrink: 0,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
this.streamingText = new TextRenderable(renderer, {
|
|
29
|
+
id: "footer-streaming", content: "", fg: COLORS.textMuted,
|
|
30
|
+
});
|
|
31
|
+
this.container.add(this.streamingText);
|
|
32
|
+
|
|
33
|
+
this.container.add(new BoxRenderable(renderer, { id: "footer-spacer", flexGrow: 1 }));
|
|
34
|
+
|
|
35
|
+
this.hintsText = new TextRenderable(renderer, {
|
|
36
|
+
id: "footer-hints",
|
|
37
|
+
content: "esc stop ^N new chat ^L ^H switch ^W close ^R mode ^E chats ^D bots / cmd",
|
|
38
|
+
fg: COLORS.borderDim,
|
|
39
|
+
});
|
|
40
|
+
this.container.add(this.hintsText);
|
|
41
|
+
|
|
42
|
+
// Smooth spinner animation — own interval, not dependent on store updates
|
|
43
|
+
this.spinnerTimer = setInterval(() => {
|
|
44
|
+
const session = store.getActiveSession();
|
|
45
|
+
if (session?.isStreaming) {
|
|
46
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE.length;
|
|
47
|
+
this.streamingText.content = `${BRAILLE[this.spinnerFrame]} generating`;
|
|
48
|
+
this.streamingText.fg = COLORS.accent;
|
|
49
|
+
this.renderer.requestRender();
|
|
50
|
+
}
|
|
51
|
+
}, 60);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get renderable(): BoxRenderable { return this.container; }
|
|
55
|
+
|
|
56
|
+
setPrivacy(active: boolean): void { this._privacy = active; }
|
|
57
|
+
setMetrics(_active: boolean): void {}
|
|
58
|
+
|
|
59
|
+
update(): void {
|
|
60
|
+
const session = store.getActiveSession();
|
|
61
|
+
if (!session?.isStreaming) {
|
|
62
|
+
this.streamingText.content = "";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
destroy(): void {
|
|
67
|
+
if (this.spinnerTimer) {
|
|
68
|
+
clearInterval(this.spinnerTimer);
|
|
69
|
+
this.spinnerTimer = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Hooks panel — manage user-defined bash commands for strategy lifecycle events
|
|
2
|
+
|
|
3
|
+
import { BoxRenderable, TextRenderable, InputRenderable, type CliRenderer } from "@opentui/core";
|
|
4
|
+
import { COLORS } from "../theme/colors.ts";
|
|
5
|
+
import { loadHooks, saveHooks, HOOK_EVENTS, type HookConfig, type HookEvent } from "../hooks/executor.ts";
|
|
6
|
+
|
|
7
|
+
type PanelMode = "list" | "add_event" | "add_command";
|
|
8
|
+
|
|
9
|
+
export class HooksPanel {
|
|
10
|
+
readonly container: BoxRenderable;
|
|
11
|
+
private listBox: BoxRenderable;
|
|
12
|
+
private inputBox: BoxRenderable;
|
|
13
|
+
private hookInput: InputRenderable;
|
|
14
|
+
private _visible = false;
|
|
15
|
+
private selectedIndex = 0;
|
|
16
|
+
private hooks: HookConfig[] = [];
|
|
17
|
+
private mode: PanelMode = "list";
|
|
18
|
+
private pendingEvent: HookEvent | null = null;
|
|
19
|
+
private eventSelectIndex = 0;
|
|
20
|
+
|
|
21
|
+
constructor(private renderer: CliRenderer) {
|
|
22
|
+
this.container = new BoxRenderable(renderer, {
|
|
23
|
+
id: "hooks-panel",
|
|
24
|
+
width: "50%",
|
|
25
|
+
height: "100%",
|
|
26
|
+
flexDirection: "column",
|
|
27
|
+
borderColor: COLORS.borderDim,
|
|
28
|
+
});
|
|
29
|
+
this.container.visible = false;
|
|
30
|
+
|
|
31
|
+
// Header
|
|
32
|
+
const header = new BoxRenderable(renderer, {
|
|
33
|
+
id: "hooks-header", height: 1, width: "100%",
|
|
34
|
+
flexDirection: "row", alignItems: "center", paddingLeft: 1,
|
|
35
|
+
backgroundColor: COLORS.bgDarker, flexShrink: 0,
|
|
36
|
+
});
|
|
37
|
+
header.add(new TextRenderable(renderer, {
|
|
38
|
+
id: "hooks-title", content: " Hooks ", fg: "#212121", bg: COLORS.accent,
|
|
39
|
+
}));
|
|
40
|
+
this.container.add(header);
|
|
41
|
+
|
|
42
|
+
// List
|
|
43
|
+
this.listBox = new BoxRenderable(renderer, {
|
|
44
|
+
id: "hooks-list", flexDirection: "column", flexGrow: 1,
|
|
45
|
+
paddingTop: 1, paddingLeft: 2, paddingRight: 2,
|
|
46
|
+
});
|
|
47
|
+
this.container.add(this.listBox);
|
|
48
|
+
|
|
49
|
+
// Input row (for adding hooks)
|
|
50
|
+
this.inputBox = new BoxRenderable(renderer, {
|
|
51
|
+
id: "hooks-input-box", flexDirection: "row", height: 1,
|
|
52
|
+
width: "100%", paddingLeft: 2,
|
|
53
|
+
});
|
|
54
|
+
this.inputBox.visible = false;
|
|
55
|
+
this.inputBox.add(new TextRenderable(renderer, {
|
|
56
|
+
id: "hooks-input-label", content: "> ", fg: COLORS.accent,
|
|
57
|
+
}));
|
|
58
|
+
this.hookInput = new InputRenderable(renderer, {
|
|
59
|
+
id: "hooks-input", placeholder: "bash command",
|
|
60
|
+
textColor: COLORS.text, placeholderColor: COLORS.borderDim, flexGrow: 1,
|
|
61
|
+
});
|
|
62
|
+
this.hookInput.on("enter", () => this.onInputSubmit());
|
|
63
|
+
this.inputBox.add(this.hookInput);
|
|
64
|
+
this.container.add(this.inputBox);
|
|
65
|
+
|
|
66
|
+
// Footer
|
|
67
|
+
this.container.add(new TextRenderable(renderer, {
|
|
68
|
+
id: "hooks-footer",
|
|
69
|
+
content: " a add d delete space toggle esc close",
|
|
70
|
+
fg: COLORS.borderDim,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
this.hooks = loadHooks();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get visible(): boolean { return this._visible; }
|
|
77
|
+
|
|
78
|
+
show(): void {
|
|
79
|
+
this._visible = true;
|
|
80
|
+
this.container.visible = true;
|
|
81
|
+
this.hooks = loadHooks();
|
|
82
|
+
this.mode = "list";
|
|
83
|
+
this.selectedIndex = 0;
|
|
84
|
+
this.render();
|
|
85
|
+
this.renderer.requestRender();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
hide(): void {
|
|
89
|
+
this._visible = false;
|
|
90
|
+
this.container.visible = false;
|
|
91
|
+
this.mode = "list";
|
|
92
|
+
this.inputBox.visible = false;
|
|
93
|
+
this.renderer.requestRender();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
toggle(): void { this._visible ? this.hide() : this.show(); }
|
|
97
|
+
|
|
98
|
+
navigate(delta: number): void {
|
|
99
|
+
if (this.mode === "add_event") {
|
|
100
|
+
this.eventSelectIndex = Math.max(0, Math.min(HOOK_EVENTS.length - 1, this.eventSelectIndex + delta));
|
|
101
|
+
} else {
|
|
102
|
+
const max = Math.max(0, this.hooks.length - 1);
|
|
103
|
+
this.selectedIndex = Math.max(0, Math.min(max, this.selectedIndex + delta));
|
|
104
|
+
}
|
|
105
|
+
this.render();
|
|
106
|
+
this.renderer.requestRender();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
handleKey(key: string): void {
|
|
110
|
+
if (this.mode === "add_command") return; // input has focus
|
|
111
|
+
|
|
112
|
+
if (this.mode === "add_event") {
|
|
113
|
+
if (key === "return") {
|
|
114
|
+
this.pendingEvent = HOOK_EVENTS[this.eventSelectIndex]!;
|
|
115
|
+
this.mode = "add_command";
|
|
116
|
+
this.inputBox.visible = true;
|
|
117
|
+
this.hookInput.value = "";
|
|
118
|
+
setTimeout(() => { this.renderer.focusRenderable(this.hookInput); this.renderer.requestRender(); }, 10);
|
|
119
|
+
this.render();
|
|
120
|
+
this.renderer.requestRender();
|
|
121
|
+
} else if (key === "escape") {
|
|
122
|
+
this.mode = "list";
|
|
123
|
+
this.render();
|
|
124
|
+
this.renderer.requestRender();
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// List mode
|
|
130
|
+
if (key === "a") {
|
|
131
|
+
this.mode = "add_event";
|
|
132
|
+
this.eventSelectIndex = 0;
|
|
133
|
+
this.render();
|
|
134
|
+
this.renderer.requestRender();
|
|
135
|
+
} else if (key === "d" && this.hooks.length > 0) {
|
|
136
|
+
this.hooks.splice(this.selectedIndex, 1);
|
|
137
|
+
saveHooks(this.hooks);
|
|
138
|
+
if (this.selectedIndex > 0) this.selectedIndex--;
|
|
139
|
+
this.render();
|
|
140
|
+
this.renderer.requestRender();
|
|
141
|
+
} else if (key === " " && this.hooks.length > 0) {
|
|
142
|
+
this.hooks[this.selectedIndex]!.enabled = !this.hooks[this.selectedIndex]!.enabled;
|
|
143
|
+
saveHooks(this.hooks);
|
|
144
|
+
this.render();
|
|
145
|
+
this.renderer.requestRender();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private onInputSubmit(): void {
|
|
150
|
+
const cmd = this.hookInput.value.trim();
|
|
151
|
+
if (cmd && this.pendingEvent) {
|
|
152
|
+
this.hooks.push({ event: this.pendingEvent, command: cmd, enabled: true });
|
|
153
|
+
saveHooks(this.hooks);
|
|
154
|
+
}
|
|
155
|
+
this.mode = "list";
|
|
156
|
+
this.inputBox.visible = false;
|
|
157
|
+
this.pendingEvent = null;
|
|
158
|
+
this.selectedIndex = Math.max(0, this.hooks.length - 1);
|
|
159
|
+
this.render();
|
|
160
|
+
this.renderer.requestRender();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private render(): void {
|
|
164
|
+
for (const child of this.listBox.getChildren()) this.listBox.remove(child.id);
|
|
165
|
+
|
|
166
|
+
if (this.mode === "add_event") {
|
|
167
|
+
this.listBox.add(new TextRenderable(this.renderer, {
|
|
168
|
+
id: "hooks-select-title", content: "Select event:", fg: COLORS.text, attributes: 1, marginBottom: 1,
|
|
169
|
+
}));
|
|
170
|
+
for (let i = 0; i < HOOK_EVENTS.length; i++) {
|
|
171
|
+
const sel = i === this.eventSelectIndex;
|
|
172
|
+
this.listBox.add(new TextRenderable(this.renderer, {
|
|
173
|
+
id: `hooks-ev-${i}`,
|
|
174
|
+
content: `${sel ? "> " : " "}${HOOK_EVENTS[i]}`,
|
|
175
|
+
fg: sel ? COLORS.text : COLORS.textMuted,
|
|
176
|
+
attributes: sel ? 1 : 0,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// List mode
|
|
183
|
+
if (this.hooks.length === 0) {
|
|
184
|
+
this.listBox.add(new TextRenderable(this.renderer, {
|
|
185
|
+
id: "hooks-empty", content: "No hooks configured.\nPress 'a' to add one.", fg: COLORS.textMuted,
|
|
186
|
+
}));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < this.hooks.length; i++) {
|
|
191
|
+
const h = this.hooks[i]!;
|
|
192
|
+
const sel = i === this.selectedIndex;
|
|
193
|
+
const status = h.enabled ? "[on] " : "[off]";
|
|
194
|
+
const statusColor = h.enabled ? COLORS.success : COLORS.borderDim;
|
|
195
|
+
|
|
196
|
+
const row = new BoxRenderable(this.renderer, {
|
|
197
|
+
id: `hook-row-${i}`, flexDirection: "column", width: "100%", marginBottom: 1,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const line = new BoxRenderable(this.renderer, {
|
|
201
|
+
id: `hook-line-${i}`, flexDirection: "row",
|
|
202
|
+
});
|
|
203
|
+
line.add(new TextRenderable(this.renderer, {
|
|
204
|
+
id: `hook-ptr-${i}`, content: sel ? "> " : " ", fg: COLORS.accent,
|
|
205
|
+
}));
|
|
206
|
+
line.add(new TextRenderable(this.renderer, {
|
|
207
|
+
id: `hook-status-${i}`, content: status, fg: statusColor,
|
|
208
|
+
}));
|
|
209
|
+
line.add(new TextRenderable(this.renderer, {
|
|
210
|
+
id: `hook-event-${i}`, content: ` ${h.event} `, fg: COLORS.textMuted,
|
|
211
|
+
}));
|
|
212
|
+
row.add(line);
|
|
213
|
+
|
|
214
|
+
// Command on second line
|
|
215
|
+
row.add(new TextRenderable(this.renderer, {
|
|
216
|
+
id: `hook-cmd-${i}`,
|
|
217
|
+
content: ` ${h.command.length > 35 ? h.command.slice(0, 34) + "." : h.command}`,
|
|
218
|
+
fg: sel ? COLORS.text : COLORS.borderDim,
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
this.listBox.add(row);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { BoxRenderable, InputRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
|
|
2
|
+
import { COLORS } from "../theme/colors.ts";
|
|
3
|
+
|
|
4
|
+
export class InputBar {
|
|
5
|
+
private container: BoxRenderable;
|
|
6
|
+
private input: InputRenderable;
|
|
7
|
+
private prompt: TextRenderable;
|
|
8
|
+
|
|
9
|
+
// Autocomplete
|
|
10
|
+
private acBox: BoxRenderable;
|
|
11
|
+
private acItems: TextRenderable[] = [];
|
|
12
|
+
private _acVisible = false;
|
|
13
|
+
private _acSelected = 0;
|
|
14
|
+
private _acMatches: string[] = [];
|
|
15
|
+
private _commands: Record<string, string> = {};
|
|
16
|
+
|
|
17
|
+
private onSubmitCallback: ((text: string) => void) | null = null;
|
|
18
|
+
private onAcChangeCallback: ((visible: boolean) => void) | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(private renderer: CliRenderer) {
|
|
21
|
+
this.container = new BoxRenderable(renderer, {
|
|
22
|
+
id: "input-bar",
|
|
23
|
+
width: "100%",
|
|
24
|
+
flexDirection: "column",
|
|
25
|
+
flexShrink: 0,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ── Autocomplete dropdown (above input, hidden by default) ──
|
|
29
|
+
this.acBox = new BoxRenderable(renderer, {
|
|
30
|
+
id: "input-ac",
|
|
31
|
+
width: "100%",
|
|
32
|
+
flexDirection: "column",
|
|
33
|
+
paddingLeft: 4,
|
|
34
|
+
backgroundColor: COLORS.bgSecondary,
|
|
35
|
+
});
|
|
36
|
+
this.acBox.visible = false;
|
|
37
|
+
this.container.add(this.acBox);
|
|
38
|
+
|
|
39
|
+
// Pre-create suggestion slots (max 8)
|
|
40
|
+
for (let i = 0; i < 8; i++) {
|
|
41
|
+
const item = new TextRenderable(renderer, {
|
|
42
|
+
id: `input-ac-${i}`, content: "", fg: COLORS.textMuted,
|
|
43
|
+
});
|
|
44
|
+
this.acItems.push(item);
|
|
45
|
+
this.acBox.add(item);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Input row ──
|
|
49
|
+
const inputRow = new BoxRenderable(renderer, {
|
|
50
|
+
id: "input-row",
|
|
51
|
+
height: 3,
|
|
52
|
+
width: "100%",
|
|
53
|
+
flexDirection: "row",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
backgroundColor: COLORS.bgDarker,
|
|
56
|
+
paddingLeft: 2,
|
|
57
|
+
});
|
|
58
|
+
this.container.add(inputRow);
|
|
59
|
+
|
|
60
|
+
this.prompt = new TextRenderable(renderer, {
|
|
61
|
+
id: "input-prompt", content: "> ", fg: COLORS.textMuted,
|
|
62
|
+
});
|
|
63
|
+
inputRow.add(this.prompt);
|
|
64
|
+
|
|
65
|
+
this.input = new InputRenderable(renderer, {
|
|
66
|
+
id: "input-field",
|
|
67
|
+
placeholder: "Message Horizon... (/ for commands)",
|
|
68
|
+
textColor: COLORS.text,
|
|
69
|
+
flexGrow: 1,
|
|
70
|
+
paddingRight: 2,
|
|
71
|
+
});
|
|
72
|
+
inputRow.add(this.input);
|
|
73
|
+
|
|
74
|
+
// ── Events ──
|
|
75
|
+
this.input.on("enter", () => {
|
|
76
|
+
// If autocomplete visible and item selected, insert it
|
|
77
|
+
if (this._acVisible && this._acMatches.length > 0) {
|
|
78
|
+
this.input.value = this._acMatches[this._acSelected]! + " ";
|
|
79
|
+
this.hideAc();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const text = this.input.value.trim();
|
|
84
|
+
if (text.length === 0) return;
|
|
85
|
+
this.input.value = "";
|
|
86
|
+
this.hideAc();
|
|
87
|
+
this.onSubmitCallback?.(text);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Listen for text changes to trigger autocomplete
|
|
91
|
+
this.input.on("input", () => {
|
|
92
|
+
this.onInputChange();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get renderable(): BoxRenderable { return this.container; }
|
|
97
|
+
get inputRenderable(): InputRenderable { return this.input; }
|
|
98
|
+
get acVisible(): boolean { return this._acVisible; }
|
|
99
|
+
|
|
100
|
+
onSubmit(cb: (text: string) => void): void { this.onSubmitCallback = cb; }
|
|
101
|
+
onAcChange(cb: (visible: boolean) => void): void { this.onAcChangeCallback = cb; }
|
|
102
|
+
|
|
103
|
+
/** Register available slash commands for autocomplete */
|
|
104
|
+
setCommands(commands: Record<string, string>): void {
|
|
105
|
+
this._commands = commands;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
focus(): void { this.renderer.focusRenderable(this.input); }
|
|
109
|
+
clear(): void { this.input.value = ""; this.hideAc(); }
|
|
110
|
+
|
|
111
|
+
// ── Autocomplete navigation (called from key handler) ──
|
|
112
|
+
|
|
113
|
+
acUp(): void {
|
|
114
|
+
if (!this._acVisible) return;
|
|
115
|
+
this._acSelected = Math.max(0, this._acSelected - 1);
|
|
116
|
+
this.renderAc();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
acDown(): void {
|
|
120
|
+
if (!this._acVisible) return;
|
|
121
|
+
this._acSelected = Math.min(this._acMatches.length - 1, this._acSelected + 1);
|
|
122
|
+
this.renderAc();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
acAccept(): void {
|
|
126
|
+
if (!this._acVisible || this._acMatches.length === 0) return;
|
|
127
|
+
this.input.value = this._acMatches[this._acSelected]! + " ";
|
|
128
|
+
this.hideAc();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
acDismiss(): void {
|
|
132
|
+
this.hideAc();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Internal ──
|
|
136
|
+
|
|
137
|
+
private onInputChange(): void {
|
|
138
|
+
const val = this.input.value;
|
|
139
|
+
|
|
140
|
+
// Only show autocomplete when typing a slash command
|
|
141
|
+
if (!val.startsWith("/") || val.includes(" ")) {
|
|
142
|
+
this.hideAc();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const prefix = val.toLowerCase();
|
|
147
|
+
this._acMatches = Object.keys(this._commands)
|
|
148
|
+
.filter((cmd) => cmd.startsWith(prefix) && cmd !== val)
|
|
149
|
+
.slice(0, 8);
|
|
150
|
+
|
|
151
|
+
if (this._acMatches.length === 0) {
|
|
152
|
+
this.hideAc();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this._acSelected = 0;
|
|
157
|
+
this._acVisible = true;
|
|
158
|
+
this.acBox.visible = true;
|
|
159
|
+
this.onAcChangeCallback?.(true);
|
|
160
|
+
this.renderAc();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private renderAc(): void {
|
|
164
|
+
for (let i = 0; i < this.acItems.length; i++) {
|
|
165
|
+
const item = this.acItems[i]!;
|
|
166
|
+
if (i < this._acMatches.length) {
|
|
167
|
+
const cmd = this._acMatches[i]!;
|
|
168
|
+
const desc = this._commands[cmd] ?? "";
|
|
169
|
+
const selected = i === this._acSelected;
|
|
170
|
+
item.content = `${selected ? ">" : " "} ${cmd.padEnd(16)} ${desc}`;
|
|
171
|
+
item.fg = selected ? COLORS.text : COLORS.textMuted;
|
|
172
|
+
item.bg = selected ? COLORS.selection : undefined;
|
|
173
|
+
item.visible = true;
|
|
174
|
+
} else {
|
|
175
|
+
item.content = "";
|
|
176
|
+
item.visible = false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.renderer.requestRender();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private hideAc(): void {
|
|
183
|
+
if (!this._acVisible) return;
|
|
184
|
+
this._acVisible = false;
|
|
185
|
+
this.acBox.visible = false;
|
|
186
|
+
this.onAcChangeCallback?.(false);
|
|
187
|
+
for (const item of this.acItems) {
|
|
188
|
+
item.content = "";
|
|
189
|
+
item.visible = false;
|
|
190
|
+
}
|
|
191
|
+
this.renderer.requestRender();
|
|
192
|
+
}
|
|
193
|
+
}
|