Skip to content

Commit 33c2462

Browse files
committed
Release v0.0.19
## What's New ### Features - Added Cmd+Enter shortcut for "Implement plan" button - Improved archive popover: auto-unarchive on selection, input clears on revisit
1 parent c384541 commit 33c2462

26 files changed

+1619
-251
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.18",
3+
"version": "0.0.19",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": "21st.dev",

src/main/lib/claude/transform.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { UIMessageChunk, MessageMetadata } from "./types"
1+
import type { UIMessageChunk, MessageMetadata, MCPServerStatus, MCPServer } from "./types"
22

33
export function createTransformer() {
44
let textId: string | null = null
@@ -73,6 +73,11 @@ export function createTransformer() {
7373
}
7474

7575
return function* transform(msg: any): Generator<UIMessageChunk> {
76+
// Debug: log all message types to understand what SDK sends
77+
if (msg.type === "system") {
78+
console.log("[transform] SYSTEM message:", msg.subtype, msg)
79+
}
80+
7681
// Track parent_tool_use_id for nested tools
7782
// Only update when explicitly present (don't reset on messages without it)
7883
if (msg.parent_tool_use_id !== undefined) {
@@ -323,6 +328,36 @@ export function createTransformer() {
323328

324329
// ===== SYSTEM STATUS (compacting, etc.) =====
325330
if (msg.type === "system") {
331+
// Session init - extract MCP servers, plugins, tools
332+
if (msg.subtype === "init") {
333+
console.log("[MCP Transform] Received SDK init message:", {
334+
tools: msg.tools?.length,
335+
mcp_servers: msg.mcp_servers,
336+
plugins: msg.plugins,
337+
skills: msg.skills?.length,
338+
})
339+
// Map MCP servers with validated status type and additional info
340+
const mcpServers: MCPServer[] = (msg.mcp_servers || []).map(
341+
(s: { name: string; status: string; serverInfo?: { name: string; version: string }; error?: string }) => ({
342+
name: s.name,
343+
status: (["connected", "failed", "pending", "needs-auth"].includes(
344+
s.status,
345+
)
346+
? s.status
347+
: "pending") as MCPServerStatus,
348+
...(s.serverInfo && { serverInfo: s.serverInfo }),
349+
...(s.error && { error: s.error }),
350+
}),
351+
)
352+
yield {
353+
type: "session-init",
354+
tools: msg.tools || [],
355+
mcpServers,
356+
plugins: msg.plugins || [],
357+
skills: msg.skills || [],
358+
}
359+
}
360+
326361
// Compacting status - show as a tool
327362
if (msg.subtype === "status" && msg.status === "compacting") {
328363
// Create unique ID and save for matching with boundary event

src/main/lib/claude/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ export type UIMessageChunk =
4444
toolCallId: string
4545
state: "input-streaming" | "output-available"
4646
}
47+
// Session initialization (MCP servers, plugins, tools)
48+
| {
49+
type: "session-init"
50+
tools: string[]
51+
mcpServers: MCPServer[]
52+
plugins: { name: string; path: string }[]
53+
skills: string[]
54+
}
55+
56+
export type MCPServerStatus = "connected" | "failed" | "pending" | "needs-auth"
57+
58+
export type MCPServer = {
59+
name: string
60+
status: MCPServerStatus
61+
serverInfo?: {
62+
name: string
63+
version: string
64+
}
65+
error?: string
66+
}
4767

4868
export type MessageMetadata = {
4969
sessionId?: string

src/main/lib/trpc/routers/claude.ts

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { observable } from "@trpc/server/observable"
22
import { eq } from "drizzle-orm"
3-
import { app, safeStorage } from "electron"
3+
import { app, safeStorage, BrowserWindow } from "electron"
44
import path from "path"
55
import * as os from "os"
66
import * as fs from "fs/promises"
@@ -18,23 +18,25 @@ import { publicProcedure, router } from "../index"
1818
import { buildAgentsOption } from "./agent-utils"
1919

2020
/**
21-
* Parse @[agent:name] and @[skill:name] mentions from prompt text
22-
* Returns the cleaned prompt and lists of mentioned agents/skills
21+
* Parse @[agent:name], @[skill:name], and @[tool:name] mentions from prompt text
22+
* Returns the cleaned prompt and lists of mentioned agents/skills/tools
2323
*/
2424
function parseMentions(prompt: string): {
2525
cleanedPrompt: string
2626
agentMentions: string[]
2727
skillMentions: string[]
2828
fileMentions: string[]
2929
folderMentions: string[]
30+
toolMentions: string[]
3031
} {
3132
const agentMentions: string[] = []
3233
const skillMentions: string[] = []
3334
const fileMentions: string[] = []
3435
const folderMentions: string[] = []
36+
const toolMentions: string[] = []
3537

3638
// Match @[prefix:name] pattern
37-
const mentionRegex = /@\[(file|folder|skill|agent):([^\]]+)\]/g
39+
const mentionRegex = /@\[(file|folder|skill|agent|tool):([^\]]+)\]/g
3840
let match
3941

4042
while ((match = mentionRegex.exec(prompt)) !== null) {
@@ -52,17 +54,34 @@ function parseMentions(prompt: string): {
5254
case "folder":
5355
folderMentions.push(name)
5456
break
57+
case "tool":
58+
// Validate tool name format: only alphanumeric, underscore, hyphen allowed
59+
// This prevents prompt injection via malicious tool names
60+
if (/^[a-zA-Z0-9_-]+$/.test(name)) {
61+
toolMentions.push(name)
62+
}
63+
break
5564
}
5665
}
5766

58-
// Clean agent/skill mentions from prompt (they will be added as context)
67+
// Clean agent/skill/tool mentions from prompt (they will be added as context or hints)
5968
// Keep file/folder mentions as they are useful context
60-
const cleanedPrompt = prompt
69+
let cleanedPrompt = prompt
6170
.replace(/@\[agent:[^\]]+\]/g, "")
6271
.replace(/@\[skill:[^\]]+\]/g, "")
72+
.replace(/@\[tool:[^\]]+\]/g, "")
6373
.trim()
6474

65-
return { cleanedPrompt, agentMentions, skillMentions, fileMentions, folderMentions }
75+
// Add tool usage hints if tools were mentioned
76+
// Tool names are already validated to contain only safe characters
77+
if (toolMentions.length > 0) {
78+
const toolHints = toolMentions
79+
.map((t) => `Use the ${t} tool for this request.`)
80+
.join(" ")
81+
cleanedPrompt = `${toolHints}\n\n${cleanedPrompt}`
82+
}
83+
84+
return { cleanedPrompt, agentMentions, skillMentions, fileMentions, folderMentions, toolMentions }
6685
}
6786

6887
/**
@@ -149,6 +168,7 @@ export const claudeRouter = router({
149168
chatId: z.string(),
150169
prompt: z.string(),
151170
cwd: z.string(),
171+
projectPath: z.string().optional(), // Original project path for MCP config lookup
152172
mode: z.enum(["plan", "agent"]).default("agent"),
153173
sessionId: z.string().optional(),
154174
model: z.string().optional(),
@@ -379,6 +399,9 @@ export const claudeRouter = router({
379399
input.subChatId
380400
)
381401

402+
// MCP servers to pass to SDK (read from ~/.claude.json)
403+
let mcpServersForSdk: Record<string, any> | undefined
404+
382405
// Ensure isolated config dir exists and symlink skills/agents from ~/.claude/
383406
// This is needed because SDK looks for skills at $CLAUDE_CONFIG_DIR/skills/
384407
try {
@@ -413,6 +436,37 @@ export const claudeRouter = router({
413436
} catch (symlinkErr) {
414437
// Ignore symlink errors (might already exist or permission issues)
415438
}
439+
440+
// Read MCP servers from ~/.claude.json for the original project path
441+
// These will be passed directly to the SDK via options.mcpServers
442+
const claudeJsonSource = path.join(os.homedir(), ".claude.json")
443+
try {
444+
const claudeJsonSourceExists = await fs.stat(claudeJsonSource).then(() => true).catch(() => false)
445+
446+
if (claudeJsonSourceExists) {
447+
// Read original config
448+
const originalConfig = JSON.parse(await fs.readFile(claudeJsonSource, "utf-8"))
449+
450+
// Look for project-specific MCP config using original project path
451+
// Config structure: { "projects": { "/path/to/project": { "mcpServers": {...} } } }
452+
const lookupPath = input.projectPath || input.cwd
453+
const projectConfig = originalConfig.projects?.[lookupPath]
454+
455+
// Debug logging
456+
console.log(`[claude] MCP config lookup: lookupPath=${lookupPath}, found=${!!projectConfig?.mcpServers}`)
457+
if (projectConfig?.mcpServers) {
458+
console.log(`[claude] MCP servers found: ${Object.keys(projectConfig.mcpServers).join(", ")}`)
459+
// Store MCP servers to pass to SDK
460+
mcpServersForSdk = projectConfig.mcpServers
461+
} else {
462+
// Log available project paths in config for debugging
463+
const projectPaths = Object.keys(originalConfig.projects || {}).filter(k => originalConfig.projects[k]?.mcpServers)
464+
console.log(`[claude] No MCP servers for ${lookupPath}. Config has MCP for: ${projectPaths.join(", ") || "(none)"}`)
465+
}
466+
}
467+
} catch (configErr) {
468+
console.error(`[claude] Failed to read MCP config:`, configErr)
469+
}
416470
} catch (mkdirErr) {
417471
console.error(`[claude] Failed to setup isolated config dir:`, mkdirErr)
418472
}
@@ -423,14 +477,16 @@ export const claudeRouter = router({
423477
...(claudeCodeToken && {
424478
CLAUDE_CODE_OAUTH_TOKEN: claudeCodeToken,
425479
}),
426-
// Isolate Claude's config/session storage per subChat
480+
// Re-enable CLAUDE_CONFIG_DIR now that we properly map MCP configs
427481
CLAUDE_CONFIG_DIR: isolatedConfigDir,
428482
}
429483

430484
// Get bundled Claude binary path
431485
const claudeBinaryPath = getBundledClaudeBinaryPath()
432486

433487
const resumeSessionId = input.sessionId || existingSessionId || undefined
488+
console.log(`[SD] Query options - cwd: ${input.cwd}, projectPath: ${input.projectPath || "(not set)"}, mcpServers: ${mcpServersForSdk ? Object.keys(mcpServersForSdk).join(", ") : "(none)"}`)
489+
434490
const queryOptions = {
435491
prompt,
436492
options: {
@@ -442,6 +498,8 @@ export const claudeRouter = router({
442498
},
443499
// Register mentioned agents with SDK via options.agents
444500
...(Object.keys(agentsOption).length > 0 && { agents: agentsOption }),
501+
// Pass MCP servers from original project config directly to SDK
502+
...(mcpServersForSdk && { mcpServers: mcpServersForSdk }),
445503
env: finalEnv,
446504
permissionMode:
447505
input.mode === "plan"
@@ -657,6 +715,18 @@ export const claudeRouter = router({
657715
currentSessionId = msgAny.session_id // Share with cleanup
658716
}
659717

718+
// Debug: Log system messages from SDK
719+
if (msgAny.type === "system") {
720+
// Full log to see all fields including MCP errors
721+
console.log(`[SD] SYSTEM message: subtype=${msgAny.subtype}`, JSON.stringify({
722+
cwd: msgAny.cwd,
723+
mcp_servers: msgAny.mcp_servers,
724+
tools: msgAny.tools,
725+
plugins: msgAny.plugins,
726+
permissionMode: msgAny.permissionMode,
727+
}, null, 2))
728+
}
729+
660730
// Transform and emit + accumulate
661731
for (const chunk of transform(msg)) {
662732
chunkCount++
@@ -707,6 +777,21 @@ export const claudeRouter = router({
707777
if (toolPart) {
708778
toolPart.result = chunk.output
709779
toolPart.state = "result"
780+
781+
// Notify renderer about file changes for Write/Edit tools
782+
if (toolPart.type === "tool-Write" || toolPart.type === "tool-Edit") {
783+
const filePath = toolPart.input?.file_path
784+
if (filePath) {
785+
const windows = BrowserWindow.getAllWindows()
786+
for (const win of windows) {
787+
win.webContents.send("file-changed", {
788+
filePath,
789+
type: toolPart.type,
790+
subChatId: input.subChatId
791+
})
792+
}
793+
}
794+
}
710795
}
711796
// Stop streaming after ExitPlanMode completes in plan mode
712797
// Match by toolCallId since toolName is undefined in output chunks
@@ -724,6 +809,22 @@ export const claudeRouter = router({
724809
case "message-metadata":
725810
metadata = { ...metadata, ...chunk.messageMetadata }
726811
break
812+
case "system-Compact":
813+
// Add system-Compact to parts so it renders in the chat
814+
// Find existing part by toolCallId or add new one
815+
const existingCompact = parts.find(
816+
(p) => p.type === "system-Compact" && p.toolCallId === chunk.toolCallId
817+
)
818+
if (existingCompact) {
819+
existingCompact.state = chunk.state
820+
} else {
821+
parts.push({
822+
type: "system-Compact",
823+
toolCallId: chunk.toolCallId,
824+
state: chunk.state,
825+
})
826+
}
827+
break
727828
}
728829
// Break from chunk loop if plan is done
729830
if (planCompleted) {
@@ -953,6 +1054,47 @@ export const claudeRouter = router({
9531054
})
9541055
}),
9551056

1057+
/**
1058+
* Get MCP servers configuration for a project
1059+
* This allows showing MCP servers in UI before starting a chat session
1060+
*/
1061+
getMcpConfig: publicProcedure
1062+
.input(z.object({ projectPath: z.string() }))
1063+
.query(async ({ input }) => {
1064+
const claudeJsonPath = path.join(os.homedir(), ".claude.json")
1065+
1066+
try {
1067+
const exists = await fs.stat(claudeJsonPath).then(() => true).catch(() => false)
1068+
if (!exists) {
1069+
return { mcpServers: [], projectPath: input.projectPath }
1070+
}
1071+
1072+
const configContent = await fs.readFile(claudeJsonPath, "utf-8")
1073+
const config = JSON.parse(configContent)
1074+
1075+
// Look for project-specific MCP config
1076+
const projectConfig = config.projects?.[input.projectPath]
1077+
1078+
if (!projectConfig?.mcpServers) {
1079+
return { mcpServers: [], projectPath: input.projectPath }
1080+
}
1081+
1082+
// Convert to array format with names
1083+
const mcpServers = Object.entries(projectConfig.mcpServers).map(([name, serverConfig]) => ({
1084+
name,
1085+
// Status will be "pending" until SDK actually connects
1086+
status: "pending" as const,
1087+
// Include config details for display (command, args, etc)
1088+
config: serverConfig as Record<string, unknown>,
1089+
}))
1090+
1091+
return { mcpServers, projectPath: input.projectPath }
1092+
} catch (error) {
1093+
console.error("[getMcpConfig] Error reading config:", error)
1094+
return { mcpServers: [], projectPath: input.projectPath, error: String(error) }
1095+
}
1096+
}),
1097+
9561098
/**
9571099
* Cancel active session
9581100
*/

src/preload/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ contextBridge.exposeInMainWorld("desktopApi", {
138138
ipcRenderer.on("shortcut:new-agent", handler)
139139
return () => ipcRenderer.removeListener("shortcut:new-agent", handler)
140140
},
141+
142+
// File change events (from Claude Write/Edit tools)
143+
onFileChanged: (callback: (data: { filePath: string; type: string; subChatId: string }) => void) => {
144+
const handler = (_event: unknown, data: { filePath: string; type: string; subChatId: string }) => callback(data)
145+
ipcRenderer.on("file-changed", handler)
146+
return () => ipcRenderer.removeListener("file-changed", handler)
147+
},
141148
})
142149

143150
// Type definitions
@@ -213,6 +220,8 @@ export interface DesktopApi {
213220
onAuthError: (callback: (error: string) => void) => () => void
214221
// Shortcuts
215222
onShortcutNewAgent: (callback: () => void) => () => void
223+
// File changes
224+
onFileChanged: (callback: (data: { filePath: string; type: string; subChatId: string }) => void) => () => void
216225
}
217226

218227
declare global {

0 commit comments

Comments
 (0)