From 936aef279cddfabceb3e364b324822c2e5f9e45c Mon Sep 17 00:00:00 2001 From: Yurii Hulyk Date: Mon, 26 Oct 2026 11:22:54 +0000 Subject: [PATCH 1/4] feat(ai): add Claude Code and Codex agents Reuse existing CLI logins for commit drafts without copying credentials. Refs #2 --- README.md | 27 +- apps/gpuix/package.json | 4 +- .../src/feature/ai/ai-connection-editor.tsx | 318 ++++++- .../gpuix/src/feature/ai/ai-provider-icons.ts | 6 + apps/gpuix/src/feature/ai/ai-settings.tsx | 37 +- .../src/feature/ai/lib/ai-integration.ts | 53 +- apps/gpuix/src/feature/ai/lib/ai-model.ts | 59 ++ .../src/feature/ai/lib/ai-settings-store.ts | 48 +- apps/gpuix/src/feature/ai/lib/cli-agent.ts | 804 ++++++++++++++++++ apps/gpuix/src/feature/ai/lib/pi-provider.ts | 121 ++- apps/gpuix/src/feature/ai/use-ai-commit.ts | 12 +- apps/gpuix/tests/ai/ai.component.test.tsx | 319 ++++++- .../tests/ai/cli-live.integration.test.ts | 91 ++ .../tests/ai/cli-process.integration.test.ts | 536 ++++++++++++ .../tests/ai/composer.component.test.tsx | 29 + apps/gpuix/tests/ai/connections.unit.test.ts | 38 + apps/gpuix/tests/ai/inference.unit.test.ts | 63 +- apps/gpuix/tests/ai/models.unit.test.ts | 93 ++ apps/gpuix/tests/ai/settings.unit.test.ts | 50 ++ 19 files changed, 2618 insertions(+), 90 deletions(-) create mode 100644 apps/gpuix/src/feature/ai/lib/ai-model.ts create mode 100644 apps/gpuix/src/feature/ai/lib/cli-agent.ts create mode 100644 apps/gpuix/tests/ai/cli-live.integration.test.ts create mode 100644 apps/gpuix/tests/ai/cli-process.integration.test.ts create mode 100644 apps/gpuix/tests/ai/models.unit.test.ts diff --git a/README.md b/README.md index 08c29ccb..052a9835 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,37 @@ - **Keep work organized.** Use tabs, spaces, and worktrees to move between projects. - **Work over SSH.** Browse repositories on macOS and Linux machines through the same workspace. - **Connect your accounts.** Work with GitHub and GitLab repositories and pull or merge requests. -- **Draft with AI.** Generate commit messages with a configured provider, including local Ollama. +- **Draft with AI.** Generate commit messages with OpenCode, ChatGPT, or your installed Claude Code and Codex CLI. - **Make it yours.** Choose themes and repository colors, icons, and artwork. Built with TypeScript, React 19, Bun, and [GPUIX](https://github.com/remorses/gpuix). The desktop interface uses native rendering and macOS window materials. +ChatGPT offers **CLI** (the default, using Codex) and **Login** connection tiles. +Existing connections retain their selected method. + +For CLI commit messages, install and sign in to Claude Code (`claude auth login`) +or Codex (`codex login`), then add it in **Settings → AI**. Choose a model from +the dropdown, or select **Use CLI default**. Both agents report their available +models and per-model effort options. **Effort** initially selects Medium when +supported, otherwise the model's reported default. **Provider default** inherits +the agent's settings, as does **Use CLI default**. Unknown capabilities never +produce guessed effort options. Direct API accounts use their live model list +with capability metadata from the bundled Pi catalog where available. +**Advanced** contains custom model +entry and an optional executable path for installations outside the usual search paths. +**Test agent** sends a synthetic diff to verify the connection using your CLI account. +1git reuses the CLI's authentication without copying its credentials. Generation +uses the checked files and selected lines, with tools and hooks restricted, and +only updates the commit draft. CLI requests currently support up to 64 KiB of +selected changes; larger selections can use an API connection. + +Run `bun run --cwd apps/gpuix test:ai:process` for subprocess regression tests. +`bun run --cwd apps/gpuix test:ai:cli` explicitly tests both installed agents +against their real providers and uses their signed-in accounts. Optional +`GPUIX_CLAUDE_PATH`, `GPUIX_CODEX_PATH`, and `GPUIX_CODEX_MODEL` variables select +test executables or a Codex model without changing the CLI configuration. + ## Download **[Download the latest macOS beta →](https://git.1bye.dev/download)** diff --git a/apps/gpuix/package.json b/apps/gpuix/package.json index ff5d59b1..86c0e6f7 100644 --- a/apps/gpuix/package.json +++ b/apps/gpuix/package.json @@ -9,6 +9,8 @@ "build:native": "bun native/build.ts", "setup:signing:macos": "bun native/setup-signing.ts", "test:signing:macos": "bun test tests/packaging/signing.integration.test.ts tests/packaging/hardened-runtime.integration.test.ts", + "test:ai:cli": "GPUIX_AI_CLI_SMOKE=1 bun test tests/ai/cli-live.integration.test.ts", + "test:ai:process": "bun test tests/ai/cli-process.integration.test.ts", "package:macos": "bun native/package-macos.ts", "test:package:macos": "bun test tests/packaging/launch.integration.test.ts", "test:updates:macos": "bun test tests/updater/install.integration.test.ts tests/updater/lifecycle.integration.test.ts tests/updater/preparation.integration.test.ts", @@ -20,7 +22,7 @@ "dev": "MallocLargeCache=0 MallocAggressiveMadvise=1 bun --preload ./native/preload.ts --hot src/main.tsx", "start": "MallocLargeCache=0 MallocAggressiveMadvise=1 bun --preload ./native/preload.ts dist/main.js", "test": "bun test --preload ./native/preload.ts --preload ./tests/_shared/machine-isolation.ts --path-ignore-patterns '**/*.integration.test.ts' tests", - "test:integration": "bun test tests/ignored-history/history.integration.test.ts tests/ignored-history/flow.integration.test.tsx tests/repository-avatars/disk-cache.integration.test.ts tests/repository-file-actions/icons.integration.test.ts tests/repository-file-actions/discard.integration.test.ts tests/repository-file-actions/paths.integration.test.ts tests/repository-file-actions/revisions.integration.test.ts tests/editor-settings/editor.integration.test.ts tests/repository-operation/operations.integration.test.ts tests/repository-conflicts/resolution.integration.test.ts tests/repository-connection/repository-reader.integration.test.ts tests/repository-discovery/discovery.integration.test.ts tests/repository-discovery/cache.integration.test.ts tests/repository-diff/repository-diff.integration.test.ts tests/repository-graph/reader.integration.test.ts tests/repository-history/history.integration.test.ts tests/repository-history/revert.integration.test.ts tests/repository-history/cherry-pick.integration.test.ts tests/repository-history/tags.integration.test.ts tests/repository-stashes/stashes.integration.test.ts tests/workspace-tabs/repository-branches.integration.test.ts tests/repository-sync/sync.integration.test.ts tests/repository-publish/publish.integration.test.ts tests/repository-pull-request/reader.integration.test.ts tests/workspace-session/store.integration.test.ts tests/close-protection/native.integration.test.ts tests/native-session/lifecycle.integration.test.ts tests/native-session/frame-pacing.integration.test.ts tests/repository-setup/setup.integration.test.ts tests/accounts/native.integration.test.ts tests/accounts/authorization.integration.test.ts tests/accounts/github-cli.integration.test.ts tests/window-chrome/vibrancy.integration.test.ts tests/window-chrome/window-management.integration.test.ts tests/git-settings/settings.integration.test.ts tests/ai/context.integration.test.ts tests/machines/store.integration.test.ts tests/machines/helper.integration.test.ts tests/machines/ssh.integration.test.ts", + "test:integration": "bun test tests/ignored-history/history.integration.test.ts tests/ignored-history/flow.integration.test.tsx tests/repository-avatars/disk-cache.integration.test.ts tests/repository-file-actions/icons.integration.test.ts tests/repository-file-actions/discard.integration.test.ts tests/repository-file-actions/paths.integration.test.ts tests/repository-file-actions/revisions.integration.test.ts tests/editor-settings/editor.integration.test.ts tests/repository-operation/operations.integration.test.ts tests/repository-conflicts/resolution.integration.test.ts tests/repository-connection/repository-reader.integration.test.ts tests/repository-discovery/discovery.integration.test.ts tests/repository-discovery/cache.integration.test.ts tests/repository-diff/repository-diff.integration.test.ts tests/repository-graph/reader.integration.test.ts tests/repository-history/history.integration.test.ts tests/repository-history/revert.integration.test.ts tests/repository-history/cherry-pick.integration.test.ts tests/repository-history/tags.integration.test.ts tests/repository-stashes/stashes.integration.test.ts tests/workspace-tabs/repository-branches.integration.test.ts tests/repository-sync/sync.integration.test.ts tests/repository-publish/publish.integration.test.ts tests/repository-pull-request/reader.integration.test.ts tests/workspace-session/store.integration.test.ts tests/close-protection/native.integration.test.ts tests/native-session/lifecycle.integration.test.ts tests/native-session/frame-pacing.integration.test.ts tests/repository-setup/setup.integration.test.ts tests/accounts/native.integration.test.ts tests/accounts/authorization.integration.test.ts tests/accounts/github-cli.integration.test.ts tests/window-chrome/vibrancy.integration.test.ts tests/window-chrome/window-management.integration.test.ts tests/git-settings/settings.integration.test.ts tests/ai/context.integration.test.ts tests/ai/cli-process.integration.test.ts tests/machines/store.integration.test.ts tests/machines/helper.integration.test.ts tests/machines/ssh.integration.test.ts", "build:remote-helpers": "bun native/build-remote-helpers.ts", "test:machines:integration": "bun test tests/machines/store.integration.test.ts tests/machines/helper.integration.test.ts tests/machines/ssh.integration.test.ts" }, diff --git a/apps/gpuix/src/feature/ai/ai-connection-editor.tsx b/apps/gpuix/src/feature/ai/ai-connection-editor.tsx index fa6e6081..650ed797 100644 --- a/apps/gpuix/src/feature/ai/ai-connection-editor.tsx +++ b/apps/gpuix/src/feature/ai/ai-connection-editor.tsx @@ -11,6 +11,7 @@ import { SelectValue, } from "@1git/gpuix-ui/select"; import { ChoiceTile } from "../../fixture/choice-tile"; +import { WorkspaceTabIcon } from "../workspace-tabs/components/workspace-tab-icon"; import { useAppTheme } from "../../styles/theme"; import { useAi } from "./ai-provider"; import { @@ -18,10 +19,13 @@ import { aiProviderNames, opencodePlanIcons, } from "./ai-provider-icons"; -import type { AiConnection } from "./lib/ai-settings-store"; -import type { AiModel } from "./lib/ai-integration"; +import { AiSettingsStore, type AiConnection } from "./lib/ai-settings-store"; +import { findModel, modelEffort, type AiModel } from "./lib/ai-model"; import { cavemanCommitPreset } from "./lib/caveman-commit-preset"; +// GPUIX's keyboard selection requires a nonempty option value. Persist defaults as empty strings. +const defaultOption = "1git:default"; + /** Scoped account editor. Closing cancels reads; secret input stays in an uncontrolled password field and ref. */ export function AiConnectionEditor({ ai, @@ -40,6 +44,7 @@ export function AiConnectionEditor({ }) { const { tokens } = useAppTheme(); const [draft, setDraft] = useState(initial); + const [advanced, setAdvanced] = useState(false); const [useDefault, setUseDefault] = useState( ai.configuration.defaultId === initial.id @@ -52,8 +57,13 @@ export function AiConnectionEditor({ const [loading, setLoading] = useState(false); const [modelError, setModelError] = useState(null); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(false); - const [authorizing, setAuthorizing] = useState(false); + + const [pendingAction, setPendingAction] = useState<"sign-in" | "test" | null>( + null + ); + const [editingKey, setEditingKey] = useState(reconnect); const [hasKey, setHasKey] = useState(false); const [keyVersion, setKeyVersion] = useState(0); @@ -62,12 +72,38 @@ export function AiConnectionEditor({ const mounted = useRef(true); const service = ai.service; const provider = draft.provider; + const chatgptProvider = provider === "chatgpt" || provider === "codex"; const mode = draft.opencodeMode; const account = draft.chatgpt; const id = draft.id; + const executable = draft.executable; const free = provider === "opencode" && mode === "free"; - const canLoad = free || connected === true; + const cli = AiSettingsStore.isCli(provider); + const canLoad = cli || free || connected === true; const saved = ai.configuration.connections.some((item) => item.id === id); + const modelChoices = models.slice(); + const selectedModel = findModel(models, draft.model); + const effort = modelEffort(selectedModel, draft.effort); + + const effortChoices = [ + { value: defaultOption, label: "Provider default" }, + ...(selectedModel?.effort?.levels ?? []), + ]; + + const effectiveDraft = { + ...draft, + effort: effort || (draft.effort === undefined && !cli ? undefined : ""), + }; + + if (cli) + modelChoices.unshift({ value: defaultOption, label: "Use CLI default" }); + + if ( + draft.model && + !models.some((item) => item.value === draft.model) && + (cli || !models.length) + ) + modelChoices.push({ value: draft.model, label: draft.model }); const row = { display: "flex", @@ -112,7 +148,7 @@ export function AiConnectionEditor({ useEffect(() => { let cancelled = false; - if (free) { + if (free || cli) { setConnected(false); return; @@ -142,7 +178,7 @@ export function AiConnectionEditor({ return () => { cancelled = true; }; - }, [service, initial, id, provider, account, free, credentialVersion]); + }, [service, initial, id, provider, account, free, cli, credentialVersion]); useEffect(() => { setModels([]); setModelError(null); @@ -156,7 +192,14 @@ export function AiConnectionEditor({ void (async () => { try { const result = await service.models( - { ...initial, id, provider, opencodeMode: mode, chatgpt: account }, + { + ...initial, + id, + provider, + opencodeMode: mode, + chatgpt: account, + executable, + }, controller.signal ); @@ -183,6 +226,7 @@ export function AiConnectionEditor({ busy, credentialVersion, modelVersion, + executable, ]); const clearKey = () => { @@ -191,9 +235,34 @@ export function AiConnectionEditor({ setKeyVersion((value) => value + 1); }; + const selectProvider = (target: AiConnection["provider"]) => { + if (busy || saved || target === provider) return; + + const sameChatgpt = + chatgptProvider && (target === "chatgpt" || target === "codex"); + + clearKey(); + setEditingKey(false); + setError(null); + setNotice(null); + setAdvanced(false); + setDraft({ + ...draft, + provider: target, + label: + sameChatgpt && draft.label !== aiProviderNames[provider] + ? draft.label + : aiProviderNames[target], + model: "", + chatgpt: undefined, + executable: undefined, + effort: undefined, + }); + }; + const run = async ( action: (signal: AbortSignal) => Promise, - authorization = false + actionKind: "sign-in" | "test" | null = null ) => { if (pending.current) return; @@ -201,9 +270,10 @@ export function AiConnectionEditor({ pending.current = controller; setBusy(true); - setAuthorizing(authorization); + setPendingAction(actionKind); onBusy(true); setError(null); + setNotice(null); try { await action(controller.signal); @@ -220,7 +290,7 @@ export function AiConnectionEditor({ if (mounted.current) { setBusy(false); - setAuthorizing(false); + setPendingAction(null); } } }; @@ -234,7 +304,7 @@ export function AiConnectionEditor({ const save = () => void run(async (signal) => { - await service.saveConnection(draft, "", useDefault, signal); + await service.saveConnection(effectiveDraft, "", useDefault, signal); ai.reload(); if (mounted.current) onClose(); @@ -292,31 +362,49 @@ export function AiConnectionEditor({ aria-label="AI provider" style={{ ...row, gap: spacing[2], height: 64 }} > - {(["opencode", "chatgpt"] as const).map((target) => ( + {(["opencode", "chatgpt", "claude-code"] as const).map((target) => ( { - if (target === provider) return; + if (target === "chatgpt" && chatgptProvider) return; - clearKey(); - setEditingKey(false); - setError(null); - setDraft({ - ...draft, - provider: target, - label: aiProviderNames[target], - model: "", - chatgpt: undefined, - }); + selectProvider(target === "chatgpt" ? "codex" : target); }} /> ))} + {chatgptProvider ? ( + <> + Connection +
+ {(["codex", "chatgpt"] as const).map((target) => ( + selectProvider(target)} + /> + ))} +
+ + ) : null} Name + ) : cli ? ( +
+ + {`Uses your ${aiProviderNames[provider]} sign-in.`} + + +
) : ( <> {account ? ( @@ -473,7 +591,7 @@ export function AiConnectionEditor({ setDraft(result); connectedNow(); } - }, true) + }, "sign-in") } > {account ? "Reconnect ChatGPT" : "Continue with ChatGPT"} @@ -495,16 +613,18 @@ export function AiConnectionEditor({ { + const choice = effortChoices.find( + (item) => item.value === value + ); + + if (!choice) return; + + setNotice(null); + setDraft({ + ...draft, + effort: choice.value === defaultOption ? "" : choice.value, + }); + }} + > + + + + + {effortChoices.map((choice) => ( + + {choice.label} + + ))} + + + {!selectedModel?.effort || + selectedModel.effort.source === "catalog" ? ( + + {!selectedModel?.effort + ? cli && !draft.model + ? "Uses your CLI's configured effort." + : "Effort options are unavailable for this model." + : "Effort options are based on the model catalog."} + + ) : null} + + ) : null} Commit instructions