horizon-code 0.6.0 → 0.6.2
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/package.json +1 -1
- package/src/ai/client.ts +13 -6
- package/src/ai/system-prompt.ts +1 -1
- package/src/app.ts +47 -16
- package/src/chat/renderer.ts +106 -24
- package/src/components/code-panel.ts +15 -8
- package/src/platform/auth.ts +3 -3
- package/src/platform/session-sync.ts +3 -3
- package/src/platform/supabase.ts +10 -9
- package/src/platform/sync.ts +9 -1
- package/src/research/apis.ts +70 -25
- package/src/research/widgets.ts +34 -30
- package/src/strategy/prompts.ts +80 -557
package/package.json
CHANGED
package/src/ai/client.ts
CHANGED
|
@@ -32,7 +32,7 @@ export async function initMCP(): Promise<{ toolCount: number }> {
|
|
|
32
32
|
});
|
|
33
33
|
mcpTools = await mcpClient.tools();
|
|
34
34
|
return { toolCount: Object.keys(mcpTools).length };
|
|
35
|
-
} catch { return { toolCount: 0 }; }
|
|
35
|
+
} catch (e) { console.error("[mcp] init failed:", e); return { toolCount: 0 }; }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export async function closeMCP(): Promise<void> {
|
|
@@ -141,7 +141,7 @@ async function* consumeSSE(res: Response): AsyncGenerator<Record<string, any>> {
|
|
|
141
141
|
if (!line.startsWith("data: ")) continue;
|
|
142
142
|
const data = line.slice(6);
|
|
143
143
|
if (data === "[DONE]") return;
|
|
144
|
-
try { yield JSON.parse(data); } catch {}
|
|
144
|
+
try { yield JSON.parse(data); } catch (e) { console.error("[sse] malformed JSON:", data.slice(0, 100)); }
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
}
|
|
@@ -162,7 +162,7 @@ function toolsToServerFormat(tools: Record<string, any>): Record<string, any> {
|
|
|
162
162
|
const converted = zodSchema(tool.parameters);
|
|
163
163
|
params = converted.jsonSchema ?? params;
|
|
164
164
|
}
|
|
165
|
-
} catch {}
|
|
165
|
+
} catch (e) { failures++; console.error("[tools] schema conversion failed for", name, e); }
|
|
166
166
|
result[name] = { description: tool.description ?? "", parameters: params };
|
|
167
167
|
}
|
|
168
168
|
return result;
|
|
@@ -273,6 +273,7 @@ export async function* chat(
|
|
|
273
273
|
let emittedResponseLen = 0;
|
|
274
274
|
let emittedCode: string | null = null;
|
|
275
275
|
let structuredActive = useStructuredOutput;
|
|
276
|
+
let emittedAnyText = false;
|
|
276
277
|
const pendingToolCalls: { toolName: string; args: Record<string, unknown> }[] = [];
|
|
277
278
|
let eventCount = 0;
|
|
278
279
|
|
|
@@ -297,6 +298,7 @@ export async function* chat(
|
|
|
297
298
|
if (trimmed.length > 3 && !trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
298
299
|
structuredActive = false;
|
|
299
300
|
yield { type: "text-delta", textDelta: jsonBuf };
|
|
301
|
+
emittedAnyText = true;
|
|
300
302
|
jsonBuf = "";
|
|
301
303
|
continue;
|
|
302
304
|
}
|
|
@@ -304,6 +306,7 @@ export async function* chat(
|
|
|
304
306
|
const response = extractJsonStringField(jsonBuf, "response");
|
|
305
307
|
if (response && response.length > emittedResponseLen) {
|
|
306
308
|
yield { type: "text-delta", textDelta: response.slice(emittedResponseLen) };
|
|
309
|
+
emittedAnyText = true;
|
|
307
310
|
emittedResponseLen = response.length;
|
|
308
311
|
}
|
|
309
312
|
|
|
@@ -314,6 +317,7 @@ export async function* chat(
|
|
|
314
317
|
}
|
|
315
318
|
} else {
|
|
316
319
|
yield { type: "text-delta", textDelta: delta };
|
|
320
|
+
emittedAnyText = true;
|
|
317
321
|
}
|
|
318
322
|
} else if (event.type === "tool-call") {
|
|
319
323
|
hasToolCalls = true;
|
|
@@ -339,15 +343,17 @@ export async function* chat(
|
|
|
339
343
|
// the model likely responded with plain text — flush jsonBuf as text
|
|
340
344
|
if (structuredActive && jsonBuf && emittedResponseLen === 0) {
|
|
341
345
|
yield { type: "text-delta", textDelta: jsonBuf };
|
|
346
|
+
emittedAnyText = true;
|
|
342
347
|
}
|
|
343
348
|
|
|
344
349
|
// If no tool calls, we're done
|
|
345
350
|
if (!hasToolCalls || pendingToolCalls.length === 0) {
|
|
346
351
|
if (eventCount === 0) {
|
|
347
352
|
yield { type: "error", message: "No response from server. Check your connection or try again." };
|
|
348
|
-
} else if (!
|
|
349
|
-
// Server sent events (meta, usage) but no actual content
|
|
350
|
-
|
|
353
|
+
} else if (!emittedAnyText && !hasToolCalls && emittedCode === null) {
|
|
354
|
+
// Server sent events (meta, usage) but no actual text or code content
|
|
355
|
+
console.error("[chat] No content received. Events:", eventCount, "jsonBuf:", jsonBuf.slice(0, 200));
|
|
356
|
+
yield { type: "text-delta", textDelta: "*(No response received. Try again.)*" };
|
|
351
357
|
}
|
|
352
358
|
yield { type: "finish" };
|
|
353
359
|
return;
|
|
@@ -391,6 +397,7 @@ export async function* chat(
|
|
|
391
397
|
if (structuredActive) {
|
|
392
398
|
jsonBuf = "";
|
|
393
399
|
emittedResponseLen = 0;
|
|
400
|
+
emittedAnyText = false;
|
|
394
401
|
}
|
|
395
402
|
|
|
396
403
|
// Loop back — server will call OpenRouter again with the full conversation including tool results
|
package/src/ai/system-prompt.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function getSystemPrompt(mode: Mode, verbosity: string = "normal"): strin
|
|
|
131
131
|
const { buildProfileContext } = require("../platform/profile.ts");
|
|
132
132
|
const profileCtx = buildProfileContext();
|
|
133
133
|
if (profileCtx) prompt += "\n\n" + profileCtx;
|
|
134
|
-
} catch {}
|
|
134
|
+
} catch (e) { console.error("[prompt] profile context failed:", e); }
|
|
135
135
|
// Inject active strategy context so research tools auto-scope
|
|
136
136
|
const draft = store.getActiveSession()?.strategyDraft;
|
|
137
137
|
if (draft?.code) {
|
package/src/app.ts
CHANGED
|
@@ -223,7 +223,7 @@ export class App {
|
|
|
223
223
|
for (const s of dbSessions) {
|
|
224
224
|
await deleteDbSession(s.id);
|
|
225
225
|
}
|
|
226
|
-
} catch {}
|
|
226
|
+
} catch (e: any) { console.error("[app] delete chats failed:", e?.message); }
|
|
227
227
|
this.showSystemMsg("All chats deleted.");
|
|
228
228
|
this.settingsPanel.hide();
|
|
229
229
|
});
|
|
@@ -461,7 +461,7 @@ export class App {
|
|
|
461
461
|
this.keyHandler.codePanelVisible = this.codePanel.visible;
|
|
462
462
|
|
|
463
463
|
this.renderer.requestRender();
|
|
464
|
-
} catch {}
|
|
464
|
+
} catch (e: any) { console.error("[app] state listener error:", e?.message); }
|
|
465
465
|
});
|
|
466
466
|
|
|
467
467
|
renderer.on("resize", () => renderer.requestRender());
|
|
@@ -578,7 +578,7 @@ export class App {
|
|
|
578
578
|
this.codePanel.appendLog(r.actionTaken);
|
|
579
579
|
}
|
|
580
580
|
}
|
|
581
|
-
} catch {}
|
|
581
|
+
} catch (e: any) { console.error("[app] alert check failed:", e?.message); }
|
|
582
582
|
})();
|
|
583
583
|
})(localMetricsData);
|
|
584
584
|
|
|
@@ -590,7 +590,7 @@ export class App {
|
|
|
590
590
|
for (const [pid] of runningProcesses) {
|
|
591
591
|
if (isRecording(pid)) recordMetrics(pid, metrics as any);
|
|
592
592
|
}
|
|
593
|
-
} catch {}
|
|
593
|
+
} catch (e: any) { console.error("[app] replay record failed:", e?.message); }
|
|
594
594
|
})();
|
|
595
595
|
})(localMetricsData);
|
|
596
596
|
} else if (this._hasLocalMetrics && alive === 0) {
|
|
@@ -720,11 +720,11 @@ export class App {
|
|
|
720
720
|
this.showSystemMsg(`Logged in as ${loginResult.email}`);
|
|
721
721
|
// Start session sync + platform sync now that we have a live session
|
|
722
722
|
import("./platform/session-sync.ts").then(({ loadSessions, startAutoSave }) => {
|
|
723
|
-
loadSessions().catch(() =>
|
|
723
|
+
loadSessions().catch((e: any) => console.error("[app] session/sync init failed:", e?.message));
|
|
724
724
|
startAutoSave();
|
|
725
|
-
}).catch(() =>
|
|
725
|
+
}).catch((e: any) => console.error("[app] session/sync init failed:", e?.message));
|
|
726
726
|
import("./platform/sync.ts").then(({ platformSync }) => {
|
|
727
|
-
platformSync.start(30000).catch(() =>
|
|
727
|
+
platformSync.start(30000).catch((e: any) => console.error("[app] platformSync start failed:", e?.message));
|
|
728
728
|
});
|
|
729
729
|
} else {
|
|
730
730
|
this.showSystemMsg(`Login failed: ${loginResult.error}`);
|
|
@@ -1079,15 +1079,19 @@ export class App {
|
|
|
1079
1079
|
const { hasLiveSession } = await import("./platform/supabase.ts");
|
|
1080
1080
|
const live = await hasLiveSession();
|
|
1081
1081
|
|
|
1082
|
+
const { loadSessions, startAutoSave } = await import("./platform/session-sync.ts");
|
|
1082
1083
|
if (live) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1084
|
+
await loadSessions().catch((e) => {
|
|
1085
|
+
this.splash.setLoading(`Failed to load chats: ${e?.message ?? "unknown error"}`);
|
|
1086
|
+
});
|
|
1085
1087
|
startAutoSave();
|
|
1088
|
+
} else {
|
|
1089
|
+
this.splash.setLoading("Session expired -- type /login to restore your chats");
|
|
1086
1090
|
}
|
|
1087
1091
|
|
|
1088
1092
|
// Start platform sync (works with API key)
|
|
1089
1093
|
const { platformSync } = await import("./platform/sync.ts");
|
|
1090
|
-
platformSync.start(30000).catch(() =>
|
|
1094
|
+
platformSync.start(30000).catch((e: any) => console.error("[app] platformSync start failed:", e?.message));
|
|
1091
1095
|
|
|
1092
1096
|
// Final status
|
|
1093
1097
|
const firstTime = !cfg.has_launched;
|
|
@@ -1128,6 +1132,10 @@ export class App {
|
|
|
1128
1132
|
store.update({ sessions });
|
|
1129
1133
|
for (const [id] of this.messageRenderables) this.scrollBox.remove(`msg-${id}`);
|
|
1130
1134
|
this.messageRenderables.clear();
|
|
1135
|
+
this.codePanel.clearWidgets();
|
|
1136
|
+
this.codePanel.setLogs("");
|
|
1137
|
+
this.codePanel.setCode("", "none");
|
|
1138
|
+
this.codePanel.setMetrics(null);
|
|
1131
1139
|
this.updateContextMeter();
|
|
1132
1140
|
this.inputBar.focus();
|
|
1133
1141
|
this.renderer.requestRender();
|
|
@@ -1471,6 +1479,11 @@ export class App {
|
|
|
1471
1479
|
currentBlocks.push({ type: "tool-call", toolName: part.toolName });
|
|
1472
1480
|
rebuildContainer(currentBlocks, "streaming");
|
|
1473
1481
|
|
|
1482
|
+
// Clear widgets placeholder early so user doesn't see stale text while tool executes
|
|
1483
|
+
if (WIDGET_TOOLS.has(part.toolName) && this.settingsPanel.settings.widgetsInTab) {
|
|
1484
|
+
this.codePanel.clearPlaceholder();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1474
1487
|
// Execute before-hooks for this tool
|
|
1475
1488
|
const beforeEvent = TOOL_BEFORE_HOOK[part.toolName];
|
|
1476
1489
|
if (beforeEvent) {
|
|
@@ -1494,13 +1507,20 @@ export class App {
|
|
|
1494
1507
|
}
|
|
1495
1508
|
} else if (part.type === "tool-result") {
|
|
1496
1509
|
// Replace the spinning tool-call with a completed tool-result (same line, not stacked)
|
|
1510
|
+
// Include toolResult so renderer can detect errors and show brief explanations
|
|
1511
|
+
const hasError = part.result && typeof part.result === "object" && "error" in (part.result as any);
|
|
1512
|
+
const resultBlock: import("./chat/types.ts").ContentBlock = {
|
|
1513
|
+
type: "tool-result",
|
|
1514
|
+
toolName: part.toolName,
|
|
1515
|
+
...(hasError ? { toolResult: part.result } : {}),
|
|
1516
|
+
};
|
|
1497
1517
|
const callIdx = currentBlocks.findIndex(
|
|
1498
1518
|
(b) => b.type === "tool-call" && b.toolName === part.toolName,
|
|
1499
1519
|
);
|
|
1500
1520
|
if (callIdx !== -1) {
|
|
1501
|
-
currentBlocks[callIdx] =
|
|
1521
|
+
currentBlocks[callIdx] = resultBlock;
|
|
1502
1522
|
} else {
|
|
1503
|
-
currentBlocks.push(
|
|
1523
|
+
currentBlocks.push(resultBlock);
|
|
1504
1524
|
}
|
|
1505
1525
|
|
|
1506
1526
|
if (WIDGET_TOOLS.has(part.toolName)) {
|
|
@@ -1631,6 +1651,13 @@ export class App {
|
|
|
1631
1651
|
// Server already recorded usage — just update the UI
|
|
1632
1652
|
this.updateBudgetMeter();
|
|
1633
1653
|
} else if (part.type === "meta") {
|
|
1654
|
+
// Server is processing — transition from "thinking" to "streaming"
|
|
1655
|
+
// so the header shows the orbit spinner instead of the static thinking block
|
|
1656
|
+
const hasThinking = currentBlocks.some((b) => b.type === "thinking");
|
|
1657
|
+
if (hasThinking) {
|
|
1658
|
+
currentBlocks = [];
|
|
1659
|
+
rebuildContainer(currentBlocks, "streaming");
|
|
1660
|
+
}
|
|
1634
1661
|
// Server tells us the tier, model, budget state
|
|
1635
1662
|
this.modeBar.setBudgetUsage(
|
|
1636
1663
|
part.budgetTotal > 0 ? part.budgetUsed / part.budgetTotal : 0,
|
|
@@ -1660,9 +1687,13 @@ export class App {
|
|
|
1660
1687
|
}
|
|
1661
1688
|
}
|
|
1662
1689
|
|
|
1663
|
-
const finalBlocks = currentBlocks
|
|
1664
|
-
b.type
|
|
1665
|
-
|
|
1690
|
+
const finalBlocks = currentBlocks
|
|
1691
|
+
.filter((b) => b.type !== "thinking")
|
|
1692
|
+
.map((b) => b.type === "markdown" ? { ...b, text: fullText || "*(no response)*" } : b);
|
|
1693
|
+
// If everything was filtered out (only had thinking), show no-response
|
|
1694
|
+
if (finalBlocks.length === 0) {
|
|
1695
|
+
finalBlocks.push({ type: "markdown", text: "*(no response)*" });
|
|
1696
|
+
}
|
|
1666
1697
|
store.updateMessageIn(sessionId, msgId, { content: finalBlocks, status: "complete" });
|
|
1667
1698
|
rebuildContainer(finalBlocks, "complete");
|
|
1668
1699
|
|
|
@@ -1774,7 +1805,7 @@ export class App {
|
|
|
1774
1805
|
const { saveActiveSession, stopAutoSave } = await import("./platform/session-sync.ts");
|
|
1775
1806
|
stopAutoSave();
|
|
1776
1807
|
await saveActiveSession();
|
|
1777
|
-
} catch {}
|
|
1808
|
+
} catch (e: any) { console.error("[app] shutdown save failed:", e?.message); }
|
|
1778
1809
|
if (dashboard.running) dashboard.stop();
|
|
1779
1810
|
cleanupStrategyProcesses();
|
|
1780
1811
|
destroyTreeSitterClient();
|
package/src/chat/renderer.ts
CHANGED
|
@@ -33,7 +33,7 @@ const syntaxStyle = SyntaxStyle.fromStyles({
|
|
|
33
33
|
"@punctuation.special": { fg: h("#D7BA7D") },
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
// Braille spinner frames
|
|
36
|
+
// Braille spinner frames (tool calls)
|
|
37
37
|
const BRAILLE = [
|
|
38
38
|
"\u2801", "\u2803", "\u2807", "\u280f",
|
|
39
39
|
"\u281f", "\u283f", "\u287f", "\u28ff",
|
|
@@ -41,6 +41,11 @@ const BRAILLE = [
|
|
|
41
41
|
"\u28e0", "\u28c0", "\u2880", "\u2800",
|
|
42
42
|
];
|
|
43
43
|
|
|
44
|
+
// Dot orbit spinner for header (different visual rhythm)
|
|
45
|
+
const ORBIT = [
|
|
46
|
+
"\u25DC", "\u25DD", "\u25DE", "\u25DF", // ◜ ◝ ◞ ◟
|
|
47
|
+
];
|
|
48
|
+
|
|
44
49
|
// Tool name → human-readable label
|
|
45
50
|
function toolLabel(name: string): string {
|
|
46
51
|
return name.replace(/_/g, " ");
|
|
@@ -62,9 +67,14 @@ export class ChatRenderer {
|
|
|
62
67
|
this.spinnerTimer = setInterval(() => {
|
|
63
68
|
if (this.spinnerNodes.length === 0) return;
|
|
64
69
|
this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE.length;
|
|
65
|
-
const
|
|
70
|
+
const brailleChar = BRAILLE[this.spinnerFrame]!;
|
|
71
|
+
const orbitChar = ORBIT[this.spinnerFrame % ORBIT.length]!;
|
|
66
72
|
for (const node of this.spinnerNodes) {
|
|
67
|
-
node.
|
|
73
|
+
if (node.id.includes("stream-spin")) {
|
|
74
|
+
node.content = ` ${orbitChar}`;
|
|
75
|
+
} else {
|
|
76
|
+
node.content = `${brailleChar} `;
|
|
77
|
+
}
|
|
68
78
|
}
|
|
69
79
|
this.renderer.requestRender();
|
|
70
80
|
}, 60);
|
|
@@ -80,6 +90,7 @@ export class ChatRenderer {
|
|
|
80
90
|
|
|
81
91
|
renderMessage(message: Message): BoxRenderable {
|
|
82
92
|
const isUser = message.role === "user";
|
|
93
|
+
const isStreaming = message.status === "streaming" || message.status === "thinking";
|
|
83
94
|
|
|
84
95
|
const box = new BoxRenderable(this.renderer, {
|
|
85
96
|
id: `msg-${message.id}`,
|
|
@@ -92,16 +103,60 @@ export class ChatRenderer {
|
|
|
92
103
|
});
|
|
93
104
|
|
|
94
105
|
if (message.role === "user" || message.role === "assistant") {
|
|
95
|
-
|
|
106
|
+
const headerBox = new BoxRenderable(this.renderer, {
|
|
107
|
+
id: `msg-header-${message.id}`,
|
|
108
|
+
flexDirection: "row",
|
|
109
|
+
width: "100%",
|
|
110
|
+
});
|
|
111
|
+
headerBox.add(new TextRenderable(this.renderer, {
|
|
96
112
|
id: `msg-label-${message.id}`,
|
|
97
113
|
content: isUser ? "U S E R" : "H O R I Z O N",
|
|
98
114
|
fg: isUser ? COLORS.textMuted : COLORS.borderDim,
|
|
99
115
|
}));
|
|
116
|
+
|
|
117
|
+
// Streaming indicator next to the role label — always visible at top
|
|
118
|
+
if (!isUser && isStreaming) {
|
|
119
|
+
const streamSpinner = new TextRenderable(this.renderer, {
|
|
120
|
+
id: `msg-stream-spin-${message.id}`,
|
|
121
|
+
content: ` ${ORBIT[0]}`,
|
|
122
|
+
fg: COLORS.accent,
|
|
123
|
+
});
|
|
124
|
+
this.spinnerNodes.push(streamSpinner);
|
|
125
|
+
headerBox.add(streamSpinner);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
box.add(headerBox);
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
// Render tool blocks first, then content blocks (text/markdown/widgets)
|
|
132
|
+
// This ensures tools are always at the top, generation text at the bottom
|
|
133
|
+
const toolBlocks: { block: ContentBlock; idx: number }[] = [];
|
|
134
|
+
const contentBlocks: { block: ContentBlock; idx: number }[] = [];
|
|
135
|
+
|
|
102
136
|
for (let i = 0; i < message.content.length; i++) {
|
|
103
137
|
const block = message.content[i]!;
|
|
104
|
-
|
|
138
|
+
if (block.type === "tool-call" || block.type === "tool-result") {
|
|
139
|
+
toolBlocks.push({ block, idx: i });
|
|
140
|
+
} else {
|
|
141
|
+
contentBlocks.push({ block, idx: i });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const { block, idx } of toolBlocks) {
|
|
146
|
+
const renderable = this.renderBlock(block, message, idx);
|
|
147
|
+
if (renderable) box.add(renderable);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Add spacing between tool section and content
|
|
151
|
+
if (toolBlocks.length > 0 && contentBlocks.length > 0) {
|
|
152
|
+
box.add(new TextRenderable(this.renderer, {
|
|
153
|
+
id: `msg-tool-spacer-${message.id}`,
|
|
154
|
+
content: "",
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const { block, idx } of contentBlocks) {
|
|
159
|
+
const renderable = this.renderBlock(block, message, idx);
|
|
105
160
|
if (renderable) box.add(renderable);
|
|
106
161
|
}
|
|
107
162
|
|
|
@@ -144,12 +199,19 @@ export class ChatRenderer {
|
|
|
144
199
|
case "thinking":
|
|
145
200
|
return this.renderThinking(message.id, index);
|
|
146
201
|
|
|
147
|
-
case "error":
|
|
202
|
+
case "error": {
|
|
203
|
+
const rawErr = block.text ?? "Unknown error";
|
|
204
|
+
// Clean up error text: first line only, remove stack traces and verbose prefixes
|
|
205
|
+
const cleanErr = rawErr
|
|
206
|
+
.replace(/^Error:\s*/i, "")
|
|
207
|
+
.replace(/\n[\s\S]*/, "")
|
|
208
|
+
.slice(0, 120);
|
|
148
209
|
return new TextRenderable(this.renderer, {
|
|
149
210
|
id: `msg-err-${message.id}-${index}`,
|
|
150
|
-
content:
|
|
211
|
+
content: `\u2716 ${cleanErr}`,
|
|
151
212
|
fg: COLORS.error,
|
|
152
213
|
});
|
|
214
|
+
}
|
|
153
215
|
|
|
154
216
|
default:
|
|
155
217
|
return null;
|
|
@@ -179,7 +241,7 @@ export class ChatRenderer {
|
|
|
179
241
|
return box;
|
|
180
242
|
}
|
|
181
243
|
|
|
182
|
-
// Tool in-progress: braille spinner + name
|
|
244
|
+
// Tool in-progress: accent braille spinner + name
|
|
183
245
|
private renderToolCall(block: ContentBlock, msgId: string, index: number): BoxRenderable {
|
|
184
246
|
const box = new BoxRenderable(this.renderer, {
|
|
185
247
|
id: `msg-tool-${msgId}-${index}`,
|
|
@@ -189,7 +251,7 @@ export class ChatRenderer {
|
|
|
189
251
|
const spinner = new TextRenderable(this.renderer, {
|
|
190
252
|
id: `msg-tool-icon-${msgId}-${index}`,
|
|
191
253
|
content: `${BRAILLE[0]} `,
|
|
192
|
-
fg: COLORS.
|
|
254
|
+
fg: COLORS.accent,
|
|
193
255
|
});
|
|
194
256
|
this.spinnerNodes.push(spinner);
|
|
195
257
|
box.add(spinner);
|
|
@@ -197,34 +259,54 @@ export class ChatRenderer {
|
|
|
197
259
|
box.add(new TextRenderable(this.renderer, {
|
|
198
260
|
id: `msg-tool-name-${msgId}-${index}`,
|
|
199
261
|
content: toolLabel(block.toolName ?? "tool"),
|
|
200
|
-
fg: COLORS.
|
|
262
|
+
fg: COLORS.text,
|
|
201
263
|
}));
|
|
202
264
|
|
|
203
265
|
return box;
|
|
204
266
|
}
|
|
205
267
|
|
|
206
|
-
// Tool completed:
|
|
268
|
+
// Tool completed: green check or red error with brief explanation
|
|
207
269
|
private renderToolResult(block: ContentBlock, msgId: string, index: number): BoxRenderable {
|
|
208
270
|
const box = new BoxRenderable(this.renderer, {
|
|
209
271
|
id: `msg-result-${msgId}-${index}`,
|
|
210
272
|
flexDirection: "row",
|
|
211
|
-
marginBottom: 1,
|
|
212
273
|
});
|
|
213
274
|
|
|
214
275
|
const hasError = block.toolResult && typeof block.toolResult === "object" && "error" in (block.toolResult as any);
|
|
215
|
-
box.add(new TextRenderable(this.renderer, {
|
|
216
|
-
id: `msg-result-icon-${msgId}-${index}`,
|
|
217
|
-
content: hasError ? "x " : ". ",
|
|
218
|
-
fg: hasError ? COLORS.error : COLORS.success,
|
|
219
|
-
}));
|
|
220
276
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
277
|
+
if (hasError) {
|
|
278
|
+
// Red x + tool name + brief, human-readable error
|
|
279
|
+
box.add(new TextRenderable(this.renderer, {
|
|
280
|
+
id: `msg-result-icon-${msgId}-${index}`,
|
|
281
|
+
content: "\u2716 ",
|
|
282
|
+
fg: COLORS.error,
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
const label = toolLabel(block.toolName ?? "tool");
|
|
286
|
+
const rawError = String((block.toolResult as any).error ?? "failed");
|
|
287
|
+
// Clean up common error patterns to be user-friendly
|
|
288
|
+
const briefError = rawError
|
|
289
|
+
.replace(/^Error:\s*/i, "")
|
|
290
|
+
.replace(/\n[\s\S]*/, "") // first line only
|
|
291
|
+
.slice(0, 80);
|
|
292
|
+
box.add(new TextRenderable(this.renderer, {
|
|
293
|
+
id: `msg-result-name-${msgId}-${index}`,
|
|
294
|
+
content: `${label} -- ${briefError}`,
|
|
295
|
+
fg: COLORS.error,
|
|
296
|
+
}));
|
|
297
|
+
} else {
|
|
298
|
+
// Green check + tool name
|
|
299
|
+
box.add(new TextRenderable(this.renderer, {
|
|
300
|
+
id: `msg-result-icon-${msgId}-${index}`,
|
|
301
|
+
content: "\u2713 ",
|
|
302
|
+
fg: COLORS.success,
|
|
303
|
+
}));
|
|
304
|
+
box.add(new TextRenderable(this.renderer, {
|
|
305
|
+
id: `msg-result-name-${msgId}-${index}`,
|
|
306
|
+
content: toolLabel(block.toolName ?? "done"),
|
|
307
|
+
fg: COLORS.textMuted,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
228
310
|
|
|
229
311
|
return box;
|
|
230
312
|
}
|
|
@@ -269,16 +269,22 @@ export class CodePanel {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
private _widgetCount = 0;
|
|
272
|
+
private _placeholderRemoved = false;
|
|
273
|
+
|
|
274
|
+
/** Remove the placeholder text — call when widgets are about to load */
|
|
275
|
+
clearPlaceholder(): void {
|
|
276
|
+
if (this._placeholderRemoved) return;
|
|
277
|
+
this._placeholderRemoved = true;
|
|
278
|
+
const children = this.widgetsList.getChildren();
|
|
279
|
+
for (const child of [...children]) {
|
|
280
|
+
this.widgetsList.remove(child.id);
|
|
281
|
+
}
|
|
282
|
+
this.renderer.requestRender();
|
|
283
|
+
}
|
|
272
284
|
|
|
273
285
|
/** Add a widget renderable to the widgets tab */
|
|
274
286
|
addWidget(toolName: string, widget: BoxRenderable): void {
|
|
275
|
-
|
|
276
|
-
if (this._widgetCount === 0) {
|
|
277
|
-
const children = this.widgetsList.getChildren();
|
|
278
|
-
for (const child of [...children]) {
|
|
279
|
-
this.widgetsList.remove(child);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
287
|
+
this.clearPlaceholder();
|
|
282
288
|
this._widgetCount++;
|
|
283
289
|
|
|
284
290
|
// Separator between widgets
|
|
@@ -307,9 +313,10 @@ export class CodePanel {
|
|
|
307
313
|
clearWidgets(): void {
|
|
308
314
|
const children = this.widgetsList.getChildren();
|
|
309
315
|
for (const child of [...children]) {
|
|
310
|
-
this.widgetsList.remove(child);
|
|
316
|
+
this.widgetsList.remove(child.id);
|
|
311
317
|
}
|
|
312
318
|
this._widgetCount = 0;
|
|
319
|
+
this._placeholderRemoved = false;
|
|
313
320
|
this.widgetsList.add(new TextRenderable(this.renderer, {
|
|
314
321
|
id: `widgets-empty-${Date.now()}`,
|
|
315
322
|
content: "*no widgets yet*\n\n*Enable \"Widgets in Tab\" in /settings.*",
|
package/src/platform/auth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { saveConfig, loadConfig, getApiKey } from "./config.ts";
|
|
2
2
|
import { platform } from "./client.ts";
|
|
3
3
|
|
|
4
|
-
export type AuthStatus = "authenticated" | "no_key" | "invalid_key";
|
|
4
|
+
export type AuthStatus = "authenticated" | "no_key" | "invalid_key" | "network_error";
|
|
5
5
|
|
|
6
6
|
export async function checkAuth(): Promise<AuthStatus> {
|
|
7
7
|
const key = getApiKey();
|
|
@@ -13,8 +13,8 @@ export async function checkAuth(): Promise<AuthStatus> {
|
|
|
13
13
|
return "authenticated";
|
|
14
14
|
} catch (err: any) {
|
|
15
15
|
if (err?.status === 401 || err?.status === 403) return "invalid_key";
|
|
16
|
-
// Network error
|
|
17
|
-
return "
|
|
16
|
+
// Network error — can't verify, report as network error
|
|
17
|
+
return "network_error";
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -84,7 +84,7 @@ export async function loadSessions(): Promise<void> {
|
|
|
84
84
|
openTabIds: openIds,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
-
} catch {}
|
|
87
|
+
} catch (e: any) { console.error("[session-sync] loadSessions failed:", e?.message ?? e); }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
@@ -132,7 +132,7 @@ export async function saveActiveSession(): Promise<void> {
|
|
|
132
132
|
|
|
133
133
|
// Update session metadata
|
|
134
134
|
await updateDbSession(dbId, { name: session.name }).catch(() => {});
|
|
135
|
-
} catch {}
|
|
135
|
+
} catch (e: any) { console.error("[session-sync] saveActiveSession failed:", e?.message ?? e); }
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
@@ -140,7 +140,7 @@ export async function saveActiveSession(): Promise<void> {
|
|
|
140
140
|
*/
|
|
141
141
|
export async function deleteSessionFromDb(sessionId: string): Promise<void> {
|
|
142
142
|
if (!(await isLoggedIn())) return;
|
|
143
|
-
try { await deleteDbSession(sessionId); } catch {}
|
|
143
|
+
try { await deleteDbSession(sessionId); } catch (e: any) { console.error("[session-sync] deleteSessionFromDb failed:", e?.message ?? e); }
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
package/src/platform/supabase.ts
CHANGED
|
@@ -34,7 +34,8 @@ export function getSupabase(): SupabaseClient {
|
|
|
34
34
|
refresh_token: encryptEnvVar(session.refresh_token),
|
|
35
35
|
};
|
|
36
36
|
config.session_encrypted = true;
|
|
37
|
-
} catch {
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error("[supabase] encryption failed, using plaintext:", e);
|
|
38
39
|
// Fallback to plaintext if encryption fails
|
|
39
40
|
config.supabase_session = {
|
|
40
41
|
access_token: session.access_token,
|
|
@@ -67,7 +68,7 @@ function saveSessionTokens(config: ReturnType<typeof loadConfig>, accessToken: s
|
|
|
67
68
|
config.supabase_session = { access_token: encryptEnvVar(accessToken), refresh_token: encryptEnvVar(refreshToken) };
|
|
68
69
|
config.session_encrypted = true;
|
|
69
70
|
return;
|
|
70
|
-
} catch {}
|
|
71
|
+
} catch (e) { console.error("[supabase] saveSessionTokens encryption failed:", e); }
|
|
71
72
|
}
|
|
72
73
|
config.supabase_session = { access_token: accessToken, refresh_token: refreshToken };
|
|
73
74
|
config.session_encrypted = false;
|
|
@@ -125,7 +126,7 @@ export async function restoreSession(): Promise<boolean> {
|
|
|
125
126
|
if (config.api_key) platform.setApiKey(config.api_key);
|
|
126
127
|
return true;
|
|
127
128
|
}
|
|
128
|
-
} catch {}
|
|
129
|
+
} catch (e) { console.error("[supabase] setSession failed:", e); }
|
|
129
130
|
|
|
130
131
|
// Access token expired — try refreshing with just the refresh token
|
|
131
132
|
try {
|
|
@@ -142,7 +143,7 @@ export async function restoreSession(): Promise<boolean> {
|
|
|
142
143
|
if (config.api_key) platform.setApiKey(config.api_key);
|
|
143
144
|
return true;
|
|
144
145
|
}
|
|
145
|
-
} catch {}
|
|
146
|
+
} catch (e) { console.error("[supabase] refreshSession failed:", e); }
|
|
146
147
|
|
|
147
148
|
// Both failed — don't delete the session, it might work next time
|
|
148
149
|
// (network issue, Supabase outage, etc.). The API key still works for chat.
|
|
@@ -224,7 +225,7 @@ export async function loginWithBrowser(): Promise<{ success: boolean; error?: st
|
|
|
224
225
|
try {
|
|
225
226
|
const apiKey = await createApiKey();
|
|
226
227
|
if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
|
|
227
|
-
} catch {}
|
|
228
|
+
} catch (e) { console.error("[supabase] createApiKey failed:", e); }
|
|
228
229
|
} else {
|
|
229
230
|
platform.setApiKey(config.api_key);
|
|
230
231
|
}
|
|
@@ -235,10 +236,10 @@ export async function loginWithBrowser(): Promise<{ success: boolean; error?: st
|
|
|
235
236
|
}
|
|
236
237
|
if (res.status === 410) return { success: false, error: "Session expired. Type /login to sign in again." };
|
|
237
238
|
if (res.status === 404) return { success: false, error: "Auth session not found." };
|
|
238
|
-
} catch {}
|
|
239
|
+
} catch (e) { console.error("[supabase] poll timeout:", e); }
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch {}
|
|
242
|
+
try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch (e) { console.error("[supabase] cleanup auth session failed:", e); }
|
|
242
243
|
return { success: false, error: "Login timed out (90s). Try /login again." };
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -260,7 +261,7 @@ export async function loginWithPassword(email: string, password: string): Promis
|
|
|
260
261
|
try {
|
|
261
262
|
const apiKey = await createApiKey();
|
|
262
263
|
if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
|
|
263
|
-
} catch {}
|
|
264
|
+
} catch (e) { console.error("[supabase] createApiKey failed:", e); }
|
|
264
265
|
} else {
|
|
265
266
|
platform.setApiKey(config.api_key);
|
|
266
267
|
}
|
|
@@ -295,7 +296,7 @@ async function createApiKey(): Promise<string | null> {
|
|
|
295
296
|
});
|
|
296
297
|
if (error) return null;
|
|
297
298
|
return rawKey;
|
|
298
|
-
} catch { return null; }
|
|
299
|
+
} catch (e) { console.error("[supabase] createApiKey failed:", e); return null; }
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
// ── Logout ──
|