Skip to content
Open
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# T3 Code

T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients.
T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, Kilo Code, OpenCode) and serves web, desktop, and mobile clients.

You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.

Expand Down Expand Up @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste

- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Providers.** Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).

Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Kilo Code, and OpenCode. If they're set up on your computer, T3 Code can control them.

## "Wait, what are you selling me?"

Expand All @@ -13,12 +13,13 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Kilo Code, and OpenCode. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - Kilo Code: install [Kilo CLI](https://kilo.ai/docs/code-with-ai/platforms/cli) and run `kilo auth login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`

### Try it out (install-free)
Expand Down
4 changes: 4 additions & 0 deletions apps/marketing/public/harnesses/kilo-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 25 additions & 8 deletions apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const mobileEndorsementRows = [
</h1>

<p class="hero-sub">
Orchestrate Claude Code, Codex, OpenCode, Cursor, and Grok from one surface.
Orchestrate Claude Code, Codex, OpenCode, Cursor, Grok, and Kilo Code from one surface.
Bring your own subscription. Fork the whole thing.
</p>

Expand Down Expand Up @@ -187,7 +187,7 @@ const mobileEndorsementRows = [
<h2>Bring your own sub</h2>
<p>
T3 Code doesn't resell tokens. Plug in Claude Code, Codex, OpenCode,
Cursor, or Grok with the credentials you already have — we orchestrate
Cursor, Grok, or Kilo Code with the credentials you already have — we orchestrate
them, you keep your plan.
</p>
</div>
Expand Down Expand Up @@ -228,6 +228,13 @@ const mobileEndorsementRows = [
<div class="harness-tag">grok login</div>
</div>
</div>
<div class="harness">
<div class="harness-mark"><img src="/harnesses/kilo-dark.svg" alt="" /></div>
<div class="harness-meta">
<div class="harness-name">Kilo Code</div>
<div class="harness-tag">kilo auth login</div>
</div>
</div>
</div>

<div class="harness-footer">
Expand Down Expand Up @@ -811,7 +818,7 @@ const mobileEndorsementRows = [

.harness-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
Expand All @@ -822,10 +829,12 @@ const mobileEndorsementRows = [
padding: 22px 20px;
display: flex; align-items: center; gap: 14px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
transition: background 0.2s ease;
}
.harness:last-child { border-right: 0; }
.harness:nth-child(3n) { border-right: 0; }
.harness:nth-child(n + 4) { border-bottom: 0; }
Comment thread
cursor[bot] marked this conversation as resolved.
.harness:hover { background: rgba(255, 255, 255, 0.025); }

.harness-mark {
Expand Down Expand Up @@ -1266,10 +1275,8 @@ const mobileEndorsementRows = [
.harness-grid { grid-template-columns: 1fr 1fr; }
.harness { border-right: 0; border-bottom: 1px solid var(--border); }
.harness:nth-child(odd) { border-right: 1px solid var(--border); }
.harness:nth-child(5):last-child {
grid-column: 1 / -1;
border-right: 0;
}
.harness:nth-child(n) { border-bottom: 1px solid var(--border); }
.harness:nth-child(n + 5) { border-bottom: 0; }
.git-inner { grid-template-columns: 1fr; gap: 40px; }
.open-grid { grid-template-columns: 1fr; }
}
Expand Down Expand Up @@ -1307,4 +1314,14 @@ const mobileEndorsementRows = [
font-size: 13px;
}
}

@media (max-width: 520px) {
.harness-grid { grid-template-columns: 1fr; }
.harness,
.harness:nth-child(n) {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.harness:last-child { border-bottom: 0; }
}
</style>
12 changes: 12 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "kilo") {
return (
<Svg width={size} height={size} viewBox="0 0 512 512" fill="none">
<Path fill="#0F0F0F" d="M512 0H0V512H512V0Z" />
<Path
fill="#FAF74F"
d="M512 512H0V0H512V512ZM322.783 322.784H278.261V392.747L308.472 422.958H378.435V378.437H322.782L322.783 322.784ZM422.957 308.474L392.746 278.263H322.783V322.784H378.435L378.435 378.437H422.957L422.957 308.474ZM233.739 278.263H189.217V322.784H233.739V278.263ZM89.0435 392.747L119.254 422.958H233.739V378.437H133.565V278.263H89.043L89.0435 392.747ZM372.538 189.217V119.254L342.327 89.0435H278.261V133.565H328.017V189.217H278.261V233.739H422.957V189.217H372.538ZM133.565 89.0435H89.0435V233.739H133.565V183.652H189.218V233.739H233.74V183.652L189.218 139.13H133.565V89.0435ZM233.739 89.0435H189.217L189.218 139.13H233.739V89.0435Z"
/>
</Svg>
);
}

if (props.provider === "cursor") {
return (
<Svg width={size} height={size} viewBox="0 0 466.73 532.09" fill="none">
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ import {
type ComposerDraft,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { resolveSelectableModelSelection } from "../../lib/modelOptions";
import {
resolveDispatchableModelSelection,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
Expand Down Expand Up @@ -646,11 +649,14 @@ export function NewTaskDraftScreen(props: {
// Snapshot read keeps just-typed selector state; the availability gate
// still applies so a stored selection on a disabled provider falls back
// to the flow's resolved model.
const modelSelection =
const selectableModelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? flow.selectedModel;
const modelSelection = environmentConnected
? resolveDispatchableModelSelection(selectedEnvironmentServerConfig, selectableModelSelection)
: selectableModelSelection;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const selectedWorktreePath =
Expand Down Expand Up @@ -801,6 +807,7 @@ export function NewTaskDraftScreen(props: {
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
(!environmentConnected || flow.selectedModelReadyForDispatch) &&
flow.prompt.trim().length > 0 &&
isIncomingShareReady &&
!isImportingShare &&
Expand Down Expand Up @@ -1003,7 +1010,11 @@ export function NewTaskDraftScreen(props: {
iconNode={
<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
}
label={flow.selectedModelOption?.label ?? "Choose model"}
label={
!environmentConnected || flow.selectedModelReadyForDispatch
? (flow.selectedModelOption?.label ?? "Choose model")
: "Checking models…"
}
maxWidth={152}
onPress={settingsSheetPresentation.open}
/>
Expand Down
21 changes: 18 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ import {
import { ControlPill } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import {
buildModelOptions,
groupByProvider,
resolveDispatchableModelSelection,
} from "../../lib/modelOptions";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type { RemoteClientConnectionState } from "../../lib/connection";
Expand Down Expand Up @@ -291,10 +295,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
const modelReadyForDispatch =
props.serverConfig !== null &&
resolveDispatchableModelSelection(props.serverConfig, props.selectedThread.modelSelection) !==
null;
// Opening and presentation count as active so the composer stays expanded
// while focus moves between its native editor and the settings picker.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;
const canSend = hasContent && (props.connectionState !== "connected" || modelReadyForDispatch);

// Notify the parent from the derived value, not focus events: the parent
// sizes the feed inset from this, and blur-during-sheet would otherwise
Expand Down Expand Up @@ -548,6 +556,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;

const handleSend = useCallback(async () => {
if (props.connectionState === "connected" && !modelReadyForDispatch) return;
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
inFlightThreadIdsRef.current.add(threadKey);
Expand All @@ -569,7 +578,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
inFlightThreadIdsRef.current.delete(threadKey);
}
}, [
modelReadyForDispatch,
onSendMessage,
props.connectionState,
props.environmentId,
props.environmentLabel,
props.selectedThread.id,
Expand Down Expand Up @@ -882,7 +893,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
iconNode={
<ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
}
label={currentModelOption?.label ?? currentModelSelection.model}
label={
props.connectionState !== "connected" || modelReadyForDispatch
? (currentModelOption?.label ?? currentModelSelection.model)
: "Checking models…"
}
maxWidth={152}
onPress={openSettings}
/>
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
buildModelOptions,
groupByProvider,
resolveDefaultableModelSelection,
resolveDispatchableModelSelection,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { scopedProjectKey } from "../../lib/scopedEntities";
Expand Down Expand Up @@ -152,6 +153,7 @@ type NewTaskFlowContextValue = {
readonly selectedProject: EnvironmentProject | null;
readonly modelOptions: ReadonlyArray<ModelOption>;
readonly selectedModel: ModelSelection | null;
readonly selectedModelReadyForDispatch: boolean;
readonly selectedModelOption: ModelOption | null;
readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
Expand Down Expand Up @@ -436,6 +438,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
: null;
const selectedModelReadyForDispatch =
selectedModel !== null &&
resolveDispatchableModelSelection(selectedEnvironmentServerConfig, selectedModel) !== null;

const selectedModelOption =
modelOptions.find(
Expand Down Expand Up @@ -1027,6 +1032,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProject,
modelOptions,
selectedModel,
selectedModelReadyForDispatch,
selectedModelOption,
selectedProviderSkills,
providerGroups,
Expand Down Expand Up @@ -1087,6 +1093,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
hasMoreBranches,
selectedEnvironmentId,
selectedModel,
selectedModelReadyForDispatch,
selectedModelKey,
selectedModelOption,
selectedProjectDraftKey,
Expand Down
Loading
Loading