Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/few-eyes-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@1git/gpuix": minor
---

Add Claude Code and Codex CLI accounts, provider-specific model and effort choices, and custom OpenAI or Claude compatible APIs with private auth headers. Prevent cancellation errors when switching providers.
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,49 @@
- **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 a custom endpoint, choose **API** in **Settings → AI**. Select OpenAI Chat
Completions, OpenAI Responses, or Claude Messages, then enter the base URL
(including any `/v1` path), model ID, and optional native effort value. The app
appends the selected format's endpoint. Add auth headers such as `Authorization`
with a `Bearer …` value or `x-api-key`; their values are masked and saved only
in the OS credential store. Remove all headers for an unauthenticated endpoint.
Leaving effort blank uses the provider's default. Models and effort are manual
for custom endpoints, so a model-list API is not required.

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.

Custom API HTTP tests use the local [API simulation](postman/simulations/ai-api.context.md)
and `GPUIX_AI_API_TEST_URL=http://localhost:PORT/ai-api/v1 bun run --cwd apps/gpuix test:ai:api`.

## Download

**[Download the latest macOS beta →](https://git.1bye.dev/download)**
Expand Down
5 changes: 4 additions & 1 deletion apps/gpuix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"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",
"test:ai:api": "bun test tests/ai/api-http.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",
Expand All @@ -20,7 +23,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"
},
Expand Down
298 changes: 298 additions & 0 deletions apps/gpuix/src/feature/ai/ai-api-fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import { useRef, useState } from "react";
import { radius, spacing, typography } from "@1git/design-tokens";
import { Button } from "@1git/gpuix-ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@1git/gpuix-ui/select";
import { useAppTheme } from "../../styles/theme";
import type { AiConnection } from "./lib/ai-settings-store";
import type { CustomApiConfiguration } from "./lib/custom-api";

const formats = [
{ value: "openai-chat", label: "OpenAI Chat Completions" },
{ value: "openai-responses", label: "OpenAI Responses" },
{ value: "anthropic", label: "Claude Messages" },
] as const;

/** Manual API fields; secret values stay in uncontrolled inputs and local refs. */
export function AiApiFields({
draft,
onChange,
onHeadersChange,
connected,
reconnect,
busy,
}: {
draft: AiConnection;
onChange: (draft: AiConnection) => void;
onHeadersChange: (raw: string) => void;
connected: boolean;
reconnect: boolean;
busy: boolean;
}) {
const { tokens } = useAppTheme();

const configuration = draft.api ?? {
protocol: "openai-chat",
baseUrl: "https://api.openai.com/v1",
};

const [replacing, setReplacing] = useState(reconnect);

const [rows, setRows] = useState([
{
id: 0,
name:
configuration.protocol === "anthropic" ? "x-api-key" : "Authorization",
},
]);

const nextId = useRef(1);
const values = useRef(new Map<number, string>());
const label = { ...typography.md, color: tokens.colors.textMuted };

const row = {
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: spacing[2],
flexShrink: 0,
};

const field = {
width: "100%",
height: tokens.layout.controlHeight,
...typography.md,
color: tokens.colors.text,
backgroundColor: "transparent",
borderWidth: 1,
borderColor: tokens.colors.border,
borderRadius: radius.md,
paddingLeft: spacing[2.5],
paddingRight: spacing[2.5],
flexShrink: 0,
};

const updateHeaders = (nextRows: typeof rows) => {
setRows(nextRows);
onHeadersChange(
JSON.stringify(
nextRows
.map(({ id, name }) => ({
name,
value: values.current.get(id) ?? "",
}))
.filter((header) => header.name || header.value)
)
);
};

const selectFormat = (protocol: CustomApiConfiguration["protocol"]) => {
const defaultUrl =
protocol === "anthropic"
? "https://api.anthropic.com/v1"
: "https://api.openai.com/v1";

const baseUrl =
configuration.baseUrl === "https://api.openai.com/v1" ||
configuration.baseUrl === "https://api.anthropic.com/v1"
? defaultUrl
: configuration.baseUrl;

if (
!values.current.size &&
rows.length === 1 &&
(rows[0]?.name === "Authorization" || rows[0]?.name === "x-api-key")
)
setRows([
{
id: rows[0].id,
name: protocol === "anthropic" ? "x-api-key" : "Authorization",
},
]);

onChange({ ...draft, api: { protocol, baseUrl } });
};

return (
<>
<text style={label}>API format</text>
<Select
value={configuration.protocol}
items={formats}
disabled={busy}
onValueChange={(value) => {
const format = formats.find((item) => item.value === value);

if (format) selectFormat(format.value);
}}
>
<SelectTrigger
testId="ai-api-format"
aria-label="API format"
tokens={tokens}
style={{ width: "100%" }}
>
<SelectValue tokens={tokens} />
</SelectTrigger>
<SelectContent tokens={tokens}>
{formats.map((format) => (
<SelectItem key={format.value} value={format.value} tokens={tokens}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
<text style={label}>Base URL</text>
<input
testId="ai-api-url"
aria-label="API base URL"
value={configuration.baseUrl}
placeholder="https://your-provider.example/v1"
readOnly={busy}
onChange={(event) =>
onChange({
...draft,
api: {
...configuration,
baseUrl: (event.value ?? "").slice(0, 2048),
},
})
}
style={field}
/>
<text
style={{ ...typography.sm, color: tokens.colors.textMuted }}
>{`Appends ${configuration.protocol === "anthropic" ? "/messages" : configuration.protocol === "openai-responses" ? "/responses" : "/chat/completions"} to this URL.`}</text>
<div style={{ ...row, justifyContent: "space-between" }}>
<text style={label}>Auth headers</text>
{!connected || replacing ? (
<Button
testId="ai-api-header-add"
tokens={tokens}
size="sm"
variant="ghost"
disabled={busy || rows.length >= 16}
onPress={() =>
updateHeaders([...rows, { id: nextId.current++, name: "" }])
}
>
Add header
</Button>
) : null}
</div>
{connected && !replacing ? (
<div style={row}>
<text style={label}>Headers saved securely.</text>
<Button
testId="ai-api-replace-headers"
tokens={tokens}
size="sm"
variant="ghost"
disabled={busy}
onPress={() => {
setReplacing(true);
onHeadersChange("[]");
}}
>
Replace headers
</Button>
</div>
) : (
<>
{rows.map((header) => (
<div key={header.id} style={row}>
<input
testId={`ai-api-header-name-${header.id}`}
aria-label={`Auth header ${header.id + 1} name`}
value={header.name}
placeholder="Header name"
readOnly={busy}
onChange={(event) =>
updateHeaders(
rows.map((item) =>
item.id === header.id
? { ...item, name: (event.value ?? "").slice(0, 128) }
: item
)
)
}
style={{ ...field, width: 132 }}
/>
<input
testId={`ai-api-header-value-${header.id}`}
aria-label={`Auth header ${header.id + 1} value`}
type="password"
placeholder={
header.name.toLowerCase() === "authorization"
? "Bearer your-api-key"
: "Header value"
}
readOnly={busy}
onChange={(event) => {
values.current.set(header.id, event.value ?? "");
updateHeaders(rows);
}}
style={{
...field,
width: undefined,
flexGrow: 1,
flexBasis: 0,
minWidth: 0,
}}
/>
<Button
testId={`ai-api-header-remove-${header.id}`}
aria-label={`Remove auth header ${header.id + 1}`}
tokens={tokens}
size="sm"
variant="ghost"
disabled={busy}
onPress={() => {
values.current.delete(header.id);
updateHeaders(rows.filter((item) => item.id !== header.id));
}}
>
Remove
</Button>
</div>
))}
<text style={{ ...typography.sm, color: tokens.colors.textMuted }}>
Remove all headers if authentication is not needed.
</text>
</>
)}
<text style={label}>Model</text>
<input
testId="ai-api-model"
aria-label="API model"
value={draft.model}
placeholder="Model ID from your provider"
readOnly={busy}
onChange={(event) =>
onChange({ ...draft, model: (event.value ?? "").slice(0, 256) })
}
style={field}
/>
<text style={label}>Effort (optional)</text>
<input
testId="ai-api-effort"
aria-label="API effort"
value={draft.effort ?? ""}
placeholder="Provider default"
readOnly={busy}
onChange={(event) =>
onChange({ ...draft, effort: (event.value ?? "").slice(0, 32) })
}
style={field}
/>
<text style={{ ...typography.sm, color: tokens.colors.textMuted }}>
Use an effort value supported by your model, such as medium or high.
</text>
</>
);
}
Loading
Loading