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,256 @@
1
+ import type { AppState, Session, StrategyDraft } from "./types.ts";
2
+ import type { Message } from "../chat/types.ts";
3
+
4
+ export type StateListener = (state: AppState) => void;
5
+
6
+ function createSession(name: string): Session {
7
+ const id = `session-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
8
+ return {
9
+ id, name, messages: [],
10
+ createdAt: Date.now(), updatedAt: Date.now(),
11
+ mode: "research", isStreaming: false, streamingMsgId: null, strategyDraft: null,
12
+ };
13
+ }
14
+
15
+ function createInitialState(): AppState {
16
+ // Start with NO sessions — they load from Supabase or get created on first input
17
+ return {
18
+ connection: "connected",
19
+ operatingMode: "supervised",
20
+ sessions: [],
21
+ activeSessionId: "",
22
+ openTabIds: [],
23
+ deployments: [],
24
+ };
25
+ }
26
+
27
+ class Store {
28
+ private state: AppState;
29
+ private listeners: Set<StateListener> = new Set();
30
+ private notifyPending = false;
31
+
32
+ constructor() { this.state = createInitialState(); }
33
+
34
+ get(): AppState { return this.state; }
35
+
36
+ update(partial: Partial<AppState>): void {
37
+ this.state = { ...this.state, ...partial };
38
+ // Ensure openTabIds has no duplicates
39
+ if (partial.openTabIds) {
40
+ this.state.openTabIds = [...new Set(this.state.openTabIds)];
41
+ }
42
+ this.notify();
43
+ }
44
+
45
+ // ── Session getters ──
46
+
47
+ getActiveSession(): Session | undefined {
48
+ return this.state.sessions.find((s) => s.id === this.state.activeSessionId);
49
+ }
50
+
51
+ getSession(id: string): Session | undefined {
52
+ return this.state.sessions.find((s) => s.id === id);
53
+ }
54
+
55
+ // ── Message operations (take explicit sessionId) ──
56
+
57
+ addMessageTo(sessionId: string, message: Message): void {
58
+ const sessions = this.state.sessions.map((s) => {
59
+ if (s.id !== sessionId) return s;
60
+ return { ...s, messages: [...s.messages, message], updatedAt: Date.now() };
61
+ });
62
+ this.state = { ...this.state, sessions };
63
+ this.notify();
64
+ }
65
+
66
+ // Convenience: add to active session
67
+ addMessage(message: Message): void {
68
+ this.addMessageTo(this.state.activeSessionId, message);
69
+ }
70
+
71
+ updateMessageIn(sessionId: string, messageId: string, updates: Partial<Message>): void {
72
+ const sessions = this.state.sessions.map((s) => {
73
+ if (s.id !== sessionId) return s;
74
+ const messages = s.messages.map((m) => m.id !== messageId ? m : { ...m, ...updates });
75
+ return { ...s, messages, updatedAt: Date.now() };
76
+ });
77
+ this.state = { ...this.state, sessions };
78
+ this.notify();
79
+ }
80
+
81
+ updateMessage(messageId: string, updates: Partial<Message>): void {
82
+ this.updateMessageIn(this.state.activeSessionId, messageId, updates);
83
+ }
84
+
85
+ // ── Session streaming state ──
86
+
87
+ setSessionStreaming(sessionId: string, streaming: boolean, msgId?: string | null): void {
88
+ const sessions = this.state.sessions.map((s) => {
89
+ if (s.id !== sessionId) return s;
90
+ return { ...s, isStreaming: streaming, streamingMsgId: msgId ?? null };
91
+ });
92
+ this.state = { ...this.state, sessions };
93
+ this.notify();
94
+ }
95
+
96
+ isAnyStreaming(): boolean {
97
+ return this.state.sessions.some((s) => s.isStreaming);
98
+ }
99
+
100
+ // ── Session mode ──
101
+
102
+ setSessionMode(sessionId: string, mode: Session["mode"]): void {
103
+ const sessions = this.state.sessions.map((s) =>
104
+ s.id === sessionId ? { ...s, mode } : s
105
+ );
106
+ this.state = { ...this.state, sessions };
107
+ this.notify();
108
+ }
109
+
110
+ // ── Strategy draft (per-session) ──
111
+
112
+ setStrategyDraft(draft: StrategyDraft): void {
113
+ const sid = this.state.activeSessionId;
114
+ const sessions = this.state.sessions.map((s) =>
115
+ s.id === sid ? { ...s, strategyDraft: draft } : s
116
+ );
117
+ this.state = { ...this.state, sessions };
118
+ this.notify();
119
+ }
120
+
121
+ updateStrategyDraft(updates: Partial<StrategyDraft>): void {
122
+ const sid = this.state.activeSessionId;
123
+ const sessions = this.state.sessions.map((s) => {
124
+ if (s.id !== sid || !s.strategyDraft) return s;
125
+ return { ...s, strategyDraft: { ...s.strategyDraft, ...updates } };
126
+ });
127
+ this.state = { ...this.state, sessions };
128
+ this.notify();
129
+ }
130
+
131
+ clearStrategyDraft(): void {
132
+ const sid = this.state.activeSessionId;
133
+ const sessions = this.state.sessions.map((s) =>
134
+ s.id === sid ? { ...s, strategyDraft: null } : s
135
+ );
136
+ this.state = { ...this.state, sessions };
137
+ this.notify();
138
+ }
139
+
140
+ // ── Tab management ──
141
+
142
+ newSession(): string {
143
+ const session = createSession("New chat");
144
+ this.state = {
145
+ ...this.state,
146
+ sessions: [...this.state.sessions, session],
147
+ activeSessionId: session.id,
148
+ openTabIds: [...this.state.openTabIds, session.id],
149
+ };
150
+ this.notify();
151
+ return session.id;
152
+ }
153
+
154
+ openTab(sessionId: string): void {
155
+ if (!this.state.openTabIds.includes(sessionId)) {
156
+ this.state = {
157
+ ...this.state,
158
+ openTabIds: [...this.state.openTabIds, sessionId],
159
+ activeSessionId: sessionId,
160
+ };
161
+ } else {
162
+ this.state = { ...this.state, activeSessionId: sessionId };
163
+ }
164
+ this.notify();
165
+ }
166
+
167
+ closeTab(sessionId: string): void {
168
+ const tabs = this.state.openTabIds.filter((id) => id !== sessionId);
169
+ if (tabs.length === 0) {
170
+ // Always keep at least one tab
171
+ const newId = this.newSession();
172
+ return;
173
+ }
174
+ const activeId = sessionId === this.state.activeSessionId
175
+ ? tabs[Math.max(0, this.state.openTabIds.indexOf(sessionId) - 1)] ?? tabs[0]!
176
+ : this.state.activeSessionId;
177
+ this.state = { ...this.state, openTabIds: tabs, activeSessionId: activeId };
178
+ this.notify();
179
+ }
180
+
181
+ nextTab(): void {
182
+ const { openTabIds, activeSessionId } = this.state;
183
+ if (openTabIds.length <= 1) return;
184
+ const idx = openTabIds.indexOf(activeSessionId);
185
+ const next = openTabIds[(idx + 1) % openTabIds.length]!;
186
+ this.state = { ...this.state, activeSessionId: next };
187
+ this.notify();
188
+ }
189
+
190
+ prevTab(): void {
191
+ const { openTabIds, activeSessionId } = this.state;
192
+ if (openTabIds.length <= 1) return;
193
+ const idx = openTabIds.indexOf(activeSessionId);
194
+ const prev = openTabIds[(idx - 1 + openTabIds.length) % openTabIds.length]!;
195
+ this.state = { ...this.state, activeSessionId: prev };
196
+ this.notify();
197
+ }
198
+
199
+ jumpToTab(index: number): void {
200
+ const tab = this.state.openTabIds[index];
201
+ if (tab) {
202
+ this.state = { ...this.state, activeSessionId: tab };
203
+ this.notify();
204
+ }
205
+ }
206
+
207
+ // ── Session CRUD ──
208
+
209
+ deleteSession(sessionId: string): void {
210
+ // Close tab if open
211
+ const tabs = this.state.openTabIds.filter((id) => id !== sessionId);
212
+ const sessions = this.state.sessions.filter((s) => s.id !== sessionId);
213
+ if (sessions.length === 0) {
214
+ const s = createSession("New chat");
215
+ sessions.push(s);
216
+ tabs.push(s.id);
217
+ }
218
+ const activeId = sessionId === this.state.activeSessionId
219
+ ? (tabs[0] ?? sessions[0]!.id) : this.state.activeSessionId;
220
+ this.state = { ...this.state, sessions, openTabIds: tabs, activeSessionId: activeId };
221
+ this.notify();
222
+ }
223
+
224
+ renameSession(sessionId: string, name: string): void {
225
+ const sessions = this.state.sessions.map((s) =>
226
+ s.id === sessionId ? { ...s, name, updatedAt: Date.now() } : s
227
+ );
228
+ this.state = { ...this.state, sessions };
229
+ this.notify();
230
+ }
231
+
232
+ togglePinSession(sessionId: string): void {
233
+ const sessions = this.state.sessions.map((s) =>
234
+ s.id === sessionId ? { ...s, pinned: !s.pinned } : s
235
+ );
236
+ this.state = { ...this.state, sessions };
237
+ this.notify();
238
+ }
239
+
240
+ subscribe(listener: StateListener): () => void {
241
+ this.listeners.add(listener);
242
+ return () => this.listeners.delete(listener);
243
+ }
244
+
245
+ private notify(): void {
246
+ // Batch: collapse multiple synchronous mutations into a single notification
247
+ if (this.notifyPending) return;
248
+ this.notifyPending = true;
249
+ queueMicrotask(() => {
250
+ this.notifyPending = false;
251
+ for (const listener of this.listeners) listener(this.state);
252
+ });
253
+ }
254
+ }
255
+
256
+ export const store = new Store();
@@ -0,0 +1,109 @@
1
+ import type { Message } from "../chat/types.ts";
2
+ import type { Mode } from "../components/mode-bar.ts";
3
+
4
+ export type ConnectionStatus = "connected" | "connecting" | "disconnected";
5
+ export type OperatingMode = "dry_run" | "supervised" | "autonomous";
6
+
7
+ export type DeploymentStatus =
8
+ | "pending" | "starting" | "running" | "queued" | "restarting"
9
+ | "scanner_idle" | "stopped" | "error" | "killed" | "stopping";
10
+
11
+ export interface Position {
12
+ market_id: string;
13
+ slug: string;
14
+ question: string;
15
+ side: "BUY" | "SELL";
16
+ size: number;
17
+ avg_entry_price: number;
18
+ cost_basis: number;
19
+ realized_pnl: number;
20
+ unrealized_pnl: number;
21
+ }
22
+
23
+ export interface Order {
24
+ market_id: string;
25
+ slug: string;
26
+ side: "BUY" | "SELL";
27
+ price: number;
28
+ size: number;
29
+ filled_size: number;
30
+ status: "PENDING" | "LIVE" | "PARTIALLY_FILLED" | "FILLED" | "CANCELLED";
31
+ order_type: "GTC" | "IOC";
32
+ created_at: number;
33
+ }
34
+
35
+ export interface DeploymentMetrics {
36
+ total_pnl: number;
37
+ realized_pnl: number;
38
+ unrealized_pnl: number;
39
+ total_exposure: number;
40
+ position_count: number;
41
+ open_order_count: number;
42
+ win_rate: number;
43
+ total_trades: number;
44
+ max_drawdown_pct: number;
45
+ sharpe_ratio: number;
46
+ profit_factor: number;
47
+ avg_return_per_trade: number;
48
+ gross_profit: number;
49
+ gross_loss: number;
50
+ }
51
+
52
+ export interface Deployment {
53
+ id: string;
54
+ strategyId: string; // strategy UUID (used for API calls)
55
+ name: string;
56
+ strategy_type: string;
57
+ status: DeploymentStatus;
58
+ dry_run: boolean;
59
+ mode: "manual" | "scanner";
60
+ metrics: DeploymentMetrics;
61
+ positions: Position[];
62
+ orders: Order[];
63
+ pnl_history: number[];
64
+ started_at: number;
65
+ }
66
+
67
+ export interface StrategyDraft {
68
+ name: string;
69
+ code: string;
70
+ params: Record<string, unknown>;
71
+ explanation: string;
72
+ riskConfig: {
73
+ max_position: number;
74
+ max_notional: number;
75
+ max_drawdown_pct: number;
76
+ max_order_size?: number;
77
+ rate_limit?: number;
78
+ rate_burst?: number;
79
+ } | null;
80
+ validationStatus: "pending" | "valid" | "invalid" | "none";
81
+ validationErrors: { line: number | null; message: string }[];
82
+ phase: "generated" | "iterated" | "validated" | "saved" | "deployed";
83
+ strategyId?: string;
84
+ filePath?: string;
85
+ }
86
+
87
+ // Each session is a full independent workspace
88
+ export interface Session {
89
+ id: string;
90
+ name: string;
91
+ messages: Message[];
92
+ createdAt: number;
93
+ updatedAt: number;
94
+ pinned?: boolean;
95
+ // Per-tab workspace state
96
+ mode: Mode;
97
+ isStreaming: boolean;
98
+ streamingMsgId: string | null;
99
+ strategyDraft: StrategyDraft | null;
100
+ }
101
+
102
+ export interface AppState {
103
+ connection: ConnectionStatus;
104
+ operatingMode: OperatingMode;
105
+ sessions: Session[];
106
+ activeSessionId: string;
107
+ openTabIds: string[]; // tabs currently open (ordered)
108
+ deployments: Deployment[];
109
+ }
@@ -0,0 +1,74 @@
1
+ // ASCII equity curve chart using box-drawing characters
2
+
3
+ const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
4
+
5
+ /**
6
+ * Render a multi-line ASCII chart with box frame and Y-axis labels.
7
+ * Returns an array of strings (one per line).
8
+ */
9
+ export function renderAsciiChart(
10
+ values: number[],
11
+ width = 48,
12
+ height = 8,
13
+ ): string[] {
14
+ if (values.length === 0) return [" (no data)"];
15
+
16
+ // Resample to fit width
17
+ const sampled: number[] = [];
18
+ for (let i = 0; i < width; i++) {
19
+ const idx = Math.floor((i / width) * values.length);
20
+ sampled.push(values[idx] ?? 0);
21
+ }
22
+
23
+ const min = Math.min(...sampled);
24
+ const max = Math.max(...sampled);
25
+ const range = max - min || 1;
26
+
27
+ // Build chart grid (height rows × width cols)
28
+ // Each cell maps to a sub-block level
29
+ const grid: string[][] = [];
30
+ for (let row = 0; row < height; row++) {
31
+ grid.push(new Array(width).fill(" "));
32
+ }
33
+
34
+ for (let col = 0; col < width; col++) {
35
+ const normalized = (sampled[col]! - min) / range; // 0..1
36
+ const totalBlocks = normalized * height;
37
+ const fullRows = Math.floor(totalBlocks);
38
+ const partialLevel = Math.round((totalBlocks - fullRows) * (BLOCKS.length - 1));
39
+
40
+ // Fill from bottom up
41
+ for (let row = 0; row < fullRows && row < height; row++) {
42
+ grid[height - 1 - row]![col] = BLOCKS[BLOCKS.length - 1]!;
43
+ }
44
+ if (fullRows < height && partialLevel > 0) {
45
+ grid[height - 1 - fullRows]![col] = BLOCKS[partialLevel]!;
46
+ }
47
+ }
48
+
49
+ // Y-axis labels (top, middle, bottom)
50
+ const fmtVal = (v: number): string => {
51
+ if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
52
+ if (Math.abs(v) >= 100) return v.toFixed(0);
53
+ return v.toFixed(1);
54
+ };
55
+
56
+ const topLabel = fmtVal(max).padStart(6);
57
+ const midLabel = fmtVal((max + min) / 2).padStart(6);
58
+ const botLabel = fmtVal(min).padStart(6);
59
+
60
+ const lines: string[] = [];
61
+
62
+ // Top border
63
+ lines.push(`${topLabel} ┌${"─".repeat(width)}┐`);
64
+
65
+ for (let row = 0; row < height; row++) {
66
+ const label = row === Math.floor(height / 2) ? midLabel : " ";
67
+ lines.push(`${label} │${grid[row]!.join("")}│`);
68
+ }
69
+
70
+ // Bottom border
71
+ lines.push(`${botLabel} └${"─".repeat(width)}┘`);
72
+
73
+ return lines;
74
+ }
@@ -0,0 +1,146 @@
1
+ // Code fence detection + strategy finalization
2
+ // Intercepts ```python fences in LLM text stream and routes code to the panel.
3
+ // This replaces propose_strategy/update_strategy tools — the LLM writes code
4
+ // directly in its response, and we catch it and handle validation/saving.
5
+
6
+ import { validateStrategyCode, autoFixStrategyCode } from "./validator.ts";
7
+ import { saveStrategy } from "./persistence.ts";
8
+ import { store } from "../state/store.ts";
9
+ import type { StrategyDraft } from "../state/types.ts";
10
+
11
+ /**
12
+ * Extract strategy name from code (hz.run or hz.backtest name= arg).
13
+ */
14
+ export function extractStrategyName(code: string): string {
15
+ const runMatch = code.match(/hz\.(?:run|backtest)\s*\(\s*(?:[\s\S]*?)?name\s*=\s*["']([^"']+)["']/);
16
+ if (runMatch) return runMatch[1]!;
17
+ // Fallback: look for a class name or comment
18
+ const commentMatch = code.match(/#\s*Strategy:\s*(\S+)/i);
19
+ if (commentMatch) return commentMatch[1]!;
20
+ return "Strategy";
21
+ }
22
+
23
+ /**
24
+ * Check if code contains hz.run() or hz.backtest() — a real strategy, not a snippet.
25
+ */
26
+ export function isStrategyCode(code: string): boolean {
27
+ return /hz\.(run|backtest)\s*\(/.test(code);
28
+ }
29
+
30
+ /**
31
+ * Extract risk config from hz.Risk(...) in code.
32
+ */
33
+ function extractRiskConfig(code: string): StrategyDraft["riskConfig"] {
34
+ const riskMatch = code.match(/hz\.Risk\(([\s\S]*?)\)/);
35
+ if (!riskMatch) return null;
36
+ const riskArgs = riskMatch[1]!.replace(/\n/g, " ").trim();
37
+
38
+ const getNum = (key: string) => {
39
+ const m = riskArgs.match(new RegExp(`${key}\\s*=\\s*([\\d.]+)`));
40
+ return m ? parseFloat(m[1]!) : undefined;
41
+ };
42
+ const rl = getNum("rate_limit");
43
+ const rb = getNum("rate_burst");
44
+ return {
45
+ max_position: getNum("max_position") ?? 100,
46
+ max_notional: getNum("max_notional") ?? 1000,
47
+ max_drawdown_pct: getNum("max_drawdown_pct") ?? 5,
48
+ max_order_size: getNum("max_order_size"),
49
+ rate_limit: rl !== undefined ? Math.round(rl) : undefined,
50
+ rate_burst: rb !== undefined ? Math.round(rb) : undefined,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Extract params dict from code.
56
+ */
57
+ function extractParams(code: string): Record<string, unknown> {
58
+ const paramsMatch = code.match(/params\s*=\s*(\{[^}]+\})/);
59
+ if (!paramsMatch) return {};
60
+ try { return JSON.parse(paramsMatch[1]!.replace(/'/g, '"')); } catch { return {}; }
61
+ }
62
+
63
+ /**
64
+ * Finalize a strategy: auto-fix → validate → extract metadata → save → set draft.
65
+ * Called when a code fence closes in the text stream.
66
+ */
67
+ export async function finalizeStrategy(code: string, overrideName?: string): Promise<StrategyDraft> {
68
+ const fixedCode = autoFixStrategyCode(code);
69
+ const errors = validateStrategyCode(fixedCode);
70
+ const name = overrideName ?? extractStrategyName(fixedCode);
71
+
72
+ const draft: StrategyDraft = {
73
+ name,
74
+ code: fixedCode,
75
+ params: extractParams(fixedCode),
76
+ explanation: "",
77
+ riskConfig: extractRiskConfig(fixedCode),
78
+ validationStatus: errors.length === 0 ? "valid" : "invalid",
79
+ validationErrors: errors,
80
+ phase: "generated",
81
+ };
82
+
83
+ store.setStrategyDraft(draft);
84
+ await saveStrategy(name, fixedCode).catch(() => null);
85
+ return draft;
86
+ }
87
+
88
+ // ── Streaming code fence detector ──
89
+
90
+ export class CodeFenceDetector {
91
+ private _inFence = false;
92
+ private _fenceStart = 0;
93
+ private _lastSearchPos = 0;
94
+
95
+ get inFence(): boolean { return this._inFence; }
96
+
97
+ /**
98
+ * Feed the full accumulated text on each delta.
99
+ * Returns:
100
+ * - null: no code fence event
101
+ * - { event: "open" }: fence just opened
102
+ * - { event: "delta", code: string }: partial code update
103
+ * - { event: "close", code: string }: fence closed, full code available
104
+ */
105
+ update(fullText: string): { event: "open" } | { event: "delta"; code: string } | { event: "close"; code: string } | null {
106
+ if (!this._inFence) {
107
+ // Look for opening fence
108
+ const markers = ["```python\n", "```python ", "```py\n", "```py "];
109
+ for (const marker of markers) {
110
+ const idx = fullText.indexOf(marker, this._lastSearchPos);
111
+ if (idx !== -1) {
112
+ this._inFence = true;
113
+ this._fenceStart = idx + marker.length;
114
+ this._lastSearchPos = this._fenceStart;
115
+ return { event: "open" };
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ // In fence — look for closing ```
122
+ // Match \n``` NOT followed by another backtick (avoids matching `````)
123
+ const searchFrom = Math.max(this._fenceStart, this._lastSearchPos - 4);
124
+ const rest = fullText.slice(searchFrom);
125
+ const closeMatch = rest.match(/\n```(?!`)/);
126
+
127
+ if (closeMatch && closeMatch.index !== undefined) {
128
+ const closeIdx = searchFrom + closeMatch.index;
129
+ this._inFence = false;
130
+ const code = fullText.slice(this._fenceStart, closeIdx);
131
+ this._lastSearchPos = closeIdx + 4;
132
+ return { event: "close", code };
133
+ }
134
+
135
+ // Still in fence — return partial code
136
+ this._lastSearchPos = fullText.length;
137
+ const code = fullText.slice(this._fenceStart);
138
+ return { event: "delta", code };
139
+ }
140
+
141
+ reset(): void {
142
+ this._inFence = false;
143
+ this._fenceStart = 0;
144
+ this._lastSearchPos = 0;
145
+ }
146
+ }