Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/main/codex-accounts/runtime-home-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalPaneOpacityTransitionMs: 150,
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalTabAcceptSuggestion: true,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'split-vertical',
Expand Down
1 change: 1 addition & 0 deletions src/main/codex-accounts/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalPaneOpacityTransitionMs: 150,
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalTabAcceptSuggestion: true,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'split-vertical',
Expand Down
9 changes: 2 additions & 7 deletions src/main/daemon/shell-ready.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { tmpdir } from 'os'
import { basename, join } from 'path'
import { chmodSync, mkdirSync, writeFileSync } from 'fs'
import { getZshShellReadyRcfileContent } from '../providers/local-pty-shell-ready'

const SHELL_READY_WRAPPER_ROOT = join(tmpdir(), 'orca-shell-ready')
const SHELL_READY_MARKER = '\\033]777;orca-shell-ready\\007'
Expand Down Expand Up @@ -28,12 +29,6 @@ export ZDOTDIR=${quotePosixSingle(zshDir)}
const zshProfile = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
`
const zshRc = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
source "$_orca_home/.zshrc"
fi
`
const zshLogin = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
Expand Down Expand Up @@ -72,7 +67,7 @@ fi
const files = [
[join(zshDir, '.zshenv'), zshEnv],
[join(zshDir, '.zprofile'), zshProfile],
[join(zshDir, '.zshrc'), zshRc],
[join(zshDir, '.zshrc'), getZshShellReadyRcfileContent()],
[join(zshDir, '.zlogin'), zshLogin],
[join(bashDir, 'rcfile'), bashRc]
] as const
Expand Down
7 changes: 7 additions & 0 deletions src/main/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,13 @@ describe('registerPtyHandlers', () => {
}
})

it('makes the zsh Tab binding configurable and disabled by env flag', async () => {
const { getZshShellReadyRcfileContent } = await import('./pty')
const zshRcContent = getZshShellReadyRcfileContent()
expect(zshRcContent).toContain('ORCA_ZSH_TAB_ACCEPTS_SUGGESTION')
expect(zshRcContent).toContain("bindkey '^I' forward-char")
})

it('does not write the startup command before the shell-ready marker arrives', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
Expand Down
5 changes: 4 additions & 1 deletion src/main/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ foreground-process inspection, and renderer IPC stay behind a single audited
boundary. Splitting it by line count would scatter tightly coupled terminal
process behavior across files without a cleaner ownership seam. */
import { type BrowserWindow, ipcMain } from 'electron'
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
export {
getBashShellReadyRcfileContent,
getZshShellReadyRcfileContent
} from '../providers/local-pty-shell-ready'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { GlobalSettings } from '../../shared/types'
import { openCodeHookService } from '../opencode/hook-service'
Expand Down
25 changes: 18 additions & 7 deletions src/main/providers/local-pty-shell-ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}

export function getZshShellReadyRcfileContent(): string {
return `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
source "$_orca_home/.zshrc"
fi
# Why: allow users to opt out of the Tab binding if they want stock completion
# semantics instead of suggestion acceptance.
if [[ "\${ORCA_ZSH_TAB_ACCEPTS_SUGGESTION:-1}" != '0' ]]; then
# Why: match the right-arrow accept path used by zsh-autosuggestions so Tab
# accepts the inline command suggestion in Orca's shells instead of falling
# back to completion or focus traversal.
bindkey '^I' forward-char
fi
`
}

const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500
const OSC_133_A = '\x1b]133;A'

Expand Down Expand Up @@ -123,12 +140,6 @@ export ZDOTDIR=${quotePosixSingle(zshDir)}
const zshProfile = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
`
const zshRc = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
source "$_orca_home/.zshrc"
fi
`
const zshLogin = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
Expand All @@ -147,7 +158,7 @@ precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark)
const files = [
[`${zshDir}/.zshenv`, zshEnv],
[`${zshDir}/.zprofile`, zshProfile],
[`${zshDir}/.zshrc`, zshRc],
[`${zshDir}/.zshrc`, getZshShellReadyRcfileContent()],
[`${zshDir}/.zlogin`, zshLogin],
[`${bashDir}/rcfile`, bashRc]
] as const
Expand Down
52 changes: 52 additions & 0 deletions src/main/rate-limits/claude-pty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { resolveClaudeCommandMock, spawnMock } = vi.hoisted(() => ({
resolveClaudeCommandMock: vi.fn(),
spawnMock: vi.fn()
}))

vi.mock('../codex-cli/command', () => ({
resolveClaudeCommand: resolveClaudeCommandMock
}))

vi.mock('node-pty', () => ({
spawn: spawnMock
}))

import { fetchViaPty } from './claude-pty'

function makeDisposable() {
return { dispose: vi.fn() }
}

describe('fetchViaPty', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveClaudeCommandMock.mockReturnValue('claude')
})

it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()

spawnMock.mockReturnValue({
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn(() => onExitDisposable),
write: vi.fn(),
kill: vi.fn()
})

const resultPromise = fetchViaPty()
await vi.advanceTimersByTimeAsync(25_000)
await resultPromise

const term = spawnMock.mock.results[0]?.value as { kill: ReturnType<typeof vi.fn> }
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
term.kill.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
term.kill.mock.invocationCallOrder[0]
)
})
})
22 changes: 20 additions & 2 deletions src/main/rate-limits/claude-pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,20 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
rows: 40,
env: { ...process.env, TERM: 'xterm-256color' }
})
const termDisposables: { dispose: () => void }[] = []
const disposeTermListeners = (): void => {
for (const disposable of termDisposables.splice(0)) {
disposable.dispose()
}
}

const timeout = setTimeout(() => {
if (!resolved) {
resolved = true
// Why: node-pty's NAPI callbacks can outlive the Electron JS
// environment if we kill the hidden PTY without disposing them first,
// which matches Orca's documented SIGABRT failure mode on shutdown.
disposeTermListeners()
term.kill()
// Even on timeout, try to parse whatever we collected
// eslint-disable-next-line no-control-regex
Expand Down Expand Up @@ -205,6 +215,7 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
if (enterInterval) {
clearInterval(enterInterval)
}
disposeTermListeners()
term.kill()

// eslint-disable-next-line no-control-regex
Expand Down Expand Up @@ -243,7 +254,7 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
startEnterPresses()
}, STARTUP_DELAY_MS)

term.onData((data) => {
const onDataDisposable = term.onData((data) => {
output += data

// eslint-disable-next-line no-control-regex
Expand Down Expand Up @@ -278,8 +289,12 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
}
}
})
if (onDataDisposable) {
termDisposables.push(onDataDisposable)
}

term.onExit(() => {
const onExitDisposable = term.onExit(() => {
disposeTermListeners()
if (enterInterval) {
clearInterval(enterInterval)
}
Expand All @@ -299,5 +314,8 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
})
}
})
if (onExitDisposable) {
termDisposables.push(onExitDisposable)
}
})
}
60 changes: 60 additions & 0 deletions src/main/rate-limits/codex-fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
resolveCodexCommandMock: vi.fn(),
ptySpawnMock: vi.fn()
}))

vi.mock('node:child_process', () => ({
spawn: childSpawnMock
}))

vi.mock('../codex-cli/command', () => ({
resolveCodexCommand: resolveCodexCommandMock
}))

vi.mock('node-pty', () => ({
spawn: ptySpawnMock
}))

import { fetchCodexRateLimits } from './codex-fetcher'

function makeDisposable() {
return { dispose: vi.fn() }
}

describe('fetchCodexRateLimits', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
})

it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()

childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn(() => onExitDisposable),
write: vi.fn(),
kill: vi.fn()
})

const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(15_000)
await resultPromise

const term = ptySpawnMock.mock.results[0]?.value as { kill: ReturnType<typeof vi.fn> }
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
term.kill.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
term.kill.mock.invocationCallOrder[0]
)
})
})
22 changes: 20 additions & 2 deletions src/main/rate-limits/codex-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,20 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
}
})
const termDisposables: { dispose: () => void }[] = []
const disposeTermListeners = (): void => {
for (const disposable of termDisposables.splice(0)) {
disposable.dispose()
}
}

const timeout = setTimeout(() => {
if (!resolved) {
resolved = true
// Why: killing a hidden PTY without disposing node-pty's NAPI listener
// handles leaves ThreadSafeFunction callbacks alive into Electron
// shutdown, which can abort the app while Node cleans up its env.
disposeTermListeners()
term.kill()
resolve({
provider: 'codex',
Expand All @@ -331,7 +341,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
}
}, PTY_TIMEOUT_MS)

term.onData((data) => {
const onDataDisposable = term.onData((data) => {
output += data

// Wait for prompt, then send /status
Expand All @@ -349,6 +359,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
}
resolved = true
clearTimeout(timeout)
disposeTermListeners()
term.kill()

// eslint-disable-next-line no-control-regex
Expand All @@ -366,8 +377,12 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
}, 500)
}
})
if (onDataDisposable) {
termDisposables.push(onDataDisposable)
}

term.onExit(() => {
const onExitDisposable = term.onExit(() => {
disposeTermListeners()
if (!resolved) {
resolved = true
clearTimeout(timeout)
Expand All @@ -384,6 +399,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
})
}
})
if (onExitDisposable) {
termDisposables.push(onExitDisposable)
}
})
}

Expand Down
33 changes: 33 additions & 0 deletions src/renderer/src/components/settings/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,39 @@ export function TerminalPane({
/>
</button>
</SearchableSetting>

<SearchableSetting
title="Tab Accepts Suggestions"
description="In shell-ready zsh sessions, Tab accepts the inline suggestion the same way the right arrow does."
keywords={['terminal', 'tab', 'suggestion', 'autocomplete', 'zsh', 'shell']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Tab Accepts Suggestions</Label>
<p className="text-xs text-muted-foreground">
In shell-ready zsh sessions, Tab accepts the inline suggestion the same way the
right arrow does. Turn this off if you want stock zsh completion on Tab.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalTabAcceptSuggestion}
onClick={() =>
updateSettings({
terminalTabAcceptSuggestion: !settings.terminalTabAcceptSuggestion
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalTabAcceptSuggestion ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalTabAcceptSuggestion ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_DARK_THEME_SEARCH_ENTRIES) ? (
Expand Down
Loading
Loading