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.
Files changed (54) hide show
  1. package/assets/python/highlights.scm +137 -0
  2. package/assets/python/tree-sitter-python.wasm +0 -0
  3. package/bin/horizon.js +2 -0
  4. package/package.json +40 -0
  5. package/src/ai/client.ts +369 -0
  6. package/src/ai/system-prompt.ts +86 -0
  7. package/src/app.ts +1454 -0
  8. package/src/chat/messages.ts +48 -0
  9. package/src/chat/renderer.ts +243 -0
  10. package/src/chat/types.ts +18 -0
  11. package/src/components/code-panel.ts +329 -0
  12. package/src/components/footer.ts +72 -0
  13. package/src/components/hooks-panel.ts +224 -0
  14. package/src/components/input-bar.ts +193 -0
  15. package/src/components/mode-bar.ts +245 -0
  16. package/src/components/session-panel.ts +294 -0
  17. package/src/components/settings-panel.ts +372 -0
  18. package/src/components/splash.ts +156 -0
  19. package/src/components/strategy-panel.ts +489 -0
  20. package/src/components/tab-bar.ts +112 -0
  21. package/src/components/tutorial-panel.ts +680 -0
  22. package/src/components/widgets/progress-bar.ts +38 -0
  23. package/src/components/widgets/sparkline.ts +57 -0
  24. package/src/hooks/executor.ts +109 -0
  25. package/src/index.ts +22 -0
  26. package/src/keys/handler.ts +198 -0
  27. package/src/platform/auth.ts +36 -0
  28. package/src/platform/client.ts +159 -0
  29. package/src/platform/config.ts +121 -0
  30. package/src/platform/session-sync.ts +158 -0
  31. package/src/platform/supabase.ts +376 -0
  32. package/src/platform/sync.ts +149 -0
  33. package/src/platform/tiers.ts +103 -0
  34. package/src/platform/tools.ts +163 -0
  35. package/src/platform/types.ts +86 -0
  36. package/src/platform/usage.ts +224 -0
  37. package/src/research/apis.ts +367 -0
  38. package/src/research/tools.ts +205 -0
  39. package/src/research/widgets.ts +523 -0
  40. package/src/state/store.ts +256 -0
  41. package/src/state/types.ts +109 -0
  42. package/src/strategy/ascii-chart.ts +74 -0
  43. package/src/strategy/code-stream.ts +146 -0
  44. package/src/strategy/dashboard.ts +140 -0
  45. package/src/strategy/persistence.ts +82 -0
  46. package/src/strategy/prompts.ts +626 -0
  47. package/src/strategy/sandbox.ts +137 -0
  48. package/src/strategy/tools.ts +764 -0
  49. package/src/strategy/validator.ts +216 -0
  50. package/src/strategy/widgets.ts +270 -0
  51. package/src/syntax/setup.ts +54 -0
  52. package/src/theme/colors.ts +107 -0
  53. package/src/theme/icons.ts +27 -0
  54. 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
+ }