Skip to content

Commit 5347c0c

Browse files
feat(core): implement universal diagnostics and koda cloud infrastructure
- feat: add 'get_diagnostics' tool with dynamic LSP and linter discovery - feat: implement 'Koda Cloud' provider for secure API proxying via Hostzera - feat: add Ctrl+V (paste) support for images in chat input - refactor(ui): modularize App.tsx logic and improve Settings UI layout - fix(ui): ensure JSX parent-element compliance in SettingsUI - feat(api): add dynamic model listing for Koda Cloud provider
1 parent 0016d05 commit 5347c0c

11 files changed

Lines changed: 352 additions & 23 deletions

File tree

Agents Instructions/AGENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Todas as Tools ficam em `src/main/tools/` e **DEVEM** estender `BaseTool` (`base
118118
| `search` | `search.ts` | Leitura | Não |
119119
| `list_dir` | `list-dir.ts` | Leitura | Não |
120120
| `file_find` | `file-find.ts` | Leitura | Não |
121+
| `get_diagnostics` | `diagnostics.ts` | Análise | Não |
121122
| `browser` | `browser.ts` | Leitura | Não |
122123
| `lsp` | `lsp.ts` | Análise | Não |
123124
| `enter_plan_mode` | `plan.ts` | Modo | Apenas no modo `planner` |

src/main/core/agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TogetherProvider } from "../providers/together.js";
1717
import { XAIProvider } from "../providers/xai.js";
1818
import { ZhipuProvider } from "../providers/zhipu.js";
1919
import { MaritacaProvider } from "../providers/maritaca.js";
20+
import { KodaCloudProvider } from "../providers/koda-cloud.js";
2021
import { mcpManager } from "../services/mcp-manager.js";
2122
import { MCPTool } from "../tools/mcp-tool.js";
2223
import { skillManager } from "../services/skill-manager.js";
@@ -74,6 +75,8 @@ export class Agent {
7475
return new ZhipuProvider(model, apiKey, maxTokens, temperature);
7576
case "maritaca":
7677
return new MaritacaProvider(model, apiKey, maxTokens, temperature);
78+
case "koda-cloud":
79+
return new KodaCloudProvider(model);
7780
default:
7881
throw new Error(`Unknown provider: ${provider}`);
7982
}
@@ -336,6 +339,8 @@ export class Agent {
336339
this.settings.provider = "zhipu";
337340
} else if (model.includes("sabia") || model.includes("maritaca")) {
338341
this.settings.provider = "maritaca";
342+
} else if (model.includes("cloud") || model.includes("koda-cloud")) {
343+
this.settings.provider = "koda-cloud";
339344
}
340345

341346
this.provider = await this.createProviderAsync();

src/main/core/prompt-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ ${ctx.workspaceName ? `- **Active Project**: ${ctx.workspaceName}` : ""}
4747
5. **Recursive Problem Solving**: If a tool fails or an error occurs in the shell, analyze the output, hypothesize the fix, and execute a new approach immediately.
4848
6. **Fast Mode Execution**: Unless explicitly instructed to use Planner Mode, you are in Fast Mode. In Fast Mode, you act immediately and autonomously. You must ignore the existence of 'enter_plan_mode' and 'exit_plan_mode' tools.
4949
7. **Read Efficiency**: When dealing with large files (> 300 lines) or looking for specific code, avoid reading the entire file. Always prefer using \`file_read\` with \`start_line\` and \`end_line\` parameters to focus only on the relevant sections. Use \`search\` or \`lsp\` to find the exact line numbers first.
50+
8. **Diagnostic Verification**: ALWAYS call \`get_diagnostics\` after completing any implementation, refactoring, or file edit task. This is the equivalent of checking the "Problems" panel in VS Code. Use the diagnostics output to catch and fix any introduced type errors or lint errors before declaring the task finished.
5051
`.trim();
5152
}
5253

src/main/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,20 @@ ipcMain.handle('agent:getModels', async (event, provider: string, apiKey: string
439439
}
440440
}
441441

442+
if (provider === 'koda-cloud') {
443+
try {
444+
const res = await fetch('http://cn-01.hostzera.com.br:2137/v1/models')
445+
if (res.ok) {
446+
const data = await res.json()
447+
return { success: true, models: data.models || data.data.map((m: any) => m.id) }
448+
}
449+
// Fallback while proxy is being updated
450+
return { success: true, models: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'] }
451+
} catch (err) {
452+
return { success: true, models: ['gemini-1.5-flash', 'gemini-1.5-pro'] }
453+
}
454+
}
455+
442456
return { success: false, error: 'Unknown provider' }
443457
} catch (err: any) {
444458
return { success: false, error: err.message }

src/main/providers/koda-cloud.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { BaseProvider, StreamChunk, Message } from "./base.js";
2+
import { ToolRegistry } from "../tools/index.js";
3+
4+
/**
5+
* KodaCloudProvider — A special provider that tunnels requests to a private
6+
* backend proxy. This allows Koda to provide premium models (like Gemini/Claude)
7+
* without exposing API keys in the client code.
8+
*/
9+
export class KodaCloudProvider extends BaseProvider {
10+
public providerName = "Koda Cloud";
11+
private proxyUrl: string;
12+
13+
constructor(model: string, proxyUrl: string = "http://cn-01.hostzera.com.br:2137/v1/chat") {
14+
// Model name is passed but could be overridden by the proxy
15+
super(model, "", 4096, 0.7);
16+
this.proxyUrl = proxyUrl;
17+
}
18+
19+
async *chat(messages: Message[], tools?: ToolRegistry): AsyncGenerator<StreamChunk> {
20+
try {
21+
const response = await fetch(this.proxyUrl, {
22+
method: "POST",
23+
headers: {
24+
"Content-Type": "application/json",
25+
// You can add a custom auth header here later
26+
"x-koda-client": "desktop-electron"
27+
},
28+
body: JSON.stringify({
29+
model: this.model,
30+
messages,
31+
// If the proxy supports tools, we could pass them here
32+
// tools: tools?.getAll().map(...)
33+
}),
34+
});
35+
36+
if (!response.ok) {
37+
const err = await response.text();
38+
yield { type: "error", error: `Cloud Proxy Error: ${err}` };
39+
return;
40+
}
41+
42+
if (!response.body) {
43+
yield { type: "error", error: "Cloud Proxy returned an empty body" };
44+
return;
45+
}
46+
47+
const reader = response.body.getReader();
48+
const decoder = new TextDecoder();
49+
let buffer = "";
50+
51+
while (true) {
52+
const { done, value } = await reader.read();
53+
if (done) break;
54+
55+
buffer += decoder.decode(value, { stream: true });
56+
const lines = buffer.split("\n");
57+
buffer = lines.pop() || "";
58+
59+
for (const line of lines) {
60+
if (!line.trim() || !line.startsWith("data: ")) continue;
61+
62+
try {
63+
const data = JSON.parse(line.replace("data: ", ""));
64+
65+
// Map the proxy JSON output to StreamChunk
66+
// The proxy should return chunks in a compatible format
67+
if (data.type === "text") {
68+
yield { type: "text", content: data.content };
69+
} else if (data.type === "thought") {
70+
yield { type: "thought", content: data.content };
71+
} else if (data.type === "tool_call_start") {
72+
yield { type: "tool_call_start", toolCall: data.toolCall };
73+
} else if (data.type === "tool_call_end") {
74+
yield { type: "tool_call_end", toolCall: data.toolCall };
75+
}
76+
} catch (e) {
77+
console.error("Error parsing proxy chunk:", e);
78+
}
79+
}
80+
}
81+
82+
yield { type: "done" };
83+
84+
} catch (err: any) {
85+
yield { type: "error", error: `Failed to connect to Koda Cloud: ${err.message}` };
86+
}
87+
}
88+
}

src/main/services/lsp-client.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export class LSPClient {
1010
private connection: rpc.MessageConnection | null = null;
1111
private rootPath: string;
1212
private openedFiles = new Set<string>();
13+
private diagnostics = new Map<string, lsp.Diagnostic[]>();
1314

1415
constructor(rootPath: string) {
1516
this.rootPath = rootPath;
@@ -20,12 +21,14 @@ export class LSPClient {
2021
}
2122

2223
async start(): Promise<void> {
23-
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
24-
this.childProcess = spawn(
25-
cmd,
26-
['typescript-language-server', '--stdio'],
27-
{ cwd: this.rootPath }
28-
);
24+
const config = await this.detectServer();
25+
if (!config) {
26+
console.log("[LSP] No supported language server found for this project.");
27+
return;
28+
}
29+
30+
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
31+
this.childProcess = spawn(config.cmd, config.args, { cwd: this.rootPath });
2932

3033
this.childProcess.stderr?.on('data', (data) => {
3134
console.log(`[LSP] stderr: ${data}`);
@@ -37,6 +40,11 @@ export class LSPClient {
3740
);
3841

3942
this.connection.listen();
43+
44+
// Listen for diagnostics from the server (Universal "Problems" source)
45+
this.connection.onNotification(lsp.PublishDiagnosticsNotification.type.method, (params: lsp.PublishDiagnosticsParams) => {
46+
this.diagnostics.set(params.uri, params.diagnostics);
47+
});
4048

4149
const initParams: lsp.InitializeParams = {
4250
processId: process.pid,
@@ -115,4 +123,36 @@ export class LSPClient {
115123
position: { line: line - 1, character: character - 1 }
116124
});
117125
}
126+
127+
getDiagnostics() {
128+
return Array.from(this.diagnostics.entries()).map(([uri, diags]) => ({
129+
uri,
130+
diagnostics: diags
131+
}));
132+
}
133+
134+
private async detectServer(): Promise<{ cmd: string; args: string[] } | null> {
135+
const { access } = await import("fs/promises");
136+
const hasFile = async (f: string) => {
137+
try { await access(resolve(this.rootPath, f)); return true; } catch { return false; }
138+
};
139+
140+
const isWin = process.platform === 'win32';
141+
const npx = isWin ? 'npx.cmd' : 'npx';
142+
143+
if (await hasFile("tsconfig.json") || await hasFile("package.json")) {
144+
return { cmd: npx, args: ["typescript-language-server", "--stdio"] };
145+
}
146+
if (await hasFile("pyproject.toml") || await hasFile("requirements.txt") || await hasFile("setup.py")) {
147+
return { cmd: npx, args: ["pyright-langserver", "--stdio"] };
148+
}
149+
if (await hasFile("Cargo.toml")) {
150+
return { cmd: "rust-analyzer", args: [] };
151+
}
152+
if (await hasFile("go.mod")) {
153+
return { cmd: "gopls", args: [] };
154+
}
155+
156+
return null;
157+
}
118158
}

src/main/tools/diagnostics.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { exec } from "child_process";
2+
import { resolve, relative } from "path";
3+
import { BaseTool, ToolParameter, ToolResult } from "./base.js";
4+
5+
/**
6+
* get_diagnostics — runs the TypeScript compiler in noEmit mode and parses
7+
* the output into a structured list of errors/warnings, similar to the
8+
* VS Code "Problems" panel. The agent should call this tool after every
9+
* implementation to validate that no type errors were introduced.
10+
*/
11+
export class DiagnosticsTool extends BaseTool {
12+
name = "get_diagnostics";
13+
description =
14+
"Runs a universal diagnostics check on the project. It combines: " +
15+
"1. Real-time diagnostics from the Language Server (LSP) for modified files. " +
16+
"2. Project-specific linter/checker discovery (npm lint, cargo check, go vet, etc). " +
17+
"Use this tool ALWAYS at the end of any implementation to catch problems like VS Code's 'Problems' panel.";
18+
19+
parameters: ToolParameter[] = [
20+
{
21+
name: "path",
22+
type: "string",
23+
description:
24+
"Root directory of the project to check. Defaults to the current working directory.",
25+
required: false,
26+
},
27+
];
28+
29+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
30+
const cwd = resolve(process.cwd(), (args.path as string) || ".");
31+
32+
// Detect what checker to run
33+
const { getTrackedFiles } = await import("../services/file-tracker.js");
34+
const modifiedFiles = getTrackedFiles().filter(f => f.access === "modified");
35+
36+
// 1. Get diagnostics from the Language Server (LSP) — The most "Universal" way
37+
const lspProblems = await this.getLspDiagnostics(cwd, modifiedFiles);
38+
39+
// 2. Discover and run project-specific diagnostic scripts (npm lint, cargo check, etc)
40+
const projectProblems = await this.runProjectDiagnostics(cwd);
41+
42+
const allProblems = [...lspProblems, ...projectProblems];
43+
44+
if (allProblems.length === 0) {
45+
return this.success("✅ No problems found — project is clean.");
46+
}
47+
48+
return this.success(`🔴 Found ${allProblems.length} diagnostic problem(s):\n\n${allProblems.join("\n")}`);
49+
}
50+
51+
/** Gets real-time diagnostics from the LSP client */
52+
private async getLspDiagnostics(cwd: string, files: any[]): Promise<string[]> {
53+
const { LSPClient } = await import("../services/lsp-client.js");
54+
const client = new LSPClient(cwd);
55+
try {
56+
await client.start();
57+
// Open all modified files so the server starts checking them
58+
for (const file of files) {
59+
await client.openFile(file.path);
60+
}
61+
// Wait for server to process and emit diagnostics
62+
await new Promise(resolve => setTimeout(resolve, 2000));
63+
64+
const diagnostics = client.getDiagnostics();
65+
await client.stop();
66+
67+
const results: string[] = [];
68+
for (const item of diagnostics) {
69+
const relPath = relative(cwd, item.uri.replace("file:///", ""));
70+
for (const diag of item.diagnostics) {
71+
const severity = diag.severity === 1 ? "🔴" : "🟡";
72+
results.push(`${severity} ${relPath}:${diag.range.start.line + 1}:${diag.range.start.character + 1}${diag.message}`);
73+
}
74+
}
75+
return results;
76+
} catch {
77+
return [];
78+
}
79+
}
80+
81+
/** Discovers and runs any project-native diagnostic tools */
82+
private async runProjectDiagnostics(cwd: string): Promise<string[]> {
83+
const { readFile } = await import("fs/promises");
84+
const { existsSync } = await import("fs");
85+
const results: string[] = [];
86+
87+
// Try finding lint/check scripts in meta files
88+
try {
89+
// Node.js
90+
if (existsSync(resolve(cwd, "package.json"))) {
91+
const pkg = JSON.parse(await readFile(resolve(cwd, "package.json"), "utf-8"));
92+
const scripts = pkg.scripts || {};
93+
const diagScript = Object.keys(scripts).find(k => k.includes("lint") || k.includes("check") || k.includes("type-check"));
94+
if (diagScript) {
95+
const output = await this.execCommand(`npm run ${diagScript}`, cwd);
96+
if (output) results.push(`[npm run ${diagScript}]:\n${output}`);
97+
}
98+
}
99+
// Rust
100+
if (existsSync(resolve(cwd, "Cargo.toml"))) {
101+
const output = await this.execCommand("cargo check --message-format short", cwd);
102+
if (output) results.push(`[cargo check]:\n${output}`);
103+
}
104+
// Go
105+
if (existsSync(resolve(cwd, "go.mod"))) {
106+
const output = await this.execCommand("go vet ./...", cwd);
107+
if (output) results.push(`[go vet]:\n${output}`);
108+
}
109+
} catch {}
110+
111+
return results;
112+
}
113+
114+
private async execCommand(cmd: string, cwd: string): Promise<string | null> {
115+
return new Promise((resolve) => {
116+
exec(cmd, { cwd, maxBuffer: 5 * 1024 * 1024 }, (error, stdout, stderr) => {
117+
if (error) {
118+
resolve((stdout + stderr).trim());
119+
} else {
120+
resolve(null);
121+
}
122+
});
123+
});
124+
}
125+
126+
/** Helper for tsc parsing as fallback */
127+
private parseTscOutput(lines: string[], cwd: string): any {
128+
// keeping this helper if needed internally
129+
return {};
130+
}
131+
}

src/main/tools/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LSPTool } from "./lsp.js";
1111
import { StartColabTool, SendColabTool, EndColabTool } from "./collaborate.js";
1212
import { EnterPlanModeTool, ExitPlanModeTool } from "./plan.js";
1313
import { LoadSkillTool } from "./skill.js";
14+
import { DiagnosticsTool } from "./diagnostics.js";
1415
import { trackFile } from "../services/file-tracker.js";
1516
import { AppSettings } from "../config/settings.js";
1617
import { resolve } from "path";
@@ -38,10 +39,11 @@ export class ToolRegistry {
3839
this.register(new SendColabTool());
3940
this.register(new EndColabTool());
4041
this.register(new LoadSkillTool());
42+
this.register(new DiagnosticsTool());
4143
}
4244

4345
clearNonCoreTools(): void {
44-
const coreTools = ["file_read", "file_write", "file_edit", "shell", "search", "list_dir", "file_find", "browser", "lsp", "enter_plan_mode", "exit_plan_mode", "kill_pty", "list_pty", "shell_input", "shell_wait", "start_collaboration", "send_to_advisor", "end_collaboration", "load_skill"];
46+
const coreTools = ["file_read", "file_write", "file_edit", "shell", "search", "list_dir", "file_find", "browser", "lsp", "enter_plan_mode", "exit_plan_mode", "kill_pty", "list_pty", "shell_input", "shell_wait", "start_collaboration", "send_to_advisor", "end_collaboration", "load_skill", "get_diagnostics"];
4547
for (const name of this.tools.keys()) {
4648
if (!coreTools.includes(name)) {
4749
this.tools.delete(name);

0 commit comments

Comments
 (0)