From a3fea2837c45807303d2082cc2b0de05f7b55caa Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 18 Jun 2026 06:40:23 +0100 Subject: [PATCH 1/2] feat(context-menu): add trailing action button support --- src/App.css | 38 +++++++++++++++++++++ src/components/ContextMenu.tsx | 60 ++++++++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/App.css b/src/App.css index 96e707a..7ec9537 100644 --- a/src/App.css +++ b/src/App.css @@ -2730,3 +2730,41 @@ background: rgba(255, 255, 255, 0.06); margin: 4px 0; } + +.context-menu__row { + display: flex; + align-items: stretch; + gap: 2px; +} + +.context-menu__row--has-trailing .context-menu__item { + flex: 1 1 auto; + width: auto; +} + +.context-menu__trailing { + all: unset; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 28px; + border-radius: 4px; + color: #f5c451; + cursor: pointer; +} + +.context-menu__trailing:hover { + background: rgba(245, 196, 81, 0.16); + color: #ffd56b; +} + +.context-menu__trailing:disabled { + color: #7f8794; + cursor: default; + opacity: 0.7; +} + +.context-menu__trailing:disabled:hover { + background: transparent; +} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 6d3db8c..c320f9a 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,6 +1,13 @@ import { type ReactNode, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; +export type ContextMenuTrailingAction = { + icon: ReactNode; + label: string; + onClick: () => void; + disabled?: boolean; +}; + export type ContextMenuItem = { label: string; onClick: () => void; @@ -9,6 +16,8 @@ export type ContextMenuItem = { disabled?: boolean; separator?: boolean; key?: string; + /** Optional secondary action rendered as a button on the right edge of the row. */ + trailingAction?: ContextMenuTrailingAction; }; type ContextMenuProps = { @@ -80,21 +89,44 @@ export function ContextMenu({ x, y, items, onClose, portalContainer }: ContextMe {item.separator ? (
) : ( - + + {item.trailingAction && ( + + )} +
)} ))} From f48f4c661bd78dd83b48440d1bb13360f9353db1 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 18 Jun 2026 06:40:23 +0100 Subject: [PATCH 2/2] feat(quick-push): add AI-powered quick commit and push workflow --- electron/aiTabMetadata/service.ts | 82 +++++ electron/main.ts | 8 + electron/preload.ts | 8 + electron/quickPush/ipc.ts | 18 + electron/quickPush/service.ts | 546 ++++++++++++++++++++++++++++++ src/App.tsx | 40 +++ src/components/QuickPushModal.tsx | 370 ++++++++++++++++++++ src/components/quickPushModal.css | 288 ++++++++++++++++ src/types/terminay.ts | 55 +++ 9 files changed, 1415 insertions(+) create mode 100644 electron/quickPush/ipc.ts create mode 100644 electron/quickPush/service.ts create mode 100644 src/components/QuickPushModal.tsx create mode 100644 src/components/quickPushModal.css diff --git a/electron/aiTabMetadata/service.ts b/electron/aiTabMetadata/service.ts index a504809..753b6d8 100644 --- a/electron/aiTabMetadata/service.ts +++ b/electron/aiTabMetadata/service.ts @@ -54,6 +54,7 @@ type AiTabMetadataTestMock = { models?: AiTabMetadataModel[] noteResult?: string titleResult?: string + promptResult?: string } type ShellEnv = Record @@ -757,4 +758,85 @@ export class AiTabMetadataService { await rm(tempDir, { force: true, recursive: true }) } } + + /** + * Run a single non-interactive prompt and return the raw model text. Unlike + * {@link generate}, this does not shape the prompt or post-process the result, + * so callers (e.g. Quick Push) can ask the model for structured output. + */ + async runPrompt(request: { provider: AiTabMetadataProvider; model: string; prompt: string; cwd: string }): Promise { + if (request.provider !== 'codex' && request.provider !== 'claudeCode') { + throw new Error(`Unsupported AI provider: ${request.provider}`) + } + + if (!request.model.trim()) { + throw new Error('Choose an AI model before running the AI provider.') + } + + const isUsingMock = + process.env.TERMINAY_TEST === '1' && + ((request.provider === 'codex' && process.env.TERMINAY_TEST_USE_REAL_CODEX !== '1') || + (request.provider === 'claudeCode' && process.env.TERMINAY_TEST_USE_REAL_CLAUDE_CODE !== '1')) + + if (isUsingMock) { + if (this.testMock.error) { + throw new Error(this.testMock.error) + } + + return this.testMock.promptResult ?? '{}' + } + + if (request.provider === 'claudeCode') { + try { + const providerEnv = await getProviderEnv() + return await runClaudeCodePrint(request.prompt, { + cwd: request.cwd, + env: providerEnv, + model: request.model, + timeout: PROVIDER_TIMEOUT_MS, + }) + } catch (error) { + throw normalizeProviderError(error, 'Unable to run Claude Code.', 'Claude Code') + } + } + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'terminay-codex-')) + const outputPath = path.join(tempDir, 'last-message.txt') + + try { + const providerEnv = await getProviderEnv() + await runCodexExec( + [ + 'exec', + '--model', + request.model, + '-c', + 'model_reasoning_effort="low"', + '--sandbox', + 'read-only', + '--skip-git-repo-check', + '--ephemeral', + '--ignore-rules', + '--color', + 'never', + '--cd', + request.cwd, + '-o', + outputPath, + request.prompt, + ], + { + cwd: request.cwd, + env: providerEnv, + timeout: PROVIDER_TIMEOUT_MS, + }, + ) + + return await readFile(outputPath, 'utf8') + } catch (error) { + throw normalizeProviderError(error, 'Unable to run Codex.', 'Codex') + } finally { + await rm(tempDir, { force: true, recursive: true }) + } + } } diff --git a/electron/main.ts b/electron/main.ts index f30460f..c3b17f9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,6 +11,8 @@ import { defaultTerminalSettings, normalizeTerminalSettings } from '../src/termi import { findCommandForKeyboardEvent, getCommandShortcut, isReservedSystemAccelerator } from '../src/keyboardShortcuts' import { registerAiTabMetadataIpcHandlers } from './aiTabMetadata/ipc' import { AiTabMetadataService, warmAiTabMetadataProviderEnv } from './aiTabMetadata/service' +import { registerQuickPushIpcHandlers } from './quickPush/ipc' +import { QuickPushService } from './quickPush/service' import { TerminalRecordingService } from './recording/service' import type { MacroDefinition } from '../src/types/macros' import type { TerminalSettings } from '../src/types/settings' @@ -215,6 +217,7 @@ const fileWatchService = new FileWatchService(fileBufferService) const fileExplorerWatchService = new FileExplorerWatchService(() => app.getPath('home')) const gitDiffService = new GitDiffService(fileBufferService) const aiTabMetadataService = new AiTabMetadataService(app.getPath('home')) +const quickPushService = new QuickPushService(aiTabMetadataService) warmAiTabMetadataProviderEnv() let cachedAppUpdateStatus: AppUpdateStatus | null = null let appUpdateFetchPromise: Promise | null = null @@ -2726,6 +2729,11 @@ registerAiTabMetadataIpcHandlers({ ipcMain, }) +registerQuickPushIpcHandlers({ + quickPushService, + ipcMain, +}) + app.on('window-all-closed', () => { mainWindow = null if (process.platform !== 'darwin') { diff --git a/electron/preload.ts b/electron/preload.ts index 3dc909a..b83965a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -31,6 +31,10 @@ import type { ControlRendererResponseMessage, ProjectEditWindowDraft, ProjectEditWindowResult, + QuickPushApplyRequest, + QuickPushApplyResult, + QuickPushGenerateRequest, + QuickPushPlan, FileExplorerWatchEvent, RemoteAccessStatus, SettingsChangeMessage, @@ -124,6 +128,10 @@ contextBridge.exposeInMainWorld('terminay', { ipcRenderer.invoke('ai-tab-metadata:list-models', { provider }) as Promise, generateAiTabMetadata: (payload: AiTabMetadataGenerateRequest) => ipcRenderer.invoke('ai-tab-metadata:generate', payload) as Promise, + generateQuickPushPlan: (payload: QuickPushGenerateRequest) => + ipcRenderer.invoke('quick-push:generate-plan', payload) as Promise, + applyQuickPush: (payload: QuickPushApplyRequest) => + ipcRenderer.invoke('quick-push:apply', payload) as Promise, getMacros: () => ipcRenderer.invoke('macros:get') as Promise, updateMacros: (macros: MacroDefinition[]) => ipcRenderer.invoke('macros:update', macros) as Promise, diff --git a/electron/quickPush/ipc.ts b/electron/quickPush/ipc.ts new file mode 100644 index 0000000..3a8c2a6 --- /dev/null +++ b/electron/quickPush/ipc.ts @@ -0,0 +1,18 @@ +import type { IpcMain } from 'electron' +import type { QuickPushApplyRequest, QuickPushGenerateRequest } from '../../src/types/terminay' +import type { QuickPushService } from './service' + +type RegisterQuickPushIpcOptions = { + quickPushService: QuickPushService + ipcMain: IpcMain +} + +export function registerQuickPushIpcHandlers({ quickPushService, ipcMain }: RegisterQuickPushIpcOptions): void { + ipcMain.handle('quick-push:generate-plan', async (_event, payload: QuickPushGenerateRequest) => { + return quickPushService.generatePlan(payload) + }) + + ipcMain.handle('quick-push:apply', async (_event, payload: QuickPushApplyRequest) => { + return quickPushService.apply(payload) + }) +} diff --git a/electron/quickPush/service.ts b/electron/quickPush/service.ts new file mode 100644 index 0000000..1e09cdc --- /dev/null +++ b/electron/quickPush/service.ts @@ -0,0 +1,546 @@ +import { execFile } from 'node:child_process' +import { readFile, stat } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import type { + QuickPushAction, + QuickPushApplyRequest, + QuickPushApplyResult, + QuickPushApplyStep, + QuickPushCommit, + QuickPushGenerateRequest, + QuickPushPlan, + QuickPushPullRequest, +} from '../../src/types/terminay' +import type { AiTabMetadataService } from '../aiTabMetadata/service' + +const execFileAsync = promisify(execFile) + +const MAX_BUFFER = 1024 * 1024 * 16 +const MAX_DIFF_CHARS = 60_000 +const MAX_UNTRACKED_TOTAL_CHARS = 20_000 +const MAX_UNTRACKED_FILE_CHARS = 8_000 +const GIT_TIMEOUT_MS = 30_000 + +type PorcelainEntry = { + x: string + y: string + path: string +} + +type QuickPushContext = { + repoRoot: string + branch: string + changedFiles: string[] + statusText: string + diffText: string + untrackedText: string + warnings: string[] +} + +function truncate(value: string, limit: number): string { + if (value.length <= limit) { + return value + } + return `${value.slice(0, limit)}\n… [truncated ${value.length - limit} characters]` +} + +/** Parse `git status --porcelain=v1 -z` output into entries (rename source paths are skipped). */ +export function parsePorcelain(stdout: string): PorcelainEntry[] { + const tokens = stdout.split('\0').filter((token) => token.length > 0) + const entries: PorcelainEntry[] = [] + + for (let index = 0; index < tokens.length; index += 1) { + const record = tokens[index] + if (record.length < 4) { + continue + } + + const x = record[0] + const y = record[1] + const filePath = record.slice(3) + + // Rename/copy records are followed by their original path as a separate token. + if (x === 'R' || x === 'C' || y === 'R' || y === 'C') { + index += 1 + } + + entries.push({ x, y, path: filePath }) + } + + return entries +} + +function looksBinary(buffer: Buffer): boolean { + return buffer.subarray(0, 8000).includes(0) +} + +async function runGit(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + maxBuffer: MAX_BUFFER, + timeout: GIT_TIMEOUT_MS, + }) + return stdout +} + +function slugifyBranch(message: string): string { + const slug = message + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/g, '') + return slug || 'changes' +} + +function actionNeedsBranch(action: QuickPushAction): boolean { + return action === 'new' || action === 'new-pr' +} + +function actionNeedsPullRequest(action: QuickPushAction): boolean { + return action === 'current-pr' || action === 'new-pr' +} + +/** + * Extract the first balanced top-level JSON object from raw model output and + * repair the mistakes models commonly make: code fences, raw control characters + * inside strings (e.g. a multi-line PR body), and trailing commas. + */ +export function extractJsonObject(raw: string): string | null { + const withoutFences = raw.replace(/```(?:json)?/gi, '') + const start = withoutFences.indexOf('{') + if (start === -1) { + return null + } + + let depth = 0 + let inString = false + let escaped = false + let out = '' + let closed = false + + for (let index = start; index < withoutFences.length; index += 1) { + const char = withoutFences[index] + + if (inString) { + if (escaped) { + out += char + escaped = false + } else if (char === '\\') { + out += char + escaped = true + } else if (char === '"') { + out += char + inString = false + } else if (char === '\n') { + out += '\\n' + } else if (char === '\r') { + out += '\\r' + } else if (char === '\t') { + out += '\\t' + } else { + out += char + } + continue + } + + out += char + + if (char === '"') { + inString = true + } else if (char === '{') { + depth += 1 + } else if (char === '}') { + depth -= 1 + if (depth === 0) { + closed = true + break + } + } + } + + if (!closed) { + return null + } + + // Drop trailing commas before a closing brace/bracket. + return out.replace(/,(\s*[}\]])/g, '$1') +} + +/** + * Turn raw model output into a normalized {@link QuickPushPlan}. Pure (no IO) so + * it can be unit-tested against fixture strings. + */ +export function parseQuickPushPlan( + raw: string, + options: { action: QuickPushAction; changedFiles: string[]; warnings?: string[] }, +): QuickPushPlan { + const warnings = [...(options.warnings ?? [])] + const json = extractJsonObject(raw) + if (!json) { + console.warn('[quick-push] No JSON object found in model output:\n', raw.slice(0, 2000)) + throw new Error('The AI did not return a JSON commit plan.') + } + + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch (error) { + console.warn( + '[quick-push] Failed to parse model JSON:', + error instanceof Error ? error.message : error, + '\n--- extracted ---\n', + json.slice(0, 2000), + '\n--- raw ---\n', + raw.slice(0, 2000), + ) + throw new Error('The AI returned a commit plan that was not valid JSON.') + } + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('The AI returned a commit plan that was not an object.') + } + + const root = parsed as Record + const knownFiles = new Set(options.changedFiles) + const assigned = new Set() + const commits: QuickPushCommit[] = [] + + const rawCommits = Array.isArray(root.commits) ? root.commits : [] + for (const entry of rawCommits) { + if (typeof entry !== 'object' || entry === null) { + continue + } + + const candidate = entry as Record + const message = typeof candidate.message === 'string' ? candidate.message.trim() : '' + if (!message) { + continue + } + + const rawFiles = Array.isArray(candidate.files) ? candidate.files : [] + const files: string[] = [] + for (const file of rawFiles) { + if (typeof file !== 'string') { + continue + } + const normalized = file.trim() + if (!normalized) { + continue + } + if (!knownFiles.has(normalized)) { + warnings.push(`Ignored "${normalized}" in "${message}" — not a changed file.`) + continue + } + if (assigned.has(normalized)) { + warnings.push(`"${normalized}" was listed in more than one commit; kept the first.`) + continue + } + assigned.add(normalized) + files.push(normalized) + } + + if (files.length === 0) { + warnings.push(`Skipped commit "${message}" — it had no valid changed files.`) + continue + } + + commits.push({ message, files }) + } + + const uncoveredFiles = options.changedFiles.filter((file) => !assigned.has(file)) + + let branchName: string | null = null + if (actionNeedsBranch(options.action)) { + const candidate = typeof root.branchName === 'string' ? root.branchName.trim() : '' + branchName = candidate || (commits[0] ? slugifyBranch(commits[0].message) : null) + if (!candidate && branchName) { + warnings.push(`The AI did not name a branch; using "${branchName}".`) + } + } + + let pullRequest: QuickPushPullRequest | null = null + if (actionNeedsPullRequest(options.action)) { + const rawPr = root.pullRequest + if (typeof rawPr === 'object' && rawPr !== null) { + const prCandidate = rawPr as Record + const title = typeof prCandidate.title === 'string' ? prCandidate.title.trim() : '' + const body = typeof prCandidate.body === 'string' ? prCandidate.body.trim() : '' + if (title) { + pullRequest = { title, body } + } + } + + if (!pullRequest && commits.length > 0) { + pullRequest = { + title: commits[0].message, + body: commits.map((commit) => `- ${commit.message}`).join('\n'), + } + warnings.push('The AI did not provide pull request details; generated them from the commits.') + } + } + + return { branchName, pullRequest, commits, uncoveredFiles, warnings } +} + +function describeAction(action: QuickPushAction, branch: string): string { + switch (action) { + case 'current': + return `Commit all of the changes onto the current branch "${branch}". Do not set "branchName" or "pullRequest".` + case 'current-pr': + return `Commit all of the changes onto the current branch "${branch}", then open a pull request. Set "pullRequest" with a title and body. Do not set "branchName".` + case 'new': + return `Commit all of the changes onto a new, descriptively named branch. Set "branchName" to a short kebab-case branch name (you may include a "feat/" or "fix/" style prefix). Do not set "pullRequest".` + case 'new-pr': + return `Commit all of the changes onto a new, descriptively named branch, then open a pull request. Set "branchName" to a short kebab-case branch name and set "pullRequest" with a title and body.` + default: + return 'Commit all of the changes.' + } +} + +function buildPrompt(context: QuickPushContext, action: QuickPushAction): string { + return [ + 'You are a commit-splitting assistant. Group the working-tree changes below into one or more logical git commits.', + '', + 'IMPORTANT: Do NOT use any tools. Do NOT explore the filesystem or run commands. Work only from the information given.', + 'Respond with ONLY a JSON object — no markdown code fences, no commentary — in exactly this shape:', + '', + '{', + ' "branchName": string | null,', + ' "pullRequest": { "title": string, "body": string } | null,', + ' "commits": [ { "message": string, "files": string[] } ]', + '}', + '', + 'Rules:', + '- Write commit messages in Conventional Commits style (e.g. "feat: …", "fix: …", "chore: …").', + '- Every changed file listed below must appear in exactly one commit\'s "files" array.', + '- Use the file paths EXACTLY as shown under "Changed files" (relative to the repo root).', + '- Group related changes together; split unrelated changes into separate commits.', + '', + `Task: ${describeAction(action, context.branch)}`, + '', + 'Changed files:', + context.changedFiles.length > 0 ? context.changedFiles.map((file) => `- ${file}`).join('\n') : '(none)', + '', + '=== git status ===', + context.statusText.trim() || '(clean)', + '', + '=== git diff (tracked changes) ===', + context.diffText.trim() || '(no tracked diff)', + '', + '=== new (untracked) files ===', + context.untrackedText.trim() || '(none)', + ].join('\n') +} + +async function gatherContext(cwd: string): Promise { + const warnings: string[] = [] + + const repoRoot = (await runGit(['rev-parse', '--show-toplevel'], cwd)).trim() + if (!repoRoot) { + throw new Error('Quick Push must be run inside a git repository.') + } + + let branch = '' + try { + branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)).trim() + } catch { + branch = '' + } + if (!branch || branch === 'HEAD') { + branch = 'the current branch' + } + + const porcelain = await runGit( + ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=no'], + repoRoot, + ) + const entries = parsePorcelain(porcelain) + const changedFiles = entries.map((entry) => entry.path) + + if (changedFiles.length === 0) { + throw new Error('There are no changes to commit.') + } + + const [statusTextRaw, unstaged, staged] = await Promise.all([ + runGit(['-c', 'color.ui=never', 'status'], repoRoot), + runGit(['-c', 'color.ui=never', 'diff'], repoRoot), + runGit(['-c', 'color.ui=never', 'diff', '--cached'], repoRoot), + ]) + + const diffParts: string[] = [] + if (staged.trim()) { + diffParts.push(`# Staged changes\n${staged}`) + } + if (unstaged.trim()) { + diffParts.push(`# Unstaged changes\n${unstaged}`) + } + const diffText = truncate(diffParts.join('\n\n'), MAX_DIFF_CHARS) + + const untrackedEntries = entries.filter((entry) => entry.x === '?' && entry.y === '?') + const untrackedSections: string[] = [] + let untrackedBudget = MAX_UNTRACKED_TOTAL_CHARS + for (const entry of untrackedEntries) { + if (untrackedBudget <= 0) { + untrackedSections.push(`--- ${entry.path} (omitted: untracked context budget exhausted) ---`) + continue + } + + const absolute = path.join(repoRoot, entry.path) + try { + const stats = await stat(absolute) + if (!stats.isFile()) { + continue + } + const buffer = await readFile(absolute) + if (looksBinary(buffer)) { + untrackedSections.push(`--- ${entry.path} (binary, omitted) ---`) + continue + } + const clipped = truncate(buffer.toString('utf8'), Math.min(MAX_UNTRACKED_FILE_CHARS, untrackedBudget)) + untrackedBudget -= clipped.length + untrackedSections.push(`--- ${entry.path} ---\n${clipped}`) + } catch { + warnings.push(`Could not read untracked file "${entry.path}".`) + } + } + + return { + repoRoot, + branch, + changedFiles, + statusText: statusTextRaw, + diffText, + untrackedText: untrackedSections.join('\n\n'), + warnings, + } +} + +function pickRemote(remotes: string): string | null { + const names = remotes + .split(/\r?\n/) + .map((name) => name.trim()) + .filter((name) => name.length > 0) + if (names.length === 0) { + return null + } + return names.includes('origin') ? 'origin' : names[0] +} + +function extractUrl(text: string): string | null { + const matches = text.match(/https?:\/\/\S+/g) + if (!matches || matches.length === 0) { + return null + } + return matches[matches.length - 1].replace(/[).,]+$/, '') +} + +export class QuickPushService { + constructor(private readonly aiTabMetadataService: AiTabMetadataService) {} + + async generatePlan(request: QuickPushGenerateRequest): Promise { + const context = await gatherContext(request.cwd) + const prompt = buildPrompt(context, request.action) + const raw = await this.aiTabMetadataService.runPrompt({ + provider: request.provider, + model: request.model, + prompt, + cwd: context.repoRoot, + }) + + return parseQuickPushPlan(raw, { + action: request.action, + changedFiles: context.changedFiles, + warnings: context.warnings, + }) + } + + async apply(request: QuickPushApplyRequest): Promise { + const steps: QuickPushApplyStep[] = [] + let pushed = false + let pullRequestUrl: string | null = null + let branch: string | null = null + + const repoRoot = (await runGit(['rev-parse', '--show-toplevel'], request.cwd)).trim() + + const run = async (label: string, args: string[]): Promise => { + try { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd: repoRoot, + maxBuffer: MAX_BUFFER, + timeout: GIT_TIMEOUT_MS, + }) + const output = `${stdout}${stderr}`.trim() + steps.push({ label, ok: true, output: output || undefined }) + return stdout + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + steps.push({ label, ok: false, output: message }) + throw new Error(message) + } + } + + try { + if (request.commits.length === 0) { + throw new Error('There are no commits to apply.') + } + + if (request.action === 'new' || request.action === 'new-pr') { + const branchName = request.branchName?.trim() + if (!branchName) { + throw new Error('A branch name is required to push to a new branch.') + } + await run(`Create branch ${branchName}`, ['checkout', '-b', branchName]) + branch = branchName + } else { + branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)).trim() + } + + for (const commit of request.commits) { + const shortMessage = commit.message.split('\n')[0] + await run(`Stage: ${shortMessage}`, ['add', '--', ...commit.files]) + await run(`Commit: ${shortMessage}`, ['commit', '-m', commit.message, '--', ...commit.files]) + } + + const remote = pickRemote(await runGit(['remote'], repoRoot)) + if (!remote) { + throw new Error('No git remote is configured to push to.') + } + + const pushTarget = branch && branch !== 'HEAD' ? branch : 'HEAD' + await run(`Push to ${remote}`, ['push', '-u', remote, pushTarget]) + pushed = true + + if (request.action === 'current-pr' || request.action === 'new-pr') { + const pr = request.pullRequest + if (!pr?.title.trim()) { + throw new Error('Pull request details are missing.') + } + try { + const { stdout, stderr } = await execFileAsync( + 'gh', + ['pr', 'create', '--title', pr.title, '--body', pr.body ?? ''], + { cwd: repoRoot, maxBuffer: MAX_BUFFER, timeout: GIT_TIMEOUT_MS }, + ) + const output = `${stdout}${stderr}`.trim() + pullRequestUrl = extractUrl(`${stdout}\n${stderr}`) + steps.push({ label: 'Open pull request', ok: true, output: output || undefined }) + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: string; stdout?: string } + const message = + err.code === 'ENOENT' + ? 'GitHub CLI (gh) is not installed or not on PATH.' + : (err.stderr?.trim() || err.stdout?.trim() || (error instanceof Error ? error.message : String(error))) + steps.push({ label: 'Open pull request', ok: false, output: message }) + throw new Error(message) + } + } + + return { ok: true, steps, branch, pushed, pullRequestUrl, error: null } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, steps, branch, pushed, pullRequestUrl, error: message } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index f9f0b2a..555355e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { Terminal, Trash2, Upload, + Zap, } from 'lucide-react'; import { ContextMenu, type ContextMenuItem } from './components/ContextMenu'; import type { FilePanelInstanceParams } from './components/file-viewer'; @@ -47,6 +48,7 @@ import { GitPanel } from './components/git-panel/GitPanel'; import { SidebarPane } from './components/sidebar/SidebarPane'; import { SidebarSplit } from './components/sidebar/SidebarSplit'; import { McpInstallModal } from './components/McpInstallModal'; +import { QuickPushModal } from './components/QuickPushModal'; import { TerminalPanel } from './components/TerminalPanel'; import type { TerminalActivityState, @@ -1680,6 +1682,7 @@ const ProjectWorkspace = forwardRef< x: number; y: number; } | null>(null); + const [quickPushAction, setQuickPushAction] = useState(null); const [loadingPaths, setLoadingPaths] = useState>({}); const [runningMacroRunsBySession, setRunningMacroRunsBySession] = useState< Record @@ -3859,6 +3862,25 @@ const ProjectWorkspace = forwardRef< ], ); + const launchQuickPush = useCallback( + (action: GitPushAgentAction) => { + setGitPushMenuPosition(null); + + if (settings.gitPushAgent.provider === 'disabled') { + setErrorText( + 'Choose a Git Push agent in Settings → AI → Git Push Agent first.', + ); + void window.terminay.openSettingsWindow({ + sectionId: 'git-push-agent', + }); + return; + } + + setQuickPushAction(action); + }, + [settings.gitPushAgent.provider], + ); + const exportTerminalForMove = useCallback( (panelId: string): MovedTerminalTab | null => { const api = dockviewApiRef.current; @@ -6001,6 +6023,11 @@ const ProjectWorkspace = forwardRef<