Skip to content

Commit dd40cd1

Browse files
committed
Release v0.0.64
Desktop v0.0.64
1 parent ef2e48e commit dd40cd1

41 files changed

Lines changed: 2176 additions & 754 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build/settingsTemplate.png

663 Bytes
Loading

build/settingsTemplate@2x.png

765 Bytes
Loading

bun.lockb

-379 Bytes
Binary file not shown.

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.63",
3+
"version": "0.0.64",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/main/index.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Sentry from "@sentry/electron/main"
2-
import { app, BrowserWindow, Menu, session } from "electron"
2+
import { app, BrowserWindow, Menu, nativeImage, session } from "electron"
33
import { existsSync, readFileSync, readlinkSync, unlinkSync } from "fs"
44
import { createServer } from "http"
55
import { join } from "path"
@@ -603,6 +603,15 @@ if (gotTheLock) {
603603
// Track devtools unlock state (hidden feature - 5 clicks on Beta tab)
604604
let devToolsUnlocked = false
605605

606+
// Menu icons: PNG template for settings (auto light/dark via "Template" suffix),
607+
// macOS native SF Symbol for terminal
608+
const settingsMenuIcon = nativeImage.createFromPath(
609+
join(__dirname, "../../build/settingsTemplate.png")
610+
)
611+
const terminalMenuIcon = process.platform === "darwin"
612+
? nativeImage.createFromNamedImage("terminal")?.resize({ width: 12, height: 12 })
613+
: null
614+
606615
// Function to build and set application menu
607616
const buildMenu = () => {
608617
// Show devtools menu item only in dev mode or when unlocked
@@ -611,7 +620,10 @@ if (gotTheLock) {
611620
{
612621
label: app.name,
613622
submenu: [
614-
{ role: "about", label: "About 1Code" },
623+
{
624+
label: "About 1Code",
625+
click: () => app.showAboutPanel(),
626+
},
615627
{
616628
label: updateAvailable
617629
? `Update to v${availableVersion}...`
@@ -631,10 +643,23 @@ if (gotTheLock) {
631643
},
632644
},
633645
{ type: "separator" },
646+
{
647+
label: "Settings...",
648+
...(settingsMenuIcon && { icon: settingsMenuIcon }),
649+
accelerator: "CmdOrCtrl+,",
650+
click: () => {
651+
const win = getWindow()
652+
if (win) {
653+
win.webContents.send("shortcut:open-settings")
654+
}
655+
},
656+
},
657+
{ type: "separator" },
634658
{
635659
label: isCliInstalled()
636660
? "Uninstall '1code' Command..."
637661
: "Install '1code' Command in PATH...",
662+
...(terminalMenuIcon && { icon: terminalMenuIcon }),
638663
click: async () => {
639664
const { dialog } = await import("electron")
640665
if (isCliInstalled()) {

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

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -359,20 +359,7 @@ function preprocessCodexModelName(params: {
359359
return params.modelId
360360
}
361361

362-
if (params.modelId === "gpt-5.3-codex") {
363-
return "gpt-5.2-codex/high"
364-
}
365-
366-
const gpt53Prefix = "gpt-5.3-codex/"
367-
if (params.modelId.startsWith(gpt53Prefix)) {
368-
const requestedThinking = params.modelId.slice(gpt53Prefix.length)
369-
const supportedThinkingLevels = new Set(["low", "medium", "high", "xhigh"])
370-
const normalizedThinking = supportedThinkingLevels.has(requestedThinking)
371-
? requestedThinking
372-
: "high"
373-
return `gpt-5.2-codex/${normalizedThinking}`
374-
}
375-
362+
// All model IDs now match the real API; pass through as-is
376363
return params.modelId
377364
}
378365

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

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface FileCommand {
1515
source: "user" | "project" | "plugin"
1616
pluginName?: string
1717
path: string
18+
content: string
1819
}
1920

2021
/**
@@ -57,6 +58,7 @@ async function scanCommandsDirectory(
5758
dir: string,
5859
source: "user" | "project" | "plugin",
5960
prefix = "",
61+
basePath?: string,
6062
): Promise<FileCommand[]> {
6163
const commands: FileCommand[] = []
6264

@@ -85,23 +87,37 @@ async function scanCommandsDirectory(
8587
fullPath,
8688
source,
8789
prefix ? `${prefix}:${entry.name}` : entry.name,
90+
basePath,
8891
)
8992
commands.push(...nestedCommands)
9093
} else if (isFile && entry.name.endsWith(".md")) {
9194
const baseName = entry.name.replace(/\.md$/, "")
9295
const fallbackName = prefix ? `${prefix}:${baseName}` : baseName
9396

9497
try {
95-
const content = await fs.readFile(fullPath, "utf-8")
96-
const parsed = parseCommandMd(content)
98+
const rawContent = await fs.readFile(fullPath, "utf-8")
99+
const parsed = parseCommandMd(rawContent)
100+
const { content: body } = matter(rawContent)
97101
const commandName = parsed.name || fallbackName
98102

103+
// Format display path: ~/... for user, relative for project
104+
let displayPath: string
105+
if (source === "project" && basePath) {
106+
displayPath = path.relative(basePath, fullPath)
107+
} else {
108+
const homeDir = os.homedir()
109+
displayPath = fullPath.startsWith(homeDir)
110+
? "~" + fullPath.slice(homeDir.length)
111+
: fullPath
112+
}
113+
99114
commands.push({
100115
name: commandName,
101116
description: parsed.description || "",
102117
argumentHint: parsed.argumentHint,
103118
source,
104-
path: fullPath,
119+
path: displayPath,
120+
content: body.trim(),
105121
})
106122
} catch (err) {
107123
console.warn(`[commands] Failed to read ${fullPath}:`, err)
@@ -115,6 +131,36 @@ async function scanCommandsDirectory(
115131
return commands
116132
}
117133

134+
/**
135+
* Generate command .md content from name, description, and body
136+
*/
137+
function generateCommandMd(command: { name: string; description: string; content: string; argumentHint?: string }): string {
138+
const frontmatter: string[] = []
139+
if (command.description) {
140+
frontmatter.push(`description: ${command.description}`)
141+
}
142+
if (command.argumentHint) {
143+
frontmatter.push(`argument-hint: ${command.argumentHint}`)
144+
}
145+
if (frontmatter.length === 0) {
146+
return command.content
147+
}
148+
return `---\n${frontmatter.join("\n")}\n---\n\n${command.content}`
149+
}
150+
151+
/**
152+
* Resolve the absolute filesystem path of a command given its display path
153+
*/
154+
function resolveCommandPath(displayPath: string, projectPath?: string): string {
155+
if (displayPath.startsWith("~")) {
156+
return path.join(os.homedir(), displayPath.slice(1))
157+
}
158+
if (projectPath && !displayPath.startsWith("/")) {
159+
return path.join(projectPath, displayPath)
160+
}
161+
return displayPath
162+
}
163+
118164
export const commandsRouter = router({
119165
/**
120166
* List all commands from filesystem
@@ -143,6 +189,8 @@ export const commandsRouter = router({
143189
projectCommandsPromise = scanCommandsDirectory(
144190
projectCommandsDir,
145191
"project",
192+
"",
193+
input.projectPath,
146194
)
147195
}
148196

@@ -181,20 +229,129 @@ export const commandsRouter = router({
181229
* Get content of a specific command file (without frontmatter)
182230
*/
183231
getContent: publicProcedure
184-
.input(z.object({ path: z.string() }))
232+
.input(z.object({ path: z.string(), projectPath: z.string().optional() }))
185233
.query(async ({ input }) => {
186234
// Security: prevent path traversal
187235
if (input.path.includes("..")) {
188236
throw new Error("Invalid path")
189237
}
190238

191239
try {
192-
const content = await fs.readFile(input.path, "utf-8")
240+
const absolutePath = resolveCommandPath(input.path, input.projectPath)
241+
const content = await fs.readFile(absolutePath, "utf-8")
193242
const { content: body } = matter(content)
194243
return { content: body.trim() }
195244
} catch (err) {
196245
console.error(`[commands] Failed to read command content:`, err)
197246
return { content: "" }
198247
}
199248
}),
249+
250+
create: publicProcedure
251+
.input(
252+
z.object({
253+
name: z.string(),
254+
description: z.string(),
255+
content: z.string(),
256+
argumentHint: z.string().optional(),
257+
source: z.enum(["user", "project"]),
258+
projectPath: z.string().optional(),
259+
})
260+
)
261+
.mutation(async ({ input }) => {
262+
const safeName = input.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")
263+
if (!safeName) {
264+
throw new Error("Command name must contain at least one alphanumeric character")
265+
}
266+
267+
let targetDir: string
268+
if (input.source === "project") {
269+
if (!input.projectPath) {
270+
throw new Error("Project path required for project commands")
271+
}
272+
targetDir = path.join(input.projectPath, ".claude", "commands")
273+
} else {
274+
targetDir = path.join(os.homedir(), ".claude", "commands")
275+
}
276+
277+
const commandPath = path.join(targetDir, `${safeName}.md`)
278+
279+
// Check if already exists
280+
try {
281+
await fs.access(commandPath)
282+
throw new Error(`Command "${safeName}" already exists`)
283+
} catch (err) {
284+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
285+
throw err
286+
}
287+
}
288+
289+
// Create directory and write command file
290+
await fs.mkdir(targetDir, { recursive: true })
291+
const fileContent = generateCommandMd({
292+
name: safeName,
293+
description: input.description,
294+
content: input.content,
295+
argumentHint: input.argumentHint,
296+
})
297+
await fs.writeFile(commandPath, fileContent, "utf-8")
298+
299+
return {
300+
name: safeName,
301+
path: commandPath,
302+
source: input.source,
303+
}
304+
}),
305+
306+
update: publicProcedure
307+
.input(
308+
z.object({
309+
path: z.string(),
310+
name: z.string(),
311+
description: z.string(),
312+
content: z.string(),
313+
argumentHint: z.string().optional(),
314+
projectPath: z.string().optional(),
315+
})
316+
)
317+
.mutation(async ({ input }) => {
318+
// Security: prevent path traversal
319+
if (input.path.includes("..")) {
320+
throw new Error("Invalid path")
321+
}
322+
323+
const absolutePath = resolveCommandPath(input.path, input.projectPath)
324+
325+
// Verify file exists before writing
326+
await fs.access(absolutePath)
327+
328+
const fileContent = generateCommandMd({
329+
name: input.name,
330+
description: input.description,
331+
content: input.content,
332+
argumentHint: input.argumentHint,
333+
})
334+
await fs.writeFile(absolutePath, fileContent, "utf-8")
335+
336+
return { success: true }
337+
}),
338+
339+
delete: publicProcedure
340+
.input(
341+
z.object({
342+
path: z.string(),
343+
projectPath: z.string().optional(),
344+
})
345+
)
346+
.mutation(async ({ input }) => {
347+
if (input.path.includes("..")) {
348+
throw new Error("Invalid path")
349+
}
350+
351+
const absolutePath = resolveCommandPath(input.path, input.projectPath)
352+
await fs.access(absolutePath)
353+
await fs.unlink(absolutePath)
354+
355+
return { success: true }
356+
}),
200357
})

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,33 @@ export const skillsRouter = router({
275275

276276
await fs.writeFile(absolutePath, fileContent, "utf-8")
277277

278+
return { success: true }
279+
}),
280+
281+
/**
282+
* Delete a skill directory
283+
*/
284+
delete: publicProcedure
285+
.input(
286+
z.object({
287+
path: z.string(),
288+
cwd: z.string().optional(),
289+
})
290+
)
291+
.mutation(async ({ input }) => {
292+
if (input.path.includes("..")) {
293+
throw new Error("Invalid path")
294+
}
295+
296+
const absolutePath = input.cwd && !input.path.startsWith("~") && !input.path.startsWith("/")
297+
? path.join(input.cwd, input.path)
298+
: resolveSkillPath(input.path)
299+
300+
// Skills are directories containing SKILL.md — delete the parent directory
301+
const skillDir = path.dirname(absolutePath)
302+
await fs.access(skillDir)
303+
await fs.rm(skillDir, { recursive: true })
304+
278305
return { success: true }
279306
}),
280307
})

src/main/windows/main.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,36 @@ function registerIpcHandlers(): void {
208208

209209
// New window - optionally open with specific chat/subchat
210210
ipcMain.handle("window:new", (_event, options?: { chatId?: string; subChatId?: string }) => {
211-
createWindow(options)
211+
// If chatId specified, check ownership atomically via focusChatOwner
212+
if (options?.chatId && windowManager.focusChatOwner(options.chatId)) {
213+
return { blocked: true }
214+
}
215+
216+
const win = createWindow(options)
217+
218+
// Pre-claim the chat for the new window
219+
if (options?.chatId) {
220+
windowManager.claimChat(options.chatId, win.id)
221+
}
222+
223+
return { blocked: false }
224+
})
225+
226+
// Chat ownership — prevent same chat open in multiple windows
227+
ipcMain.handle("chat:claim", (event, chatId: string) => {
228+
const win = getWindowFromEvent(event)
229+
if (!win) return { ok: false, ownerStableId: "unknown" }
230+
return windowManager.claimChat(chatId, win.id)
231+
})
232+
233+
ipcMain.handle("chat:release", (event, chatId: string) => {
234+
const win = getWindowFromEvent(event)
235+
if (!win) return
236+
windowManager.releaseChat(chatId, win.id)
237+
})
238+
239+
ipcMain.handle("chat:focus-owner", (_event, chatId: string) => {
240+
return windowManager.focusChatOwner(chatId)
212241
})
213242

214243
// Set window title

0 commit comments

Comments
 (0)