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: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## What This Project Does

touchgrass is a terminal bridge for controlling local AI CLI sessions from chat.
Its core product goal is to be the best possible remote controller for Claude Code, Codex, PI, Kimi, and similar terminal-first AI tools.
Its core product goal is to be the best possible remote controller for Claude Code, Codex, PI, Kimi, Gemini, and similar terminal-first AI tools.
Users can build personal agents on top of touchgrass by defining behavior in `AGENTS.md` (without needing a separate agent runtime flag).

Supported channels:
Expand Down Expand Up @@ -39,6 +39,7 @@ touchgrass claude
touchgrass codex
touchgrass pi
touchgrass kimi
touchgrass gemini

touchgrass office chat <session_id> "text" # write into terminal (PTY stdin)
touchgrass office chat <session_id> --file <path> # write file path into terminal
Expand All @@ -63,7 +64,7 @@ Telegram chat shorthands:

## Architecture

1. CLI process (`touchgrass claude/codex/pi/kimi`):
1. CLI process (`touchgrass claude/codex/pi/kimi/gemini`):
- spawns PTY
- watches JSONL outputs
- bridges tool output to chat
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pub fn load_state() -> AppState {

/// IDs of presets that were built-in in previous versions but may have been removed.
fn is_old_builtin(id: &str) -> bool {
matches!(id, "shell" | "claude" | "claude-skip" | "codex" | "codex-auto" | "pi" | "kimi")
matches!(id, "shell" | "claude" | "claude-skip" | "codex" | "codex-auto" | "pi" | "kimi" | "gemini" | "gemini-auto")
}

pub fn save_state(state: &AppState) {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src-tauri/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ pub fn check_dependencies() -> DependencyReport {
};

// Check AI tools
let ai_names = ["claude", "codex", "kimi", "pi"];
let ai_names = ["claude", "codex", "kimi", "pi", "gemini"];
let ai_tools: Vec<DepStatus> = ai_names
.iter()
.map(|&name| {
Expand Down
16 changes: 15 additions & 1 deletion packages/app/src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ pub struct AppState {
}

/// IDs of built-in default presets (used for migration on load).
pub const DEFAULT_PRESET_IDS: &[&str] = &["claude", "claude-skip", "codex", "codex-auto", "pi"];
pub const DEFAULT_PRESET_IDS: &[&str] = &["claude", "claude-skip", "codex", "codex-auto", "pi", "gemini", "gemini-auto"];

impl Default for AppState {
fn default() -> Self {
Expand Down Expand Up @@ -146,6 +146,20 @@ impl Default for AppState {
project_id: None,
enabled: true,
},
Preset {
id: "gemini".into(),
label: "gemini --approval-mode auto_edit".into(),
command: "gemini --approval-mode auto_edit".into(),
project_id: None,
enabled: true,
},
Preset {
id: "gemini-auto".into(),
label: "gemini --yolo".into(),
command: "gemini --yolo".into(),
project_id: None,
enabled: true,
},
],
active_tabs: HashMap::new(),
saved_sessions: Vec::new(),
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/lib/PresetPopover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },
{ value: 'pi', label: 'Pi' },
{ value: 'kimi', label: 'Kimi' },
{ value: 'gemini', label: 'Gemini' },
];

onMount(async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/lib/SetupWizard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
codex: { label: 'Codex', command: 'npm i -g @openai/codex' },
kimi: { label: 'Kimi', command: 'curl -L code.kimi.com/install.sh | bash' },
pi: { label: 'Pi', command: 'npm install -g @mariozechner/pi-coding-agent' },
gemini: { label: 'Gemini', command: 'npm install -g @google/gemini-cli' },
};

async function checkDeps() {
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/lib/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const telegramIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fil

export const piIcon = `<svg width="16" height="16" viewBox="0 0 800 800" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path d="M517.36 400H634.72V634.72H517.36Z"/></svg>`;

export const geminiIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 1L14.89 9.11L23 12L14.89 14.89L12 23L9.11 14.89L1 12L9.11 9.11L12 1Z"/></svg>`;

export const terminalIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25zM7.25 8a.749.749 0 01-.22.53l-2.25 2.25a.749.749 0 11-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5h3a.75.75 0 010 1.5h-3a.75.75 0 010-1.5z"/></svg>`;

/** Return the appropriate icon SVG string for a command */
Expand All @@ -16,6 +18,7 @@ export function commandIcon(command: string): string {
if (cmd.includes('claude')) return claudeIcon;
if (cmd.includes('codex')) return openaiIcon;
if (cmd.startsWith('pi')) return piIcon;
if (cmd.includes('gemini')) return geminiIcon;
return terminalIcon;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/lib/stores/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export async function resumeSession(
let toolSid = savedSession.tool_session_id;

// If we don't have a stored session ID, try to fetch the most recent from daemon
if (!toolSid && (baseCmd === 'claude' || baseCmd === 'codex' || baseCmd === 'pi' || baseCmd === 'kimi')) {
if (!toolSid && (baseCmd === 'claude' || baseCmd === 'codex' || baseCmd === 'pi' || baseCmd === 'kimi' || baseCmd === 'gemini')) {
try {
const resp = await invoke<{ ok: boolean; sessions: Array<{ sessionRef: string }> }>(
'daemon_recent_sessions', { tool: baseCmd, cwd }
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/__tests__/gemini-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "bun:test";
import { __cliRunTestUtils } from "../cli/run";

const { extractApprovalPrompt } = __cliRunTestUtils;

describe("gemini prompt extraction", () => {
it("extracts 'Allow execution' prompt and options with command context", () => {
const ptyOutput = `
╭─── Action Required ──╮
│ ? Shell git diff │
│ │
│ git diff │
│ Allow execution? │
╰──────────────────────╯
● 1. Allow once
2. No
`;
const result = extractApprovalPrompt("gemini", ptyOutput);
expect(result).not.toBeNull();
// Should skip UI lines and extract just the command
expect(result?.promptText).toBe("git diff\n\nAllow execution?");
expect(result?.pollOptions).toEqual([
"Allow once",
"No"
]);
});

it("extracts 'Approve plan' prompt", () => {
const ptyOutput = `
Approve plan?
1. Yes
2. No
3. Edit
`;
const result = extractApprovalPrompt("gemini", ptyOutput);
expect(result).not.toBeNull();
expect(result?.promptText).toBe("Approve plan?");
expect(result?.pollOptions).toEqual(["Yes", "No", "Edit"]);
});

it("ignores partial 'Allow' prompts without a question mark", () => {
const ptyOutput = "Allow exec";
const result = extractApprovalPrompt("gemini", ptyOutput);
expect(result).toBeNull();
});

it("ignores prompts where options appear before the keyword", () => {
// This simulates old menu items lingering in the PTY buffer
const ptyOutput = `
1. Old Option
Allow execution of: 'ls'?
`;
const result = extractApprovalPrompt("gemini", ptyOutput);
expect(result).toBeNull();
});

it("cleans up TUI artifacts from options", () => {
const ptyOutput = `
Allow file write?
│ 1. Yes (y)
─ 2. No (n)
`;
const result = extractApprovalPrompt("gemini", ptyOutput);
expect(result?.pollOptions).toEqual(["Yes", "No"]);
});
});
8 changes: 8 additions & 0 deletions packages/cli/src/__tests__/restart-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ describe("restart command resume extraction", () => {
expect(__restartTestUtils.extractResumeRef("codex", command)).toBe("019c56ac-417b-7180-bd3f-2ed6e25885e3");
});

it("detects gemini tool", () => {
expect(__restartTestUtils.detectTool("gemini resume 123")).toBe("gemini");
});

it("extracts pi and kimi session refs from --session/-S flags", () => {
expect(
__restartTestUtils.extractResumeRef("pi", "pi --provider google --session /tmp/pi-session.jsonl")
Expand All @@ -55,6 +59,10 @@ describe("restart command resume extraction", () => {
expect(
__restartTestUtils.extractResumeRef("kimi", "kimi --model kimi-k2 -S kimi-session-1")
).toBe("kimi-session-1");

expect(
__restartTestUtils.extractResumeRef("gemini", "gemini resume gemini-session-123")
).toBe("gemini-session-123");
});
});

Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/__tests__/resume-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ describe("resume session discovery", () => {
"/tmp/.kimi/sessions/4cf85ededc494adbc6f7c889fb447b7a/399cb3b5-2e50-4a59-a6c8-e13e61f3eb7d/wire.jsonl"
)
).toBe("399cb3b5-2e50-4a59-a6c8-e13e61f3eb7d");

expect(
__resumeTestUtils.normalizePreview(" Gemini response ")
).toBe("Gemini response");
});

it("reads recent sessions from home directories", () => {
Expand All @@ -93,6 +97,19 @@ describe("resume session discovery", () => {
);
utimesSync(claudeFile, new Date(), new Date("2026-02-15T10:00:00.000Z"));

const geminiDir = join(root, ".gemini", "sessions", "-tmp-repo");
mkdirSync(geminiDir, { recursive: true });
const geminiFile = join(geminiDir, "gemini-session-1.jsonl");
writeFileSync(
geminiFile,
JSON.stringify({
type: "message",
role: "assistant",
content: "hello from gemini",
}) + "\n"
);
utimesSync(geminiFile, new Date(), new Date("2026-02-15T11:00:00.000Z"));

const codexDir = join(root, ".codex", "sessions", "2026", "02", "15");
mkdirSync(codexDir, { recursive: true });
const codexOld = join(
Expand Down Expand Up @@ -150,6 +167,11 @@ describe("resume session discovery", () => {
expect(claudeSessions[0]?.label).toContain("hello from claude");
expect(claudeSessions[0]?.label).toContain("ago:");

const geminiSessions = __resumeTestUtils.listRecentSessions("gemini", "/tmp/repo");
expect(geminiSessions[0]?.sessionRef).toBe("gemini-session-1");
expect(geminiSessions[0]?.label).toContain("hello from gemini");
expect(geminiSessions[0]?.label).toContain("ago:");

const codexSessions = __resumeTestUtils.listRecentSessions("codex", "/tmp/repo");
expect(codexSessions[0]?.sessionRef).toBe("019c56ac-417b-7180-bd3f-2ed6e2589999");
expect(codexSessions[1]?.sessionRef).toBe("019c56ac-417b-7180-bd3f-2ed6e25885e3");
Expand Down Expand Up @@ -184,6 +206,17 @@ describe("resume session discovery", () => {
);
expect(__resumeTestUtils.extractLastAssistantPreview("claude", claudeFile)).toContain("Claude wrote");

const geminiFile = join(root, "gemini.jsonl");
writeFileSync(
geminiFile,
JSON.stringify({
type: "message",
role: "assistant",
content: "Gemini wrote this response",
}) + "\n"
);
expect(__resumeTestUtils.extractLastAssistantPreview("gemini", geminiFile)).toContain("Gemini wrote");

const codexFile = join(root, "codex.jsonl");
writeFileSync(
codexFile,
Expand All @@ -194,6 +227,7 @@ describe("resume session discovery", () => {
);
expect(__resumeTestUtils.extractLastAssistantPreview("codex", codexFile)).toContain("Codex output");


const piFile = join(root, "pi.jsonl");
writeFileSync(
piFile,
Expand Down Expand Up @@ -226,6 +260,7 @@ describe("resume handler", () => {
expect(__resumeTestUtils.detectTool("codex resume abc")).toBe("codex");
expect(__resumeTestUtils.detectTool("pi --mode json")).toBe("pi");
expect(__resumeTestUtils.detectTool("kimi --model kimi-k2")).toBe("kimi");
expect(__resumeTestUtils.detectTool("gemini --some-flag")).toBe("gemini");
expect(__resumeTestUtils.detectTool("bash")).toBeNull();
});

Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/bot/handlers/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ const RESUME_BUTTON_LIMIT = 10;
const RESUME_SEARCH_LIMIT = 500;
const RESUME_TAIL_BYTES = 24 * 1024;

export type ResumeTool = "claude" | "codex" | "pi" | "kimi";
export type ResumeTool = "claude" | "codex" | "pi" | "kimi" | "gemini";

function detectTool(command: string): ResumeTool | null {
const head = command.trim().split(/\s+/)[0]?.toLowerCase();
if (head === "claude" || head === "codex" || head === "pi" || head === "kimi") return head;
if (head === "claude" || head === "codex" || head === "pi" || head === "kimi" || head === "gemini") return head;
return null;
}

Expand Down Expand Up @@ -118,6 +118,10 @@ function parseAssistantTextLine(tool: ResumeTool, line: string): string | null {
return text || null;
}
}

if (tool === "gemini" && msg.type === "message" && msg.role === "assistant" && typeof msg.content === "string") {
return msg.content.trim() || null;
}
} catch {}
return null;
}
Expand Down Expand Up @@ -298,6 +302,11 @@ export function listRecentSessions(tool: ResumeTool, cwd: string): ResumeSession
return toResumeCandidates(tool, listJsonlFiles(dir));
}

if (tool === "gemini") {
const dir = join(userHomeDir(), ".gemini", "sessions", encodedClaudeDir(cleanCwd));
return toResumeCandidates(tool, listJsonlFiles(dir));
}

if (tool === "kimi") {
const dir = join(userHomeDir(), ".kimi", "sessions", encodedKimiDir(cleanCwd));
return toResumeCandidates(tool, listKimiWireFiles(dir));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/bot/handlers/session-mgmt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { RemoteSession } from "../../session/manager";
import type { RouterContext } from "../command-router";
import { handleSessionCommand } from "./session";

type SessionTool = "claude" | "codex" | "pi" | "kimi";
type SessionTool = "claude" | "codex" | "pi" | "kimi" | "gemini";

function cleanToken(token: string | undefined): string | null {
if (!token) return null;
Expand All @@ -13,7 +13,7 @@ function cleanToken(token: string | undefined): string | null {

function detectTool(command: string): SessionTool | null {
const head = command.trim().split(/\s+/)[0]?.toLowerCase();
if (head === "claude" || head === "codex" || head === "pi" || head === "kimi") return head;
if (head === "claude" || head === "codex" || head === "pi" || head === "kimi" || head === "gemini") return head;
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/bot/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { readAgentSoul } from "../../daemon/agent-soul";
import type { RouterContext } from "../command-router";
import { basename } from "path";

type SessionTool = "claude" | "codex" | "pi" | "kimi";
type SessionTool = "claude" | "codex" | "pi" | "kimi" | "gemini";

function detectTool(command: string): SessionTool | null {
const head = command.trim().split(/\s+/)[0]?.toLowerCase();
if (head === "claude" || head === "codex" || head === "pi" || head === "kimi") return head;
if (head === "claude" || head === "codex" || head === "pi" || head === "kimi" || head === "gemini") return head;
return null;
}

Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/channels/internal/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// for some reason, the internal channel is missing from the repository. This is a placeholder to prevent errors when trying to use it.
export class InternalChannel {
type = "internal";
fmt = {} as any;
constructor(public name: string) {}
async send() {}
async sendOutput() {}
clearLastMessage() {}
async startReceiving(cb: any) {}
stopReceiving() {}
setTyping() {}
drainEvents(chatId: string, since: number) { return []; }
async routeInbound(msg: any) {}
onPollAnswer: any;
}
5 changes: 5 additions & 0 deletions packages/cli/src/channels/slack/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// for some reason, the slack api is missing from the repository. This is a placeholder to prevent errors when trying to use it.
export class SlackApi {
constructor(token: string) {}
async authTest() { return { user: "", team: "", user_id: "", team_id: "" }; }
}
14 changes: 14 additions & 0 deletions packages/cli/src/channels/slack/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// for some reason, the slack channel is missing from the repository. This is a placeholder to prevent errors when trying to use it.
export class SlackChannel {
type = "slack";
fmt = {} as any;
constructor(token: string, appToken: string, name: string) {
console.error("SlackChannel is missing from the repository.");
}
async send() {}
async sendOutput() {}
clearLastMessage() {}
async startReceiving() {}
stopReceiving() {}
setTyping() {}
}
Loading