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,245 @@
1
+ import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
2
+ import { COLORS } from "../theme/colors.ts";
3
+
4
+ export type Mode = "research" | "strategy" | "portfolio";
5
+
6
+ const MODE_CONFIG: Record<Mode, { label: string; icon: string; color: string }> = {
7
+ research: { label: "Research", icon: "~", color: COLORS.secondary },
8
+ strategy: { label: "Strategy", icon: ">", color: COLORS.accent },
9
+ portfolio: { label: "Portfolio", icon: "o", color: COLORS.success },
10
+ };
11
+
12
+ export class ModeBar {
13
+ private container: BoxRenderable;
14
+ private modeText: TextRenderable;
15
+ private statusText: TextRenderable;
16
+ private metricsText: TextRenderable;
17
+ private _current: Mode = "research";
18
+ private _privacy = false;
19
+ private _showMetrics = false;
20
+ private _queueCount = 0;
21
+ private _paused = false;
22
+ private _bgProcessCount = 0;
23
+ private _contextUsed = 0;
24
+ private _contextMax = 200000;
25
+ private _budgetPct = 0;
26
+ private _tier: string = "";
27
+ get currentTier(): string { return this._tier; }
28
+ private onChangeCallback: ((mode: Mode) => void) | null = null;
29
+
30
+ // Session metrics
31
+ private _tokensIn = 0;
32
+ private _tokensOut = 0;
33
+ private _totalCost = 0;
34
+ private _requestCount = 0;
35
+ private _streamStartTime = 0;
36
+ private _currentStreamTokens = 0;
37
+ private _lastTokPerSec = 0;
38
+
39
+ constructor(private renderer: CliRenderer) {
40
+ this.container = new BoxRenderable(renderer, {
41
+ id: "mode-bar",
42
+ height: 1,
43
+ width: "100%",
44
+ flexDirection: "row",
45
+ alignItems: "center",
46
+ paddingLeft: 2,
47
+ paddingRight: 2,
48
+ flexShrink: 0,
49
+ });
50
+
51
+ // Active mode (single label, colored)
52
+ this.modeText = new TextRenderable(renderer, {
53
+ id: "mode-label", content: "", fg: COLORS.textMuted,
54
+ });
55
+ this.container.add(this.modeText);
56
+
57
+ // Status: generating / queue count / paused
58
+ this.statusText = new TextRenderable(renderer, {
59
+ id: "mode-status", content: "", fg: COLORS.textMuted,
60
+ });
61
+ this.container.add(this.statusText);
62
+
63
+ this.container.add(new BoxRenderable(renderer, { id: "mode-spacer", flexGrow: 1 }));
64
+
65
+ // Metrics (right-aligned)
66
+ this.metricsText = new TextRenderable(renderer, {
67
+ id: "mode-metrics", content: "", fg: COLORS.borderDim,
68
+ });
69
+ this.container.add(this.metricsText);
70
+
71
+ this.updateDisplay();
72
+ }
73
+
74
+ get renderable(): BoxRenderable { return this.container; }
75
+ get current(): Mode { return this._current; }
76
+ get privacyEnabled(): boolean { return this._privacy; }
77
+ get metricsEnabled(): boolean { return this._showMetrics; }
78
+
79
+ onChange(cb: (mode: Mode) => void): void { this.onChangeCallback = cb; }
80
+
81
+ cycle(): void {
82
+ const modes: Mode[] = ["research", "strategy", "portfolio"];
83
+ const idx = modes.indexOf(this._current);
84
+ this._current = modes[(idx + 1) % modes.length]!;
85
+ this.updateDisplay();
86
+ this.onChangeCallback?.(this._current);
87
+ }
88
+
89
+ setMode(mode: Mode): void {
90
+ if (this._current === mode) return;
91
+ this._current = mode;
92
+ this.updateDisplay();
93
+ this.onChangeCallback?.(this._current);
94
+ }
95
+
96
+ // ── Context tracking ──
97
+
98
+ setBudgetUsage(pct: number, tier: string): void {
99
+ this._budgetPct = pct;
100
+ this._tier = tier;
101
+ if (this._showMetrics) {
102
+ this.updateMetricsDisplay();
103
+ this.renderer.requestRender();
104
+ }
105
+ }
106
+
107
+ setContextUsage(used: number, max?: number): void {
108
+ this._contextUsed = used;
109
+ if (max) this._contextMax = max;
110
+ if (this._showMetrics) {
111
+ this.updateMetricsDisplay();
112
+ this.renderer.requestRender();
113
+ }
114
+ }
115
+
116
+ get contextPct(): number {
117
+ return this._contextMax > 0 ? this._contextUsed / this._contextMax : 0;
118
+ }
119
+
120
+ // ── Status indicators ──
121
+
122
+ setQueueCount(count: number): void {
123
+ this._queueCount = count;
124
+ this.updateStatus();
125
+ }
126
+
127
+ setPaused(paused: boolean): void {
128
+ this._paused = paused;
129
+ this.updateStatus();
130
+ }
131
+
132
+ setBgProcessCount(count: number): void {
133
+ this._bgProcessCount = count;
134
+ this.updateStatus();
135
+ }
136
+
137
+ togglePrivacy(): void { this._privacy = !this._privacy; this.updateDisplay(); }
138
+ toggleMetrics(): void { this._showMetrics = !this._showMetrics; this.updateDisplay(); }
139
+
140
+ // ── Stream tracking ──
141
+
142
+ streamStart(): void {
143
+ this._streamStartTime = Date.now();
144
+ this._currentStreamTokens = 0;
145
+ this.updateStatus();
146
+ }
147
+
148
+ streamDelta(chars: number): void {
149
+ this._currentStreamTokens += Math.round(chars / 4);
150
+ if (this._showMetrics) {
151
+ const elapsed = (Date.now() - this._streamStartTime) / 1000;
152
+ if (elapsed > 0.5) {
153
+ this._lastTokPerSec = Math.round(this._currentStreamTokens / elapsed);
154
+ this.updateMetricsDisplay();
155
+ this.renderer.requestRender();
156
+ }
157
+ }
158
+ }
159
+
160
+ streamEnd(inputText: string, outputText: string): void {
161
+ const tokIn = Math.round(inputText.length / 4);
162
+ const tokOut = Math.round(outputText.length / 4);
163
+ const elapsed = (Date.now() - this._streamStartTime) / 1000;
164
+ this._tokensIn += tokIn;
165
+ this._tokensOut += tokOut;
166
+ this._totalCost += (tokIn * 0.000003) + (tokOut * 0.000015);
167
+ this._requestCount++;
168
+ this._lastTokPerSec = elapsed > 0 ? Math.round(tokOut / elapsed) : 0;
169
+ this.updateDisplay();
170
+ this.renderer.requestRender();
171
+ }
172
+
173
+ resetUsage(): void {
174
+ this._tokensIn = 0; this._tokensOut = 0; this._totalCost = 0;
175
+ this._requestCount = 0; this._lastTokPerSec = 0;
176
+ this.updateDisplay();
177
+ }
178
+
179
+ // ── Rendering ──
180
+
181
+ private updateDisplay(): void {
182
+ const m = MODE_CONFIG[this._current];
183
+ this.modeText.content = ` ${m.icon} ${m.label} `;
184
+ this.modeText.fg = "#212121";
185
+ this.modeText.bg = m.color;
186
+ this.updateStatus();
187
+ this.updateMetricsDisplay();
188
+ }
189
+
190
+ private updateStatus(): void {
191
+ const parts: string[] = [];
192
+ if (this._paused) {
193
+ parts.push("paused -- /resume to continue");
194
+ } else if (this._queueCount > 0) {
195
+ parts.push(`${this._queueCount} queued`);
196
+ }
197
+ if (this._bgProcessCount > 0) {
198
+ parts.push(`${this._bgProcessCount} running`);
199
+ }
200
+ this.statusText.content = parts.length > 0 ? ` ${parts.join(" ")}` : "";
201
+ this.statusText.fg = this._paused ? COLORS.warning : this._bgProcessCount > 0 ? COLORS.success : COLORS.textMuted;
202
+ }
203
+
204
+ private updateMetricsDisplay(): void {
205
+ if (!this._showMetrics) { this.metricsText.content = ""; return; }
206
+ if (this._requestCount === 0 && this._contextUsed === 0) {
207
+ this.metricsText.content = "no usage yet";
208
+ this.metricsText.fg = COLORS.borderDim;
209
+ return;
210
+ }
211
+
212
+ const totalTok = this._tokensIn + this._tokensOut;
213
+ const parts: string[] = [];
214
+
215
+ // Tier badge
216
+ if (this._tier) {
217
+ parts.push(this._tier.toUpperCase());
218
+ }
219
+
220
+ // Budget bar
221
+ if (this._budgetPct > 0 || this._tier) {
222
+ const bFilled = Math.round(this._budgetPct * 8);
223
+ const bBar = "\u2588".repeat(bFilled) + "\u2591".repeat(8 - bFilled);
224
+ const bPctStr = `${Math.round(this._budgetPct * 100)}%`;
225
+ parts.push(`[${bBar}] ${bPctStr}`);
226
+ }
227
+
228
+ // Context bar: [████░░░░] 34%
229
+ const pct = this.contextPct;
230
+ const barW = 10;
231
+ const filled = Math.round(pct * barW);
232
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(barW - filled);
233
+ const pctStr = `${Math.round(pct * 100)}%`;
234
+ const barColor = pct > 0.8 ? COLORS.error : pct > 0.6 ? COLORS.warning : COLORS.textMuted;
235
+ parts.push(`ctx [${bar}] ${pctStr}`);
236
+
237
+ if (totalTok > 0) parts.push(`${totalTok.toLocaleString()} tok`);
238
+ if (this._totalCost > 0) parts.push(`$${this._totalCost.toFixed(4)}`);
239
+ if (this._requestCount > 0) parts.push(`${this._requestCount} req`);
240
+ if (this._lastTokPerSec > 0) parts.push(`${this._lastTokPerSec} t/s`);
241
+
242
+ this.metricsText.content = parts.join(" · ");
243
+ this.metricsText.fg = barColor;
244
+ }
245
+ }
@@ -0,0 +1,294 @@
1
+ import { BoxRenderable, TextRenderable, InputRenderable, type CliRenderer } from "@opentui/core";
2
+ import { COLORS } from "../theme/colors.ts";
3
+ import { ICONS } from "../theme/icons.ts";
4
+ import { store } from "../state/store.ts";
5
+
6
+ function timeAgo(ts: number): string {
7
+ const diff = Date.now() - ts;
8
+ if (diff < 60000) return "now";
9
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
10
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
11
+ return `${Math.floor(diff / 86400000)}d`;
12
+ }
13
+
14
+ export class SessionPanel {
15
+ private panel: BoxRenderable;
16
+ private listBox: BoxRenderable;
17
+ private renameInput: InputRenderable | null = null;
18
+ private _visible = false;
19
+ private selectedIndex = 0;
20
+ private sortedSessions: ReturnType<typeof store.get>["sessions"] = [];
21
+ private confirmingDelete = false;
22
+ private _renaming = false;
23
+ private renamingSessionId: string | null = null;
24
+ private onSelectCallback: ((sessionId: string) => void) | null = null;
25
+ private onNewCallback: (() => void) | null = null;
26
+ private onDeleteCallback: ((sessionId: string) => void) | null = null;
27
+ private onRenameCallback: ((sessionId: string, name: string) => void) | null = null;
28
+ private onPinCallback: ((sessionId: string) => void) | null = null;
29
+ private onOpenTabCallback: ((sessionId: string) => void) | null = null;
30
+ private onRenameEndCallback: (() => void) | null = null;
31
+
32
+ constructor(private renderer: CliRenderer) {
33
+ this.panel = new BoxRenderable(renderer, {
34
+ id: "session-panel",
35
+ position: "absolute",
36
+ left: 0,
37
+ top: 0,
38
+ width: 34,
39
+ height: "100%",
40
+ backgroundColor: COLORS.bgDarker,
41
+ flexDirection: "column",
42
+ border: ["right"],
43
+ borderStyle: "single",
44
+ borderColor: COLORS.borderDim,
45
+ paddingTop: 1,
46
+ paddingLeft: 1,
47
+ paddingRight: 1,
48
+ });
49
+ this.panel.visible = false;
50
+
51
+ this.panel.add(new TextRenderable(renderer, {
52
+ id: "session-title",
53
+ content: "chats",
54
+ fg: COLORS.textMuted,
55
+ attributes: 1,
56
+ marginBottom: 1,
57
+ }));
58
+
59
+ this.listBox = new BoxRenderable(renderer, {
60
+ id: "session-list",
61
+ flexDirection: "column",
62
+ flexGrow: 1,
63
+ });
64
+ this.panel.add(this.listBox);
65
+
66
+ this.panel.add(new TextRenderable(renderer, {
67
+ id: "session-hints",
68
+ content: "enter switch o open n new d del r rename p pin",
69
+ fg: COLORS.borderDim,
70
+ marginTop: 1,
71
+ }));
72
+
73
+ renderer.root.add(this.panel);
74
+ }
75
+
76
+ get visible(): boolean { return this._visible; }
77
+ get isRenaming(): boolean { return this._renaming; }
78
+
79
+ onSelect(cb: (sessionId: string) => void): void { this.onSelectCallback = cb; }
80
+ onNew(cb: () => void): void { this.onNewCallback = cb; }
81
+ onDelete(cb: (sessionId: string) => void): void { this.onDeleteCallback = cb; }
82
+ onRename(cb: (sessionId: string, name: string) => void): void { this.onRenameCallback = cb; }
83
+ onPin(cb: (sessionId: string) => void): void { this.onPinCallback = cb; }
84
+ onOpenTab(cb: (sessionId: string) => void): void { this.onOpenTabCallback = cb; }
85
+ onRenameEnd(cb: () => void): void { this.onRenameEndCallback = cb; }
86
+
87
+ toggle(): void { if (this._visible) this.hide(); else this.show(); }
88
+
89
+ show(): void {
90
+ this._visible = true;
91
+ this.panel.visible = true;
92
+ this.confirmingDelete = false;
93
+ this.exitRename();
94
+ this.selectedIndex = 0;
95
+ this.updateList(); // populate sortedSessions first
96
+ const state = store.get();
97
+ const idx = this.sortedSessions.findIndex((s) => s.id === state.activeSessionId);
98
+ if (idx >= 0) this.selectedIndex = idx;
99
+ this.updateList(); // re-render with correct selection
100
+ this.renderer.requestRender();
101
+ }
102
+
103
+ hide(): void {
104
+ this._visible = false;
105
+ this.panel.visible = false;
106
+ this.exitRename();
107
+ this.renderer.requestRender();
108
+ }
109
+
110
+ navigate(delta: number): void {
111
+ if (this._renaming) return;
112
+ if (this.sortedSessions.length === 0) return;
113
+ this.confirmingDelete = false;
114
+ this.selectedIndex = Math.max(0, Math.min(this.sortedSessions.length - 1, this.selectedIndex + delta));
115
+ this.updateList();
116
+ this.renderer.requestRender();
117
+ }
118
+
119
+ selectCurrent(): void {
120
+ if (this._renaming) return;
121
+ const session = this.sortedSessions[this.selectedIndex];
122
+ if (!session) return;
123
+ if (this.confirmingDelete) {
124
+ this.onDeleteCallback?.(session.id);
125
+ this.confirmingDelete = false;
126
+ if (this.selectedIndex > 0) this.selectedIndex--;
127
+ this.updateList();
128
+ return;
129
+ }
130
+ this.onSelectCallback?.(session.id);
131
+ this.hide();
132
+ }
133
+
134
+ createNew(): void {
135
+ this.onNewCallback?.();
136
+ this.updateList();
137
+ // New session is most recent, should be first in sorted (by updatedAt desc)
138
+ this.selectedIndex = 0;
139
+ this.updateList();
140
+ this.renderer.requestRender();
141
+ }
142
+
143
+ handleAction(key: string): void {
144
+ if (key === "rename-cancel") {
145
+ this.exitRename();
146
+ this.updateList();
147
+ this.renderer.requestRender();
148
+ return;
149
+ }
150
+ if (this._renaming) return;
151
+ const session = this.sortedSessions[this.selectedIndex];
152
+ if (!session) return;
153
+
154
+ if (key === "d") {
155
+ if (this.confirmingDelete) {
156
+ this.onDeleteCallback?.(session.id);
157
+ this.confirmingDelete = false;
158
+ if (this.selectedIndex > 0) this.selectedIndex--;
159
+ } else {
160
+ this.confirmingDelete = true;
161
+ }
162
+ this.updateList();
163
+ this.renderer.requestRender();
164
+ } else if (key === "p") {
165
+ this.onPinCallback?.(session.id);
166
+ this.updateList();
167
+ this.renderer.requestRender();
168
+ } else if (key === "o") {
169
+ // Open in a new tab (keeps current tab open)
170
+ this.onOpenTabCallback?.(session.id);
171
+ this.hide();
172
+ } else if (key === "r") {
173
+ this._renaming = true;
174
+ this.renamingSessionId = session.id;
175
+ this.confirmingDelete = false;
176
+ this.updateList();
177
+ this.renderer.requestRender();
178
+ }
179
+ }
180
+
181
+ private exitRename(): void {
182
+ this._renaming = false;
183
+ this.renamingSessionId = null;
184
+ this.renameInput = null;
185
+ this.onRenameEndCallback?.();
186
+ }
187
+
188
+ // Compat stubs
189
+ completeRename(_: string): boolean { return false; }
190
+ cancelRename(): void { this.exitRename(); }
191
+
192
+ private updateList(): void {
193
+ for (const child of this.listBox.getChildren()) this.listBox.remove(child.id);
194
+
195
+ const state = store.get();
196
+ this.sortedSessions = [...state.sessions].sort((a, b) => {
197
+ const aPinned = a.pinned ? 1 : 0;
198
+ const bPinned = b.pinned ? 1 : 0;
199
+ if (aPinned !== bPinned) return bPinned - aPinned;
200
+ return b.updatedAt - a.updatedAt;
201
+ });
202
+
203
+ let lastWasPinned = false;
204
+
205
+ for (let i = 0; i < this.sortedSessions.length; i++) {
206
+ const session = this.sortedSessions[i]!;
207
+ const isSelected = i === this.selectedIndex;
208
+ const isActive = session.id === state.activeSessionId;
209
+ const isOpen = state.openTabIds.includes(session.id);
210
+ const isPinned = !!session.pinned;
211
+ const msgCount = session.messages.filter((m) => m.role === "user").length;
212
+ const ago = timeAgo(session.updatedAt);
213
+
214
+ if (lastWasPinned && !isPinned) {
215
+ this.listBox.add(new TextRenderable(this.renderer, {
216
+ id: `session-sep-${i}`,
217
+ content: " " + "\u2500".repeat(28),
218
+ fg: COLORS.borderDim,
219
+ }));
220
+ }
221
+ lastWasPinned = isPinned;
222
+
223
+ const pointer = isSelected ? ICONS.pointer : " ";
224
+ const dot = isActive ? ICONS.running : isOpen ? ICONS.dot : " ";
225
+ const pin = isPinned ? "* " : " ";
226
+ const name = session.name.length > 14 ? session.name.slice(0, 14) + ".." : session.name.padEnd(16);
227
+ const meta = `${msgCount > 0 ? String(msgCount).padStart(2) : " "} ${ago.padStart(3)}`;
228
+
229
+ let content = `${pointer}${dot}${pin}${name} ${meta}`;
230
+ let fg = isSelected ? COLORS.text : COLORS.textMuted;
231
+
232
+ if (isSelected && this.confirmingDelete) {
233
+ content = `${pointer} delete? d=yes esc=no`;
234
+ fg = COLORS.error as any;
235
+ }
236
+
237
+ this.listBox.add(new TextRenderable(this.renderer, {
238
+ id: `session-item-${i}`,
239
+ content,
240
+ fg,
241
+ attributes: isSelected ? 1 : 0,
242
+ }));
243
+
244
+ // Insert rename input right below the selected session
245
+ if (isSelected && this._renaming && this.renamingSessionId === session.id) {
246
+ const renameRow = new BoxRenderable(this.renderer, {
247
+ id: `session-rename-${i}`,
248
+ flexDirection: "row",
249
+ height: 1,
250
+ width: "100%",
251
+ paddingLeft: 4,
252
+ });
253
+
254
+ renameRow.add(new TextRenderable(this.renderer, {
255
+ id: `session-rename-label-${i}`,
256
+ content: "> ",
257
+ fg: COLORS.accent,
258
+ }));
259
+
260
+ this.renameInput = new InputRenderable(this.renderer, {
261
+ id: `session-rename-input-${i}`,
262
+ placeholder: "new name",
263
+ textColor: COLORS.text,
264
+ placeholderColor: COLORS.borderDim,
265
+ flexGrow: 1,
266
+ });
267
+
268
+ this.renameInput.value = "";
269
+
270
+ this.renameInput.on("enter", () => {
271
+ const newName = this.renameInput?.value.trim();
272
+ if (newName && this.renamingSessionId) {
273
+ this.onRenameCallback?.(this.renamingSessionId, newName);
274
+ }
275
+ this.exitRename();
276
+ this.updateList();
277
+ this.renderer.requestRender();
278
+ });
279
+
280
+ renameRow.add(this.renameInput);
281
+ this.listBox.add(renameRow);
282
+
283
+ // Defer focus to next tick so the renderable is fully mounted
284
+ const input = this.renameInput;
285
+ const r = this.renderer;
286
+ setTimeout(() => {
287
+ r.focusRenderable(input);
288
+ input.focus();
289
+ r.requestRender();
290
+ }, 10);
291
+ }
292
+ }
293
+ }
294
+ }