From 71a90ae70e3ff880396363809bd9bfcfd520f30c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 30 Sep 2026 23:40:17 -0700 Subject: [PATCH 1/5] fix(web): hotkey settings record plain keys and Tab (#14548) Co-authored-by: Claude Opus 5.5 (1M context) --- .../KeybindingsSettings.logic.test.ts | 25 +++++++++++++++++++ .../settings/KeybindingsSettings.logic.ts | 4 +-- .../settings/KeybindingsSettings.tsx | 6 +++-- .../settings/useSnapShotShortcutRecorder.tsx | 2 ++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 791ebccfa932..15561e06be83 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -113,6 +113,31 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it.each([ + ["k", "KeyK", "k"], + ["Tab", "Tab", "tab"], + ["F5", "F5", "f5"], + ])("captures %s without a modifier", (key, code, expected) => { + const noModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + expect(keybindingFromKeyboardEvent({ key, code, ...noModifiers }, "MacIntel")).toBe(expected); + }); + + it("waits for a key when only a modifier is pressed", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "Meta", + code: "MetaLeft", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toBeNull(); + }); + it.each([ ["@", "Digit2", "mod+shift+2"], ['"', "Digit2", "mod+shift+2"], diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a1f445b5db82..4f41e3755382 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -358,6 +358,7 @@ function normalizeShortcutKeyToken(key: string): string | null { return null; } +/** Turns a keydown into a binding such as `mod+shift+k` or `tab`. Null for modifier-only presses. */ export function keybindingFromKeyboardEvent( event: Pick, platform: string, @@ -375,9 +376,6 @@ export function keybindingFromKeyboardEvent( } if (event.altKey) parts.push("alt"); if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } parts.push(keyToken); return parts.join("+"); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 9d768d7fd41c..bf14e709dda1 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -768,7 +768,8 @@ function useKeybindingRowEditor({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: row.key, isRecording: false }); @@ -1083,7 +1084,8 @@ function useNewKeybindingDraft({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: "", isRecording: false }); diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx index 8f377842b68d..50fd3b338210 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx @@ -106,6 +106,8 @@ export function useSnapShotShortcutRecorder({ } return; } + // A global shortcut without a modifier would take that key from every app. + if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) return; const input = keybindingFromKeyboardEvent(event, navigator.platform); if (!input) return; const next = parseKeybindingShortcut(input); From 41a8239849d7e6523c08edb363efdeccfb150be5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 00:01:39 -0700 Subject: [PATCH 2/5] fix(release): Windows CLI smoke test no longer fails on temp dir cleanup (#14553) Co-authored-by: Claude Opus 5.5 (1M context) --- scripts/smoke-cli-archive.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index cce14d5654f1..0af024c0ec92 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -15,6 +15,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { Command, Flag } from "effect/unstable/cli"; @@ -73,7 +74,18 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { const path = yield* Path.Path; const platform = yield* HostProcessPlatform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-smoke-" }); + const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" }); + // Windows can keep t3.exe locked (EBUSY) for a moment after the server + // exits. A leftover scratch directory on a CI runner is harmless, so + // cleanup retries briefly and never fails a smoke test that passed. + yield* Effect.addFinalizer(() => + fs.remove(scratch, { recursive: true }).pipe( + Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }), + Effect.catch((error) => + Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`), + ), + ), + ); // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU // tar; use the bsdtar Windows ships, which reads both formats. From 148e6deea046658639aae9fef5b349781cec39d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 00:15:28 -0700 Subject: [PATCH 3/5] feat: start a new project from just a name (#14527) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/Stack.tsx | 5 + .../features/projects/AddProjectNewRoute.tsx | 12 + .../features/projects/AddProjectScreen.tsx | 261 +++++++++++- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/project/NewProject.test.ts | 140 ++++++ apps/server/src/project/NewProject.ts | 179 ++++++++ apps/server/src/server.test.ts | 59 +++ apps/server/src/ws.ts | 50 +++ apps/web/src/components/CommandPalette.tsx | 399 ++++++++++++++++-- .../src/components/CommandPaletteResults.tsx | 2 +- .../src/components/chat/DraftHeroHeadline.tsx | 2 +- .../sidebar/SidebarThreadHeader.tsx | 2 +- apps/web/src/hooks/useNewProject.ts | 147 +++++++ docs/user/source-control.md | 10 +- .../client-runtime/src/operations/projects.ts | 31 ++ .../src/state/projectCommands.ts | 7 + packages/contracts/src/project.ts | 14 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 5 + packages/shared/src/path.test.ts | 14 + packages/shared/src/path.ts | 21 + 21 files changed, 1334 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/projects/AddProjectNewRoute.tsx create mode 100644 apps/server/src/project/NewProject.test.ts create mode 100644 apps/server/src/project/NewProject.ts create mode 100644 apps/web/src/hooks/useNewProject.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7f508f486ce3..4baeadb94238 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -53,6 +53,7 @@ import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewR import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; +import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({ screen: AddProjectLocalRoute, linking: "add-project/local", }), + AddProjectNew: createNativeStackScreen({ + screen: AddProjectNewRoute, + linking: "add-project/new", + }), }, }); diff --git a/apps/mobile/src/features/projects/AddProjectNewRoute.tsx b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx new file mode 100644 index 000000000000..8bbd26f2e114 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx @@ -0,0 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { AddProjectNewScreen } from "./AddProjectScreen"; + +type AddProjectNewRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectNewRoute({ + route, +}: StaticScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5b38cfa7bdd8..7cdcd0d33493 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -15,6 +15,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -77,6 +81,8 @@ interface EnvironmentOption { readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; + /** Folder for projects started from just a name; null on servers without it. */ + readonly newProjectsRoot: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray { platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, + newProjectsRoot: config?.newProjectsRoot ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() { {selectedEnvironment ? ( <> + {selectedEnvironment.newProjectsRoot !== null ? ( + + } + isFirst + onPress={() => + navigation.dispatch( + StackActions.push("AddProjectNew", { + environmentId: selectedEnvironment.environmentId, + }), + ) + } + /> + ) : null} } - isFirst + isFirst={selectedEnvironment.newProjectsRoot === null} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -898,6 +927,236 @@ function FolderBrowser(props: { ); } +/** + * New project: a name, then the server makes the folder, README, icon, and + * first commit. Optionally publishes it to GitHub as a private repository. + */ +export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const navigation = useNavigation(); + // Starts on the machine picked in Add project; the rows below switch it. + const environmentOptions = useEnvironmentOptions().filter( + (option) => option.newProjectsRoot !== null, + ); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + () => stringParam(props.environmentId) as EnvironmentId | null, + ); + const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const discoveryState = useEnvironmentQuery( + environment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: environment.environmentId, + input: {}, + }), + ); + const githubTarget = getNewProjectGitHubTarget(discoveryState.data); + const [name, setName] = useState(""); + const [publishesToGitHub, setPublishesToGitHub] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const trimmedName = name.trim(); + const pathPreview = + environment?.newProjectsRoot != null + ? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName) + : null; + + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showMachines = + environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0); + const machineRows = showMachines ? ( + + {environmentOptions.map((option, index) => { + const selected = option.environmentId === environment?.environmentId; + return ( + + } + selected={selected} + // The create in flight keeps the machine it started on. + disabled={isSubmitting} + isFirst={index === 0} + right={ + selected ? ( + + ) : null + } + onPress={() => setSelectedEnvironmentId(option.environmentId)} + /> + ); + })} + + ) : null; + + // State lags a render behind, so a double tap could start a second create. + const submittingRef = useRef(false); + const submit = async () => { + if (!environment || trimmedName.length === 0 || submittingRef.current) return; + submittingRef.current = true; + setError(null); + setIsSubmitting(true); + try { + const result = await createNew({ + environmentId: environment.environmentId, + input: { name: trimmedName }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + return; + } + const { projectId, workspaceRoot, commitError } = result.value; + if (commitError !== undefined) { + Alert.alert("Created without a first commit", commitError); + } + if (publishesToGitHub && githubTarget !== null) { + void publishRepository({ + environmentId: environment.environmentId, + input: { + cwd: workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), + visibility: "private", + }, + }).then((publishResult) => { + if (AsyncResult.isFailure(publishResult)) { + Alert.alert( + "Could not create the GitHub repository", + errorMessage(Cause.squash(publishResult.cause)), + ); + } + }); + } + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + // The project exists, so clearing the name keeps Create from making a `-2` copy. + setName(""); + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return; + } + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }); + } finally { + submittingRef.current = false; + setIsSubmitting(false); + } + }; + + return ( + + {error ? : null} + {environment ? ( + <> + void submit()} + /> + {pathPreview !== null ? ( + + {trimmedName.length > 0 + ? `Creates ${pathPreview}` + : `Goes in ${environment.newProjectsRoot}`} + {showMachines ? ` on ${environment.label}` : null} + + ) : null} + {machineRows} + {githubTarget !== null ? ( + + 0 && pathPreview !== null + ? getNewProjectGitHubRepository(githubTarget, pathPreview) + : githubTarget.account + } + icon={ + + } + isFirst + right={ + + } + onPress={() => setPublishesToGitHub((publishes) => !publishes)} + /> + + ) : null} + void submit()} + loading={isSubmitting} + /> + + + } + isFirst + // New project opens from Add project, so going back shows the + // other sources. A deep link has nothing behind it. + onPress={() => + navigation.canGoBack() + ? navigation.goBack() + : navigation.dispatch(StackActions.replace("AddProject")) + } + /> + + + ) : environmentOptions.length > 0 ? ( + machineRows + ) : ( + + )} + + ); +} + export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 61f70aac2836..27fde35b9e90 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, diff --git a/apps/server/src/project/NewProject.test.ts b/apps/server/src/project/NewProject.test.ts new file mode 100644 index 000000000000..3286aa784c29 --- /dev/null +++ b/apps/server/src/project/NewProject.test.ts @@ -0,0 +1,140 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { createNewProjectFolder } from "./NewProject.ts"; + +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-new-project-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const GIT_ENV_KEYS = [ + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "EMAIL", +] as const; + +// Git reads the developer's own config (signing, default branch, identity) +// unless the test replaces it, so each test runs against an empty one. +const withGitEnv = ( + env: Partial>, + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const saved = GIT_ENV_KEYS.map((key) => [key, process.env[key]] as const); + for (const key of GIT_ENV_KEYS) delete process.env[key]; + Object.assign(process.env, { + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...env, + }); + return saved; + }), + () => effect, + (saved) => + Effect.sync(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }), + ); + }); + +const gitOutput = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ operation: "NewProject.test", cwd, args }); + return result.stdout.trim(); + }); + +it.layer(TestLayer)("createNewProjectFolder", (it) => { + it.effect("makes the folder, starter files, and first commit, and suffixes a taken name", () => + Effect.scoped( + withGitEnv( + { + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@test.com", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }), + "projects", + ); + + const first = yield* createNewProjectFolder({ root, name: "Pinball Stats" }); + const second = yield* createNewProjectFolder({ root, name: "pinball stats" }); + + assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); + assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); + assert.isUndefined(first.commitError); + const readme = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "README.md"), + ); + assert.include(readme, "# Pinball Stats"); + assert.include(readme, `src="assets/icon.svg"`); + const icon = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "assets", "icon.svg"), + ); + assert.include(icon, ">PS"); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["log", "--format=%s"]), + "Initial commit", + ); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["rev-parse", "--abbrev-ref", "HEAD"]), + "main", + ); + assert.equal(yield* gitOutput(first.workspaceRoot, ["status", "--porcelain"]), ""); + }), + ), + ), + ); + + it.effect("keeps the folder and reports why when Git cannot commit", () => + Effect.scoped( + // No identity anywhere, and Git may not guess one from the host name. + withGitEnv( + { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.useConfigOnly", + GIT_CONFIG_VALUE_0: "true", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }); + + const result = yield* createNewProjectFolder({ root, name: "No Identity" }); + + assert.include(result.commitError ?? "", "no name or email"); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, "README.md"))); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, ".git"))); + }), + ), + ), + ); +}); diff --git a/apps/server/src/project/NewProject.ts b/apps/server/src/project/NewProject.ts new file mode 100644 index 000000000000..d59be024e5e3 --- /dev/null +++ b/apps/server/src/project/NewProject.ts @@ -0,0 +1,179 @@ +/** + * NewProject - makes the folder for a project started from just a name: + * `/` with a README, an icon, and a first commit. + * + * The icon lives at `assets/icon.svg`, a path ProjectFaviconResolver already + * checks after the usual web app icons. It travels with the repository, so + * every machine that clones the project shows the same icon. + * + * @module NewProject + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { newProjectFolderName } from "@t3tools/shared/path"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +// Tailwind 600 shades: dark enough for white initials on every hue. +const ICON_BACKGROUNDS = [ + "#dc2626", + "#ea580c", + "#d97706", + "#16a34a", + "#059669", + "#0d9488", + "#0891b2", + "#0284c7", + "#2563eb", + "#4f46e5", + "#7c3aed", + "#9333ea", + "#c026d3", + "#db2777", + "#e11d48", +] as const; + +const MAX_FOLDER_ATTEMPTS = 100; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** A rounded square with the name's initials, colored by a hash of the name. */ +function newProjectIconSvg(name: string): string { + const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); + const initials = + words + .slice(0, 2) + .map((word) => Array.from(word)[0] ?? "") + .join("") + .toUpperCase() || + Array.from(name.trim())[0] || + "?"; + let hash = 0; + for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0; + const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length]; + return [ + ``, + ` `, + ` ${escapeXml(initials)}`, + ``, + "", + ].join("\n"); +} + +function newProjectReadme(name: string): string { + return [ + ``, + "", + `# ${name}`, + "", + "Created in [T3 Code](https://t3.codes).", + "", + ].join("\n"); +} + +// Git's own identity message runs several lines; say what to do instead. +// Otherwise its last line ("error: gpg failed to sign the data") says enough. +function describeCommitFailure(stderr: string): string { + if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) { + return "Git has no name or email on this machine. Set user.name and user.email, then commit."; + } + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines.at(-1) ?? "Git could not make the first commit."; +} + +/** + * Claims a fresh folder for `name` under `root` (adding `-2`, `-3`, ... when + * the name is taken), writes the starter files, and makes the first commit. + * A failed commit (no Git identity, a signing prompt) keeps the folder and + * returns why, so the project still opens. Any other failure removes the + * folder it claimed. + */ +export const createNewProjectFolder = Effect.fn("NewProject.createNewProjectFolder")( + function* (input: { readonly root: string; readonly name: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + + yield* fileSystem.makeDirectory(input.root, { recursive: true }); + // Created without `recursive`, so the create itself claims the folder and + // two requests for the same name never share one. + const folderName = newProjectFolderName(input.name); + let workspaceRoot: string | null = null; + for (let attempt = 1; workspaceRoot === null && attempt <= MAX_FOLDER_ATTEMPTS; attempt++) { + const candidate = path.join( + input.root, + attempt === 1 ? folderName : `${folderName}-${attempt}`, + ); + const claimed = yield* fileSystem.makeDirectory(candidate).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + ); + if (claimed) workspaceRoot = candidate; + } + if (workspaceRoot === null) { + return yield* PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "makeDirectory", + description: `Every folder name for "${folderName}" is taken.`, + pathOrDescriptor: input.root, + }); + } + const cwd = workspaceRoot; + + return yield* Effect.gen(function* () { + const branch = + (yield* git + .readConfigValue(cwd, "init.defaultBranch") + .pipe(Effect.orElseSucceed(() => null))) ?? "main"; + yield* git.execute({ + operation: "NewProject.init", + cwd, + args: ["init", `--initial-branch=${branch}`], + timeoutMs: 10_000, + }); + yield* fileSystem.writeFileString(path.join(cwd, "README.md"), newProjectReadme(input.name)); + yield* fileSystem.makeDirectory(path.join(cwd, "assets")); + yield* fileSystem.writeFileString( + path.join(cwd, "assets", "icon.svg"), + newProjectIconSvg(input.name), + ); + // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + yield* git.execute({ + operation: "NewProject.add", + cwd, + args: ["add", "--force", "--", "README.md", "assets/icon.svg"], + timeoutMs: 10_000, + }); + const commitError = yield* git + .execute({ + operation: "NewProject.commit", + cwd, + args: ["commit", "--message", "Initial commit"], + allowNonZeroExit: true, + timeoutMs: 30_000, + }) + .pipe( + Effect.map((result) => + result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr), + ), + Effect.catch((error) => Effect.succeed(error.message)), + ); + return { workspaceRoot: cwd, commitError }; + }).pipe(Effect.onError(() => fileSystem.remove(cwd, { recursive: true }).pipe(Effect.ignore))); + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 930e7e7bb369..fd505400c2e5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5556,6 +5556,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a project from just a name in the projects folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created: Array<{ readonly title: string; readonly workspaceRoot: string }> = []; + const gitCalls: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "project.create") { + created.push({ title: command.title, workspaceRoot: command.workspaceRoot }); + } + return { sequence: created.length }; + }), + }, + gitVcsDriver: { + readConfigValue: () => Effect.succeed(null), + execute: (input) => + Effect.sync(() => { + gitCalls.push(input.args.join(" ")); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + const root = (yield* client[WS_METHODS.serverGetConfig]({})).newProjectsRoot ?? ""; + const result = yield* client[WS_METHODS.projectsCreateNew]({ name: "Pinball Stats" }); + + assert.equal(result.workspaceRoot, path.join(root, "pinball-stats")); + assert.isUndefined(result.commitError); + assert.deepEqual(created, [ + { title: "Pinball Stats", workspaceRoot: result.workspaceRoot }, + ]); + assert.deepEqual(gitCalls, [ + "init --initial-branch=main", + "add --force -- README.md assets/icon.svg", + "commit --message Initial commit", + ]); + assert.isTrue( + yield* fileSystem.exists(path.join(result.workspaceRoot, "assets", "icon.svg")), + ); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("advertises the usable file manager and its reveal label", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1bdf80208e55..f256dfaa4aa0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,6 +49,7 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectId, + type ProjectCreateNewInput, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as NewProject from "./project/NewProject.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; @@ -2018,6 +2020,49 @@ const makeWsRpcLayer = ( ); }); + // Projects started from just a name live beside Scratch and worktrees, + // away from folders the user organizes by hand. A nested repository is + // fine here (unlike Scratch) because each project gets its own `git init`. + const newProjectsRoot = path.resolve(config.baseDir, "projects"); + const createNewProject = (input: ProjectCreateNewInput) => + Effect.gen(function* () { + const folder = yield* NewProject.createNewProjectFolder({ + root: newProjectsRoot, + name: input.name, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the project folder.", + cause, + }), + ), + ); + const projectId = ProjectId.make(yield* randomUUID); + yield* Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("project-create-new"), + projectId, + title: input.name, + workspaceRoot: folder.workspaceRoot, + createdAt: yield* nowIso, + }); + yield* dispatchNormalizedCommand(command); + }).pipe( + // Only a rejected command means no project uses the folder. An + // interrupt can land after the command is queued, so keep it then. + Effect.tapError(() => + fileSystem.remove(folder.workspaceRoot, { recursive: true }).pipe(Effect.ignore), + ), + ); + return { + projectId, + workspaceRoot: folder.workspaceRoot, + ...(folder.commitError === undefined ? {} : { commitError: folder.commitError }), + }; + }); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -2082,6 +2127,7 @@ const makeWsRpcLayer = ( threadSnapshotPagination: true, reasoningMessages: true, ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), + newProjectsRoot, }; }); @@ -3252,6 +3298,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { "rpc.aggregate": "orchestration", }), + [WS_METHODS.projectsCreateNew]: (input) => + observeRpcEffect(WS_METHODS.projectsCreateNew, createNewProject(input), { + "rpc.aggregate": "orchestration", + }), [WS_METHODS.projectCloneCancel]: (input) => observeRpcEffect( WS_METHODS.projectCloneCancel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6ea939d666bb..95f2df5928ae 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -10,6 +10,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -43,8 +46,11 @@ import * as Option from "effect/Option"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, + CheckIcon, + ChevronRightIcon, CornerLeftUpIcon, FileSearchIcon, + FolderGit2Icon, FolderIcon, FolderPlusIcon, MessageSquareDashedIcon, @@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; +import { useNewProject } from "../hooks/useNewProject"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; @@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + // The name step of New project: while set, the palette input is the name. + const [newProjectFlow, setNewProjectFlow] = useState<{ + readonly environmentId: EnvironmentId; + /** Machine of the Add project sources view under this step; null from the palette root. */ + readonly sourcesEnvironmentId: EnvironmentId | null; + } | null>(null); + const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false); + const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); + // State lags a render behind, so a repeated Enter could start a second create. + const newProjectSubmittingRef = useRef(false); + const createNewProject = useNewProject(); const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); @@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: { getFilesystemBrowsePath( query, browseEnvironmentPlatform, - browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null, ), - [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + [ + browseEnvironmentId, + browseEnvironmentPlatform, + isRemoteProjectRepositoryStep, + newProjectFlow, + query, + ], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: { function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); + } else if (newProjectFlow?.sourcesEnvironmentId) { + // The machine switcher may have moved off the sources view's machine. + setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: { [pushPaletteView], ); + /** Folder that holds an environment's name-only projects, or null when it has none. */ + const newProjectsRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => + environments.find((environment) => environment.environmentId === environmentId)?.serverConfig + ?.newProjectsRoot ?? null, + [environments], + ); + + const startNewProject = useCallback( + (environmentId: EnvironmentId, sourcesEnvironmentId: EnvironmentId | null): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + setNewProjectFlow({ environmentId, sourcesEnvironmentId }); + setNewProjectPublishesToGitHub(false); + pushPaletteView({ + addonIcon: , + groups: [], + }); + }, + [pushPaletteView], + ); + const openSourceControlSettings = useCallback(() => { setOpen(false); void navigate({ to: "/settings/source-control" }); @@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: { }, ]; + if (newProjectsRootFor(environmentId) !== null) { + sourceItems.unshift({ + kind: "action", + value: `action:add-project:${environmentId}:new`, + searchTerms: ["new project", "create", "empty", "repository", "git init"], + title: "New project", + description: "Start a new Git repository from a name", + icon: , + keepOpen: true, + run: async () => { + startNewProject(environmentId, environmentId); + }, + }); + } + const orderedSources: ReadonlyArray = [ "url", ...sortAddProjectProviderSources(readinessBySource), @@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: { return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; }, - [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + [ + newProjectsRootFor, + openSourceControlSettings, + startAddProjectBrowse, + startAddProjectClone, + startNewProject, + ], ); const startAddProjectSourceSelection = useCallback( @@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: { ], ); - const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( - (option) => ({ - kind: "action", - value: `action:add-project:environment:${option.environmentId}`, - searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], - title: option.label, - description: option.isConnected - ? option.isPrimary - ? "This device" - : option.environmentId - : option.status, - disabled: !option.isConnected, - icon: , - keepOpen: true, - run: async () => { - startAddProjectSourceSelection(option.environmentId); - }, - }), + const buildEnvironmentItem = ( + option: AddProjectEnvironmentOption, + value: string, + run: (environmentId: EnvironmentId) => void, + ): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isConnected + ? option.isPrimary + ? "This device" + : option.environmentId + : option.status, + disabled: !option.isConnected, + icon: , + keepOpen: true, + run: async () => { + run(option.environmentId); + }, + }); + const addProjectEnvironmentItems = addProjectEnvironmentOptions.map((option) => + buildEnvironmentItem( + option, + `action:add-project:environment:${option.environmentId}`, + startAddProjectSourceSelection, + ), + ); + const newProjectEnvironmentOptions = addProjectEnvironmentOptions.filter( + (option) => option.isConnected && newProjectsRootFor(option.environmentId) !== null, ); const addProjectEnvironmentGroups = useMemo( @@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + // New project starts on this device (options list it first); the name step + // lists the other machines when there is a choice. + const openNewProjectFlow = () => { + const firstOption = newProjectEnvironmentOptions[0]; + if (firstOption) startNewProject(firstOption.environmentId, null); + }; + useLayoutEffect(() => { if (openIntent?.kind !== "search") return; browseNavigation.invalidate(); cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setLinkedThreadSearch(openIntent); setQuery(openIntent.query); @@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: { clearOpenIntent(); browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setQuery(""); const currentPrefix = @@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: { }, }); + if (newProjectEnvironmentOptions.length > 0) { + actionItems.push({ + kind: "action", + value: "action:new-project", + searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"], + title: "New project", + icon: , + keepOpen: true, + run: async () => { + openNewProjectFlow(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:add-project", @@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: { cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); pushPaletteView({ addonIcon: , @@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: { ], ); + const newProjectGitHubTarget = + newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null); + const newProjectName = query.trim(); + const canSubmitNewProject = + newProjectFlow !== null && + newProjectName.length > 0 && + !isCreatingNewProject && + canCreateProjectInEnvironment(browseEnvironment?.connection.phase); + + async function submitNewProject(): Promise { + if (newProjectFlow === null || !canSubmitNewProject || newProjectSubmittingRef.current) { + return; + } + newProjectSubmittingRef.current = true; + setIsCreatingNewProject(true); + try { + const created = await createNewProject({ + environmentId: newProjectFlow.environmentId, + name: newProjectName, + github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, + }); + if (created) setOpen(false); + } finally { + newProjectSubmittingRef.current = false; + setIsCreatingNewProject(false); + } + } + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { return getAddProjectInitialQueryForEnvironment(environmentId); } @@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; + const newProjectPathPreview = + newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); + const newProjectGitHubToggleValue = "new-project:github"; + // The name step's way out to folders and clones, for the selected machine. + // It replaces the name step (and a sources view for another machine under + // it), so Back returns to wherever New project was opened from. + const showExistingProjectSources = (environmentId: EnvironmentId) => { + const sourcesEnvironmentId = newProjectFlow?.sourcesEnvironmentId ?? null; + if (sourcesEnvironmentId === environmentId) { + popView(); + return; + } + if (sourcesEnvironmentId !== null) { + setViewStack((previousViews) => previousViews.slice(0, -1)); + } + popView(); + startAddProjectSourceSelection(environmentId); + }; + // Switching machines keeps the typed name; the GitHub option follows the + // machine because source control discovery reads addProjectEnvironmentId. + const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { + setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); + setAddProjectEnvironmentId(environmentId); + }; + const selectedNewProjectEnvironment = + newProjectFlow === null + ? undefined + : newProjectEnvironmentOptions.find( + (option) => option.environmentId === newProjectFlow.environmentId, + ); + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showNewProjectMachines = + newProjectFlow !== null && + (newProjectEnvironmentOptions.length > 1 || + (selectedNewProjectEnvironment === undefined && newProjectEnvironmentOptions.length > 0)); + const newProjectEnvironmentLabel = showNewProjectMachines + ? (selectedNewProjectEnvironment?.label ?? + environmentLabelById.get(newProjectFlow.environmentId) ?? + null) + : null; + const newProjectMachineGroup: CommandPaletteView["groups"][number] | null = + !showNewProjectMachines || newProjectFlow === null + ? null + : { + value: "new-project-machines", + label: "Environments", + items: newProjectEnvironmentOptions.map((option) => ({ + ...buildEnvironmentItem( + option, + `new-project:environment:${option.environmentId}`, + switchNewProjectEnvironment, + ), + // The create in flight keeps the machine it started on. + ...(isCreatingNewProject ? { disabled: true } : {}), + ...(option.environmentId === newProjectFlow.environmentId + ? { + titleTrailingContent: ( + + ), + } + : {}), + })), + }; + const newProjectExistingGroup: CommandPaletteView["groups"][number] | null = + newProjectFlow === null + ? null + : { + value: "new-project-existing", + label: "", + items: [ + { + kind: "action", + value: "new-project:existing", + searchTerms: [], + title: "Add existing project", + description: "Open a folder or clone a repository", + icon: , + titleTrailingContent: ( + + ), + keepOpen: true, + run: async () => { + showExistingProjectSources(newProjectFlow.environmentId); + }, + }, + ], + }; + const newProjectOptionGroups: CommandPaletteView["groups"] = + newProjectGitHubTarget === null || newProjectPathPreview === null + ? [] + : [ + { + value: "new-project-options", + label: "Options", + items: [ + { + kind: "action", + value: newProjectGitHubToggleValue, + searchTerms: [], + title: "Create private repository on GitHub", + description: + newProjectName.length > 0 + ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview) + : (newProjectGitHubTarget.account ?? "Your GitHub account"), + icon: , + titleTrailingContent: ( + + + + ), + keepOpen: true, + run: async () => { + setNewProjectPublishesToGitHub((publishes) => !publishes); + }, + }, + ], + }, + ]; + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; - if (addProjectCloneFlow?.step === "repository") { + if (newProjectFlow !== null) { + displayedGroups = [ + ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), + ...newProjectOptionGroups, + ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), + ]; + } else if (addProjectCloneFlow?.step === "repository") { displayedGroups = []; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; @@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: { } const inputPlaceholder = - remoteProjectInputPlaceholder(addProjectCloneFlow) ?? - getCommandPaletteInputPlaceholder(paletteMode); + newProjectFlow !== null + ? "Project name" + : (remoteProjectInputPlaceholder(addProjectCloneFlow) ?? + getCommandPaletteInputPlaceholder(paletteMode)); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = @@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: { return; } + // Enter creates the project unless an item below the name is + // highlighted, in which case it runs that item. + if ( + newProjectFlow !== null && + event.key === "Enter" && + highlightedItemValue === null && + // Enter that confirms an IME composition is part of typing the name. + !event.nativeEvent.isComposing && + event.keyCode !== 229 + ) { + event.preventDefault(); + void submitNewProject(); + return; + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && @@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: { ]); const inputAccessory = - addProjectCloneFlow?.step === "repository" ? ( + newProjectFlow !== null ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitNewProject(); + }} + /> + } + > + {isCreatingNewProject ? "Creating" : "Create"} + + Enter + + + Create (Enter) + + ) : addProjectCloneFlow?.step === "repository" ? ( + {newProjectPathPreview !== null ? ( +
+
+ + + + {newProjectName.length > 0 ? newProjectName : "New project"} + + + {newProjectName.length > 0 + ? `Creates ${newProjectPathPreview}` + : `Goes in ${newProjectsRoot}`} + {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} + + +
+
+ ) : null} {remoteProjectContext ? (
Repository
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b94707f5eca6..4be4aee17ff7 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label ? {group.label} : null} {(item) => item.disabled ? ( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3d5b7bfd311d..ac995a47a670 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -304,7 +304,7 @@ export function DraftHeroHeadline({ {projectPickerEntries.length > 0 ? : null} - New project + Add project diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e552e64c19a3..e2bd4bfb74e5 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -127,7 +127,7 @@ export function SidebarThreadHeader({ {hasProjects ? ( <> {projectScope} - + diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts new file mode 100644 index 000000000000..175609cbfc42 --- /dev/null +++ b/apps/web/src/hooks/useNewProject.ts @@ -0,0 +1,147 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { waitForProject } from "~/state/entities"; +import { projectEnvironment } from "~/state/projects"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} + +/** + * Starts a project from just a name. The server makes a folder under its + * `newProjectsRoot` with a README, an icon, and a first commit; this then + * opens a new thread draft in it. With `github`, it also publishes the + * repository as private, without holding up the draft. + * + * Resolves to whether the project was created. + */ +export function useNewProject() { + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const handleNewThread = useNewThreadHandler(); + + const publishToGitHub = useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly account: string | null; + }) => { + const result = await publishRepository({ + environmentId: input.environmentId, + input: { + cwd: input.workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(input, input.workspaceRoot), + visibility: "private", + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the GitHub repository", + description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`, + }), + ); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Published to GitHub", + description: result.value.repository.nameWithOwner, + }), + ); + }, + [publishRepository], + ); + + return useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly name: string; + readonly github: { readonly account: string | null } | null; + }): Promise => { + const result = await createNew({ + environmentId: input.environmentId, + input: { name: input.name }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the project", + description: errorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return false; + } + + const { projectId, workspaceRoot, commitError } = result.value; + // The folder sits in T3 Code's data directory, so always say where. + toastManager.add( + stackedThreadToast( + commitError === undefined + ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot } + : { + type: "warning", + title: `Created ${input.name} without a first commit`, + description: `${commitError} The project is in ${workspaceRoot}.`, + }, + ), + ); + if (input.github) { + void publishToGitHub({ + environmentId: input.environmentId, + workspaceRoot, + account: input.github.account, + }); + } + + const projectRef = scopeProjectRef(input.environmentId, projectId); + // Drafts key off the project's stored path, so wait for the create event + // to reach the store before opening one. + const project = await waitForProject(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`, + }), + ); + return null; + }); + if (project === null) return true; + await handleNewThread(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: errorMessage(error), + }), + ); + }); + return true; + }, + [createNew, handleNewThread, publishToGitHub], + ); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 306632d9ca64..3d068a232be7 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -79,7 +79,15 @@ az extension add --name azure-devops az login ``` -## Clone or publish a project +## Start, clone, or publish a project + +To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or +**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git +repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, +an icon, and a first commit, then opens a new thread in it. The folder is named after the project, +like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also +publish it. If Git has no name or email on that machine, the project is created without the +first commit. Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting provider or paste a Git URL, then choose where to save it. The project opens right away while the diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 597b0b5d7b4f..ec03ea86dce1 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -8,6 +8,7 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; +import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -250,6 +251,36 @@ export function getCloneDestinationPath( return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; } +/** + * Where `projects.createNew` will put a project named `name`. The server adds + * `-2`, `-3`, ... when that folder is taken, so this is a preview. + */ +export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { + return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); +} + +/** + * The GitHub account a new project would be published under, or null when + * GitHub is not ready on that environment. A ready GitHub with an unknown + * account still publishes; `gh` picks the signed-in user. + */ +export function getNewProjectGitHubTarget( + discovery: SourceControlDiscoveryResult | null, +): { readonly account: string | null } | null { + if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null; + const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github"); + return { account: github ? Option.getOrNull(github.auth.account) : null }; +} + +/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ +export function getNewProjectGitHubRepository( + target: { readonly account: string | null }, + workspaceRoot: string, +): string { + const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; + return target.account ? `${target.account}/${folderName}` : folderName; +} + /** * Destination query after choosing a directory while the clone folder is * pinned in the path input. Selecting an existing directory with the pinned diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index d969a8c0d379..5c39113501fb 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -99,6 +99,13 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + // Makes a new folder and repository from just a name, then the project. + createNew: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-new", + tag: WS_METHODS.projectsCreateNew, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f97924b084fb..a36003eb11b1 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({ }); export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; +/** A project started from just a name, in a new folder the server makes. */ +export const ProjectCreateNewInput = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}); +export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type; + +export const ProjectCreateNewResult = Schema.Struct({ + projectId: ProjectId, + workspaceRoot: TrimmedNonEmptyString, + /** Why the first commit failed. The project and its files exist either way. */ + commitError: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41331f0c46cf..c1b25459048e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -159,6 +159,8 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectCreateNewInput, + ProjectCreateNewResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -295,6 +297,7 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", projectsEnsureScratch: "projects.ensureScratch", + projectsCreateNew: "projects.createNew", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +// Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project. +const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { + payload: ProjectCreateNewInput, + success: ProjectCreateNewResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, + WsProjectsCreateNewRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49faaaf44712..c8983988ead1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({ * and whose data dir is outside a Git checkout. */ scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** + * Folder that holds projects started from just a name. Present only on + * servers that answer projects.createNew. + */ + newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 799f225fb6e2..b018a4e81d8c 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + newProjectFolderName, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, } from "./path.ts"; @@ -43,4 +44,17 @@ describe("path helpers", () => { // Non-root drive paths keep their trailing separator trimmed as before. expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); + + it("names a new project's folder from any typed name", () => { + expect(newProjectFolderName("Pinball Stats")).toBe("pinball-stats"); + expect(newProjectFolderName(" Café & Crème! ")).toBe("cafe-creme"); + expect(newProjectFolderName("../../etc")).toBe("etc"); + // Nothing usable left, so the server falls back to a fixed name. + expect(newProjectFolderName("🎱🎱")).toBe("project"); + expect(newProjectFolderName(`${"a".repeat(63)} b`)).toBe("a".repeat(63)); + // Windows cannot make folders with device names. + expect(newProjectFolderName("Con")).toBe("con-project"); + expect(newProjectFolderName("LPT1")).toBe("lpt1-project"); + expect(newProjectFolderName("console")).toBe("console"); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6758d814aa8..c8626eb5e14c 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string { } return normalized; } + +// Windows refuses these as file names, with or without an extension. +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; + +/** + * Folder name for a project started from just a name ("Pinball Stats" becomes + * "pinball-stats"). The server uses it for `projects.createNew`, and clients + * use it to show the path before the server makes it. + */ +export function newProjectFolderName(name: string): string { + const slug = name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .slice(0, 64) + .replace(/-+$/, ""); + if (slug.length === 0) return "project"; + return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug; +} From 5b73e922d57a646d259ab49eec5623f110d7c413 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 08:36:37 +0000 Subject: [PATCH 4/5] fix(web): German Option characters reach the terminal On macOS, Option+L is @. The terminal forwarded Option as Alt into a non-Darwin libghostty-vt build, so DEC 1036 sent ESC @. Marking Alt consumed still left the raw Alt bit set, and modifyOtherKeys mode 2 encoded that as an Alt sequence. Consume a lone macOS Option that produced a character, and clear those consumed bits from the raw modifier mask before encoding. Ctrl, Meta, Option+arrow, and a lone Shift stay intact. There is no Option-as-Meta setting; this matches Terminal.app with that option off. Grok 4.7 via Cursor cloud agent. Co-authored-by: maco --- apps/web/src/terminal/ghostty/core.test.ts | 101 ++++++++ apps/web/src/terminal/ghostty/core.ts | 16 +- .../web/src/terminal/ghostty/keyCodes.test.ts | 100 +++++++- apps/web/src/terminal/ghostty/keyCodes.ts | 74 +++++- .../src/terminal/ghostty/runtimeAbi.test.ts | 222 +++++++++++++++++- 5 files changed, 488 insertions(+), 25 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de61..b24f174b5ab4 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -378,3 +378,104 @@ describe("GhosttyTerminalCore snapshots", () => { expect(core.snapshot()).toEqual(reference.snapshot()); }); }); + +function terminalKey( + partial: Partial & Pick, +): KeyboardEvent { + return { + altKey: false, + ctrlKey: false, + isComposing: false, + metaKey: false, + repeat: false, + shiftKey: false, + getModifierState: () => false, + ...partial, + } as KeyboardEvent; +} + +function withPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { configurable: true, value: platform }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +describe("GhosttyTerminalCore.encodeKey", () => { + const cores = new Set(); + + async function createCore() { + const core = await GhosttyTerminalCore.create( + 12, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + cores.add(core); + return core; + } + + afterEach(() => { + for (const core of cores) core.dispose(); + cores.clear(); + }); + + it("types macOS Option characters through the WASM encoder", async () => { + const core = await createCore(); + const optionAt = terminalKey({ altKey: true, code: "KeyL", key: "@" }); + withPlatform("MacIntel", () => { + expect(core.encodeKey(optionAt)).toBe("@"); + expect(core.encodeKey(terminalKey({ altKey: true, code: "KeyE", key: "€" }))).toBe("€"); + expect(core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "|" }))).toBe("|"); + expect( + core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "\\", shiftKey: true })), + ).toBe("\\"); + expect(core.encodeKey(terminalKey({ code: "KeyC", ctrlKey: true, key: "c" }))).toBe("\u0003"); + expect( + core.encodeKey(terminalKey({ altKey: true, code: "ArrowLeft", key: "ArrowLeft" })), + ).toBe("\u001b[1;3D"); + + core.write("\u001b[>4;2m"); + expect(core.encodeKey(optionAt)).toBe("@"); + expect( + core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "\\", shiftKey: true })), + ).toBe("\\"); + expect(core.encodeKey(terminalKey({ code: "KeyA", key: "A", shiftKey: true }))).toBe( + "\u001b[27;2;65~", + ); + + core.write("\u001b[>1u"); + expect(core.encodeKey(optionAt)).toBe("@"); + expect(core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "|" }))).toBe("|"); + expect(core.encodeKey(terminalKey({ code: "KeyC", ctrlKey: true, key: "c" }))).toBe( + "\u001b[99;5u", + ); + + core.write("\u001b[>31u"); + expect(core.encodeKey(optionAt)).toBe("\u001b[64::108;;64u"); + }); + }); + + it("keeps Alt as a modifier off macOS", async () => { + const core = await createCore(); + withPlatform("Linux", () => { + expect(core.encodeKey(terminalKey({ altKey: true, code: "KeyL", key: "@" }))).toBe("\u001b@"); + }); + withPlatform("Win32", () => { + expect(core.encodeKey(terminalKey({ altKey: true, code: "Digit7", key: "|" }))).toBe( + "\u001b|", + ); + }); + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d45..9a5bbb156ecc 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -1,6 +1,6 @@ import { type GhosttyKeyboardLayoutMap, - ghosttyConsumedMods, + ghosttyEncoderMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint, loadGhosttyKeyboardLayoutMap, @@ -481,19 +481,9 @@ export class GhosttyTerminalCore { action === "release" ? 0 : event.repeat ? 2 : 1, ); this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); - const mods = - (event.shiftKey ? 1 : 0) | - (event.ctrlKey ? 1 << 1 : 0) | - (event.altKey ? 1 << 2 : 0) | - (event.metaKey ? 1 << 3 : 0) | - (event.getModifierState("CapsLock") ? 1 << 4 : 0) | - (event.getModifierState("NumLock") ? 1 << 5 : 0); + const { mods, consumedMods } = ghosttyEncoderMods(event); this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); - this.runtime.call( - "ghostty_key_event_set_consumed_mods", - this.keyEvent, - ghosttyConsumedMods(event), - ); + this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, consumedMods); this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); this.runtime.call( "ghostty_key_event_set_unshifted_codepoint", diff --git a/apps/web/src/terminal/ghostty/keyCodes.test.ts b/apps/web/src/terminal/ghostty/keyCodes.test.ts index ef4b623fdfc8..f3f4024aedcd 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.test.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; +import { + ghosttyConsumedMods, + ghosttyEncoderMods, + ghosttyKeyForCode, + ghosttyUnshiftedCodepoint, +} from "./keyCodes"; describe("ghosttyKeyForCode", () => { it("keeps the tail of the pinned Ghostty key enum in order", () => { @@ -14,12 +19,95 @@ describe("ghosttyKeyForCode", () => { describe("ghosttyConsumedMods", () => { const shifted = { altKey: false, ctrlKey: false, key: "@", metaKey: false, shiftKey: true }; - it("only consumes a lone Shift producing a character", () => { - expect(ghosttyConsumedMods(shifted)).toBe(1); - expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0); - expect(ghosttyConsumedMods({ ...shifted, key: "Tab" })).toBe(0); + it("consumes a lone Shift producing a character", () => { + expect(ghosttyConsumedMods(shifted, "Linux")).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...shifted, key: "Tab" }, "MacIntel")).toBe(0); // Deliberate: Shift+Space collapses to Space so it still types one. - expect(ghosttyConsumedMods({ ...shifted, key: " " })).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, key: " " }, "Linux")).toBe(1); + }); + + it("consumes a lone macOS Option that produced a character", () => { + const option = { altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }; + expect(ghosttyConsumedMods(option, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods(option, "iPhone")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "€" }, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "\\", shiftKey: true }, "MacIntel")).toBe( + (1 << 2) | 1, + ); + expect(ghosttyConsumedMods({ ...option, ctrlKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, metaKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, key: "ArrowLeft" }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, key: "Dead" }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods(option, "Linux")).toBe(0); + expect(ghosttyConsumedMods(option, "Win32")).toBe(0); + expect(ghosttyConsumedMods({ ...option, shiftKey: true }, "Linux")).toBe(0); + }); +}); + +describe("ghosttyEncoderMods", () => { + const locksOff = { getModifierState: () => false }; + + it("drops consumed Option from the raw mask and keeps a lone Shift", () => { + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }, + "MacIntel", + ), + ).toEqual({ mods: 0, consumedMods: 1 << 2 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: false, key: "\\", metaKey: false, shiftKey: true }, + "MacIntel", + ), + ).toEqual({ mods: 0, consumedMods: (1 << 2) | 1 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: false, ctrlKey: false, key: "A", metaKey: false, shiftKey: true }, + "MacIntel", + ), + ).toEqual({ mods: 1, consumedMods: 1 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: true, key: "a", metaKey: false, shiftKey: false }, + "MacIntel", + ), + ).toEqual({ mods: (1 << 1) | (1 << 2), consumedMods: 0 }); + expect( + ghosttyEncoderMods( + { ...locksOff, altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }, + "Linux", + ), + ).toEqual({ mods: 1 << 2, consumedMods: 0 }); + expect( + ghosttyEncoderMods( + { + ...locksOff, + altKey: true, + ctrlKey: false, + key: "ArrowLeft", + metaKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toEqual({ mods: 1 << 2, consumedMods: 0 }); + }); + + it("keeps lock modifiers that did not compose the character", () => { + expect( + ghosttyEncoderMods( + { + altKey: true, + ctrlKey: false, + key: "@", + metaKey: false, + shiftKey: false, + getModifierState: (key) => key === "CapsLock", + }, + "MacIntel", + ), + ).toEqual({ mods: 1 << 4, consumedMods: 1 << 2 }); }); }); diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts index a643827d6f2d..c9b597120313 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -233,13 +233,77 @@ export function loadGhosttyKeyboardLayoutMap(): Promise, + event: Pick, + platform = ghosttyHostPlatform(), ): number { - if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0; - return [...event.key].length === 1 ? 1 : 0; + if ([...event.key].length !== 1 || event.ctrlKey || event.metaKey) return 0; + const shift = event.shiftKey ? GHOSTTY_MOD_SHIFT : 0; + if (event.altKey && isGhosttyMacPlatform(platform)) return shift | GHOSTTY_MOD_ALT; + if (!event.shiftKey || event.altKey) return 0; + return GHOSTTY_MOD_SHIFT; +} + +export interface GhosttyEncoderMods { + readonly mods: number; + readonly consumedMods: number; +} + +// Raw mods plus the consumed mask passed to libghostty-vt. +// +// Consuming Option is not enough on this build. modifyOtherKeys mode 2 and +// Kitty report-all read the raw Alt bit and still emit an Alt sequence when +// that bit is set, even if Alt is also consumed. Clear every modifier that +// composed the Option character (Option, and Shift when it participated). +// A lone Shift stays in the raw mask so modifyOtherKeys can still report +// Shift+letter. +export function ghosttyEncoderMods( + event: GhosttyKeyModState, + platform = ghosttyHostPlatform(), +): GhosttyEncoderMods { + const consumedMods = ghosttyConsumedMods(event, platform); + let mods = + (event.shiftKey ? GHOSTTY_MOD_SHIFT : 0) | + (event.ctrlKey ? GHOSTTY_MOD_CTRL : 0) | + (event.altKey ? GHOSTTY_MOD_ALT : 0) | + (event.metaKey ? GHOSTTY_MOD_SUPER : 0) | + (event.getModifierState?.("CapsLock") ? 1 << 4 : 0) | + (event.getModifierState?.("NumLock") ? 1 << 5 : 0); + if ((consumedMods & GHOSTTY_MOD_ALT) !== 0) { + mods &= ~consumedMods; + } + return { mods, consumedMods }; } export function ghosttyUnshiftedCodepoint( diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts index 7d4782b4b409..7893fb1e1ce5 100644 --- a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline"; import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline"; import pinnedVersion from "../../../../../native/libghostty-vt/VERSION?raw"; -import { ghosttyKeyForCode } from "./keyCodes"; +import { ghosttyEncoderMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; type WasmFunction = (...args: number[]) => number; @@ -714,4 +714,224 @@ describe("vendored libghostty-vt WebAssembly", () => { call("ghostty_wasm_free_opaque", terminalSlot); free(terminalOptions, 8); }); + + it("encodes macOS Option text only when consumed Alt is absent from raw mods", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + + const terminalOptions = alloc(8); + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); + terminalOptionsView.setUint16(0, 80, true); + terminalOptionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const encoderSlot = call("ghostty_wasm_alloc_opaque"); + const eventSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0); + expect(call("ghostty_key_event_new", 0, eventSlot)).toBe(0); + const keyEncoder = new DataView(memory.buffer).getUint32(encoderSlot, true); + const keyEvent = new DataView(memory.buffer).getUint32(eventSlot, true); + + const writeTerminal = (data: string) => { + const bytes = new TextEncoder().encode(data); + const pointer = alloc(bytes.length); + new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes); + call("ghostty_terminal_vt_write", terminal, pointer, bytes.length); + free(pointer, bytes.length); + }; + + const encode = (input: { + readonly code: string; + readonly key: string; + readonly mods: number; + readonly consumedMods: number; + readonly unshifted: number; + }) => { + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + call("ghostty_key_event_set_action", keyEvent, 1); + call("ghostty_key_event_set_key", keyEvent, ghosttyKeyForCode(input.code)); + call("ghostty_key_event_set_mods", keyEvent, input.mods); + call("ghostty_key_event_set_consumed_mods", keyEvent, input.consumedMods); + call("ghostty_key_event_set_composing", keyEvent, 0); + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, input.unshifted); + const text = new TextEncoder().encode(input.key); + const textPointer = text.length === 0 ? 0 : alloc(text.length); + if (textPointer !== 0) new Uint8Array(memory.buffer, textPointer, text.length).set(text); + call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length); + const written = call("ghostty_wasm_alloc_usize"); + const query = call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + let encoded = ""; + if (outputSize > 0) { + expect(query).toBe(-3); + const output = alloc(outputSize); + expect( + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, output, outputSize, written), + ).toBe(0); + const outputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + encoded = new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputLength)); + free(output, outputSize); + } else { + expect(query).toBe(0); + } + call("ghostty_wasm_free_usize", written); + if (textPointer !== 0) free(textPointer, text.length); + return encoded; + }; + + const alt = 1 << 2; + const optionAt = { + code: "KeyL", + key: "@", + shiftKey: false, + }; + const produced = ghosttyEncoderMods( + { + altKey: true, + ctrlKey: false, + getModifierState: () => false, + key: optionAt.key, + metaKey: false, + shiftKey: optionAt.shiftKey, + }, + "MacIntel", + ); + const unshiftedAt = ghosttyUnshiftedCodepoint(optionAt); + const layoutUnshiftedAt = ghosttyUnshiftedCodepoint(optionAt, new Map([["KeyL", "l"]])); + expect(produced).toEqual({ mods: 0, consumedMods: alt }); + expect(unshiftedAt).toBe("@".codePointAt(0)); + expect(layoutUnshiftedAt).toBe("l".codePointAt(0)); + + // Raw Alt, whether or not it is also consumed, is an Alt chord on this + // non-Darwin build. The mask encodeKey sends (Alt consumed and cleared) + // is the one that writes the character. + expect(encode({ ...optionAt, mods: alt, consumedMods: 0, unshifted: unshiftedAt })).toBe( + "\u001b@", + ); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe("@"); + expect( + encode({ + ...optionAt, + mods: produced.mods, + consumedMods: produced.consumedMods, + unshifted: unshiftedAt, + }), + ).toBe("@"); + + writeTerminal("\u001b[>4;2m"); + expect(encode({ ...optionAt, mods: alt, consumedMods: 0, unshifted: unshiftedAt })).toBe( + "\u001b[27;3;64~", + ); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe( + "\u001b[27;3;64~", + ); + expect( + encode({ + ...optionAt, + mods: produced.mods, + consumedMods: produced.consumedMods, + unshifted: unshiftedAt, + }), + ).toBe("@"); + expect( + encode({ + ...optionAt, + mods: produced.mods, + consumedMods: produced.consumedMods, + unshifted: layoutUnshiftedAt, + }), + ).toBe("@"); + + const optionBackslash = ghosttyEncoderMods( + { + altKey: true, + ctrlKey: false, + getModifierState: () => false, + key: "\\", + metaKey: false, + shiftKey: true, + }, + "MacIntel", + ); + const backslash = { + code: "Digit7", + key: "\\", + unshifted: ghosttyUnshiftedCodepoint({ code: "Digit7", key: "\\", shiftKey: true }), + }; + const layoutBackslash = ghosttyUnshiftedCodepoint( + { code: "Digit7", key: "\\", shiftKey: true }, + new Map([["Digit7", "7"]]), + ); + expect(optionBackslash).toEqual({ mods: 0, consumedMods: alt | 1 }); + expect(backslash.unshifted).toBe(0); + // Leaving the composing Shift in the raw mask still encodes modifyOtherKeys. + expect( + encode({ + ...backslash, + mods: 1, + consumedMods: optionBackslash.consumedMods, + }), + ).toBe("\u001b[27;2;92~"); + expect(encode({ ...backslash, ...optionBackslash })).toBe("\\"); + expect(encode({ ...backslash, ...optionBackslash, unshifted: layoutBackslash })).toBe("\\"); + + const shiftA = ghosttyEncoderMods( + { + altKey: false, + ctrlKey: false, + getModifierState: () => false, + key: "A", + metaKey: false, + shiftKey: true, + }, + "MacIntel", + ); + expect(shiftA).toEqual({ mods: 1, consumedMods: 1 }); + expect( + encode({ + code: "KeyA", + key: "A", + mods: shiftA.mods, + consumedMods: shiftA.consumedMods, + unshifted: "a".codePointAt(0)!, + }), + ).toBe("\u001b[27;2;65~"); + + writeTerminal("\u001b[>1u"); + expect(encode({ ...optionAt, mods: alt, consumedMods: 0, unshifted: unshiftedAt })).toBe( + "\u001b[64;3u", + ); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe("@"); + expect(encode({ ...optionAt, ...produced, unshifted: unshiftedAt })).toBe("@"); + expect(encode({ ...optionAt, ...produced, unshifted: layoutUnshiftedAt })).toBe("@"); + + writeTerminal("\u001b[>31u"); + expect(encode({ ...optionAt, mods: alt, consumedMods: alt, unshifted: unshiftedAt })).toBe( + "\u001b[64::108;3u", + ); + expect(encode({ ...optionAt, ...produced, unshifted: unshiftedAt })).toBe( + "\u001b[64::108;;64u", + ); + expect(encode({ ...optionAt, ...produced, unshifted: layoutUnshiftedAt })).toBe( + "\u001b[108;;64u", + ); + + call("ghostty_key_event_free", keyEvent); + call("ghostty_key_encoder_free", keyEncoder); + call("ghostty_wasm_free_opaque", eventSlot); + call("ghostty_wasm_free_opaque", encoderSlot); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(terminalOptions, 8); + }); }); From eb91684ce805e665a026ad1dee9301bd1c720f9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 10:36:16 +0000 Subject: [PATCH 5/5] docs(web): document Ghostty Option key helpers CodeRabbit docstring coverage on the functions this diff touches was 33.33%. Add JSDoc to the key-encoding helpers and the test functions beside them. Behavior is unchanged. From apps/web at Node 24.13.1: vp test run --project unit src/terminal/ghostty/keyCodes.test.ts src/terminal/ghostty/runtimeAbi.test.ts src/terminal/ghostty/core.test.ts Result: 3 files passed, 33 tests passed. vp run --filter @t3tools/web typecheck Result: tsc --noEmit passed (exit 0). vp fmt apps/web/src/terminal/ghostty/keyCodes.ts apps/web/src/terminal/ghostty/core.ts apps/web/src/terminal/ghostty/core.test.ts Result: finished, no further edits. vp lint apps/web/src/terminal/ghostty/keyCodes.ts apps/web/src/terminal/ghostty/core.ts apps/web/src/terminal/ghostty/core.test.ts Result: exit 0. One pre-existing warning in ghosttyCellText (unicorn no-new-array). Grok 4.7 via Cursor cloud agent. Co-authored-by: maco --- apps/web/src/terminal/ghostty/core.test.ts | 7 +++ apps/web/src/terminal/ghostty/core.ts | 8 +++ apps/web/src/terminal/ghostty/keyCodes.ts | 61 ++++++++++++++++------ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index b24f174b5ab4..7cda7b32e906 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -379,6 +379,7 @@ describe("GhosttyTerminalCore snapshots", () => { }); }); +/** Keyboard event stub. Unset modifiers are false and locks are reported off. */ function terminalKey( partial: Partial & Pick, ): KeyboardEvent { @@ -394,6 +395,11 @@ function terminalKey( } as KeyboardEvent; } +/** + * Run `run` while `navigator.platform` is `platform`, then restore the previous + * own-property descriptor. `encodeKey` reads the platform when deciding whether + * Option produced a character. + */ function withPlatform(platform: string, run: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); Object.defineProperty(navigator, "platform", { configurable: true, value: platform }); @@ -409,6 +415,7 @@ function withPlatform(platform: string, run: () => T): T { describe("GhosttyTerminalCore.encodeKey", () => { const cores = new Set(); + /** Small terminal used to assert the bytes `encodeKey` writes. */ async function createCore() { const core = await GhosttyTerminalCore.create( 12, diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 9a5bbb156ecc..9f85913ac0f7 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -472,6 +472,14 @@ export class GhosttyTerminalCore { ); } + /** + * Bytes libghostty-vt emits for one key event. + * + * On macOS, a lone Option that produced a character (German Option+L is `@`) + * is consumed and cleared from the raw modifier mask before encoding, so the + * character is written instead of an Alt sequence. Ctrl, Meta, Option+arrow, + * and a lone Shift stay intact. + */ encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { this.ensureActive(); this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts index c9b597120313..aacb7bafa6b5 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -218,6 +218,11 @@ const shiftedToUnshiftedCharacter = new Map([ let keyboardLayoutMapPromise: Promise | undefined; +/** + * Browser keyboard layout, cached for the page. Used to recover the unshifted + * base key of an Option-composed character such as German Option+L (`@`). + * Resolves to `undefined` when the Keyboard API is missing or rejects. + */ export function loadGhosttyKeyboardLayoutMap(): Promise { if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; const browserNavigator = globalThis.navigator as @@ -247,23 +252,35 @@ interface GhosttyKeyModState { getModifierState?(key: string): boolean; } +/** + * `navigator.platform` for the current host, or `""` when there is no DOM. + * Callers pass an explicit platform in tests so Option handling can be checked + * without stubbing the browser. + */ function ghosttyHostPlatform(): string { return typeof navigator === "undefined" ? "" : (navigator.platform ?? ""); } -// Same host check as isMacPlatform. Kept local so key encoding does not import -// the app utility graph. +/** + * True when `platform` is a macOS or iOS `navigator.platform` string. + * Same host check as `isMacPlatform`. Kept local so key encoding does not + * import the app utility graph. Option is only consumed on these hosts. + */ function isGhosttyMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } -// Browsers do not report which modifiers a layout consumed. A lone Shift is -// consumed so a shifted character encodes as text. On macOS a lone Option is -// consumed too: this WASM build is not Darwin, so it ignores macos-option-as-alt -// and DEC 1036 turns Option+L (`@`) into `ESC @` (readline set-mark). Ctrl and -// Meta stay unconsumed. Option+arrow is not one character, so word motion is -// unchanged. There is no Option-as-Meta setting; this matches Terminal.app with -// that option off. +/** + * Modifiers the layout consumed to produce `event.key`, as a GhosttyMods bitmask. + * + * Browsers do not report which modifiers a layout consumed. A lone Shift is + * consumed so a shifted character encodes as text. On macOS a lone Option is + * consumed too: this WASM build is not Darwin, so it ignores macos-option-as-alt + * and DEC 1036 turns Option+L (`@`) into `ESC @` (readline set-mark). Ctrl and + * Meta stay unconsumed. Option+arrow is not one character, so word motion is + * unchanged. There is no Option-as-Meta setting; this matches Terminal.app with + * that option off. + */ export function ghosttyConsumedMods( event: Pick, platform = ghosttyHostPlatform(), @@ -280,14 +297,16 @@ export interface GhosttyEncoderMods { readonly consumedMods: number; } -// Raw mods plus the consumed mask passed to libghostty-vt. -// -// Consuming Option is not enough on this build. modifyOtherKeys mode 2 and -// Kitty report-all read the raw Alt bit and still emit an Alt sequence when -// that bit is set, even if Alt is also consumed. Clear every modifier that -// composed the Option character (Option, and Shift when it participated). -// A lone Shift stays in the raw mask so modifyOtherKeys can still report -// Shift+letter. +/** + * Raw mods plus the consumed mask passed to libghostty-vt. + * + * Consuming Option is not enough on this build. modifyOtherKeys mode 2 and + * Kitty report-all read the raw Alt bit and still emit an Alt sequence when + * that bit is set, even if Alt is also consumed. Clear every modifier that + * composed the Option character (Option, and Shift when it participated). + * A lone Shift stays in the raw mask so modifyOtherKeys can still report + * Shift+letter. + */ export function ghosttyEncoderMods( event: GhosttyKeyModState, platform = ghosttyHostPlatform(), @@ -306,6 +325,14 @@ export function ghosttyEncoderMods( return { mods, consumedMods }; } +/** + * Unshifted codepoint for Kitty alternate-key encoding. + * + * Prefers the active layout map, then US letter and symbol pairs, then + * lowercasing. Returns 0 when the unshifted form cannot be known. Reporting + * the shifted character as unshifted corrupts Kitty alternate keys. + * Option-composed characters such as `@` still report their layout base key. + */ export function ghosttyUnshiftedCodepoint( event: Pick, layoutMap?: GhosttyKeyboardLayoutMap,