handling files when stages and dirty at once

This commit is contained in:
2026-07-28 08:57:36 +02:00
parent d3bcdb74c2
commit 03e16d49a1
29 changed files with 1597 additions and 191 deletions

View File

@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
import type { WebContents } from 'electron'
import { getRoot } from './project'
import { getConfig } from './config'
import { logger } from './logger'
/**
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
@@ -19,10 +20,14 @@ let pty: PtyModule | null = null
try {
pty = require('node-pty') as PtyModule
} catch (e) {
console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message)
logger.error('pty', 'node-pty unavailable — run `npm run rebuild`', e)
}
const terms = new Map<number, import('node-pty').IPty>()
/** Ids we killed on purpose (pane closed, window quitting, StrictMode remount).
* Their exit is expected, so it must NOT be logged as a warning — a log full of
* false alarms is a log nobody reads. */
const killing = new Set<number>()
let seq = 0
/**
@@ -45,6 +50,13 @@ function ptyEnv(): { [key: string]: string } {
if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) {
env.LANG = 'en_US.UTF-8'
}
// Same inheritance gap as locale, but for color: a terminal launch leaks
// COLORTERM=truecolor so `claude` renders its UI backgrounds as exact 24-bit
// colors; the GUI-launched packaged app has none, so claude falls back to a
// 256/16-color approximation and the same backgrounds shift shade. Match both.
if (process.platform !== 'win32' && !env.COLORTERM) {
env.COLORTERM = 'truecolor'
}
return env
}
@@ -60,7 +72,10 @@ export function ptyAvailable(): boolean {
}
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1
if (!pty) {
logger.warn('pty', `create(${kind}) refused — node-pty never loaded`)
return -1
}
const cwd = getRoot() || process.env.HOME || process.cwd()
const shell = defaultShell()
const ai = getConfig().ai
@@ -71,7 +86,7 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
// echoed command cluttering the pane; claude takes over a clean terminal.
const args = launchAgent ? ['-i', '-c', ai.command] : []
const proc = pty.spawn(shell, args, {
name: 'xterm-color',
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd,
@@ -79,9 +94,21 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
})
const id = ++seq
terms.set(id, proc)
logger.info('pty', `spawned ${kind}`, { id, pid: proc.pid, shell, args, cwd })
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) })
proc.onExit(({ exitCode, signal }) => {
terms.delete(id)
// The agent pane dying on its own (`claude` not on PATH, OOM-killed,
// segfault) looks from the UI like "the terminal just went blank" — the exit
// code and signal are the only evidence of what actually happened. An exit we
// asked for is routine, so only an unrequested one is a warning.
const expected = killing.delete(id)
const abnormal = !expected && (exitCode !== 0 || (signal != null && signal !== 0))
if (abnormal) logger.warn('pty', `${kind} exited unexpectedly`, { id, exitCode, signal })
else logger.info('pty', `${kind} exited`, { id, exitCode, expected })
if (!sender.isDestroyed()) sender.send('pty:exit', { id })
})
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
@@ -100,10 +127,10 @@ export function resizePty(id: number, cols: number, rows: number): void {
export function killPty(id: number): void {
const p = terms.get(id)
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) }
if (p) { killing.add(id); try { p.kill() } catch { /* already gone */ } terms.delete(id) }
}
export function killAllPtys(): void {
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } }
for (const [id, p] of terms) { killing.add(id); try { p.kill() } catch { /* noop */ } }
terms.clear()
}