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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Settings gained a "Project" group with a per-project Claude Code binary
override. The backend already resolved project → global → `$PATH`; the
override just had no UI.

### Fixed

- Terminal sessions no longer inherit the AppImage's runtime environment.
Beyond the vars stripped in #3, `childEnv` now drops the AppImageLauncher
vars and the `WEBKIT_DISABLE_*` pair, and scrubs mount paths out of
`LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GDK_PIXBUF_MODULE_FILE` (and any
future path list) — the bundled Ubuntu libs broke linkers and GTK apps
launched from a lich terminal. User-set entries survive; outside an AppImage
the environment passes through untouched.
- The `GDK_BACKEND=x11` forced at startup no longer leaks into spawned
sessions: the session environment is snapshotted before the GTK tweak.
- `task dev` now opens alongside an installed lich: dev instances register a
distinct GTK application ID (`lichdev`), so GTK single-instance no longer
swallows the dev window when the AppImage is running.

## [0.1.1] - 2026-07-12

### Fixed
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/components/settings/ProjectClaudeCodeSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useEffect, useState } from "react"
import { Service as Store } from "../../../bindings/github.com/omartelo/lich/internal/store"
import { useProjects } from "@/lib/projects"
import { SettingBlock } from "./SettingBlock"

// Same key as the global section; the scope (project id) is what differs. An
// empty override inherits the global value — the backend resolves project →
// global → $PATH in store.ClaudeBin.
const GLOBAL_SCOPE = ""
const CLAUDE_BIN_KEY = "claude.bin"

export function ProjectClaudeCodeSettings() {
const { projects } = useProjects()
const [globalBin, setGlobalBin] = useState("")
const [bins, setBins] = useState<Record<string, string>>({})

useEffect(() => {
void Store.GetSetting(CLAUDE_BIN_KEY, GLOBAL_SCOPE).then(setGlobalBin)
for (const project of projects) {
void Store.GetSetting(CLAUDE_BIN_KEY, project.id).then((value) =>
setBins((prev) => ({ ...prev, [project.id]: value })),
)
}
}, [projects])

const persist = (projectId: string, value: string) => {
setBins((prev) => ({ ...prev, [projectId]: value }))
void Store.SetSetting(CLAUDE_BIN_KEY, projectId, value.trim())
}

if (projects.length === 0) {
return (
<p className="py-5 text-sm text-muted-foreground">
No open projects. Open a project to configure its overrides.
</p>
)
}

return (
<SettingBlock
title="Custom path"
description="Per-project path to the Claude Code binary or a launcher script spawned in the project's terminals. Leave a project empty to inherit the global custom path."
>
<div className="space-y-5">
{projects.map((project) => (
<div key={project.id}>
<div className="text-sm font-medium text-foreground">{project.name}</div>
<p className="mb-2 mt-0.5 text-xs text-muted-foreground">{project.path}</p>
<input
value={bins[project.id] ?? ""}
onChange={(event) => persist(project.id, event.target.value)}
placeholder={globalBin || "claude"}
spellCheck={false}
aria-label={`Claude Code path for ${project.name}`}
className="h-9 w-96 max-w-full rounded-md border border-border bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
))}
</div>
</SettingBlock>
)
}
55 changes: 37 additions & 18 deletions frontend/src/components/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,29 @@ import { Search } from "lucide-react"
import { TerminalSettings } from "./TerminalSettings"
import { AppearanceSettings } from "./AppearanceSettings"
import { ClaudeCodeSettings } from "./ClaudeCodeSettings"
import { ProjectClaudeCodeSettings } from "./ProjectClaudeCodeSettings"
import { HotkeysSettings } from "./HotkeysSettings"
import { cn } from "@/lib/utils"

// SECTIONS is the settings registry: adding a category is one new file under
// components/settings/ plus one entry here — the nav and the content pane both
// derive from this list.
// derive from this list. "app" sections apply globally; "project" sections hold
// per-project overrides and render under a "Project" group header in the nav.
const SECTIONS = [
{ id: "appearance", label: "Appearance", Component: AppearanceSettings },
{ id: "terminal", label: "Terminal", Component: TerminalSettings },
{ id: "hotkeys", label: "Hotkeys", Component: HotkeysSettings },
{ id: "claude-code", label: "Claude Code", Component: ClaudeCodeSettings },
{ id: "appearance", label: "Appearance", group: "app", Component: AppearanceSettings },
{ id: "terminal", label: "Terminal", group: "app", Component: TerminalSettings },
{ id: "hotkeys", label: "Hotkeys", group: "app", Component: HotkeysSettings },
{ id: "claude-code", label: "Claude Code", group: "app", Component: ClaudeCodeSettings },
{
id: "project-claude-code",
label: "Claude Code",
group: "project",
Component: ProjectClaudeCodeSettings,
},
] as const satisfies ReadonlyArray<{
id: string
label: string
group: "app" | "project"
Component: ComponentType
}>

Expand All @@ -33,9 +42,25 @@ export function Settings() {
const filtered = SECTIONS.filter((section) =>
section.label.toLowerCase().includes(query.toLowerCase()),
)
const appSections = filtered.filter((section) => section.group === "app")
const projectSections = filtered.filter((section) => section.group === "project")
const activeSection = SECTIONS.find((section) => section.id === active)
const ActiveComponent = activeSection?.Component

const navButton = (section: (typeof SECTIONS)[number]) => (
<button
key={section.id}
type="button"
onClick={() => setActive(section.id)}
className={cn(
"rounded-md px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
active === section.id && "bg-accent text-accent-foreground",
)}
>
{section.label}
</button>
)

return (
<div className="absolute inset-0 z-10 flex bg-background">
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-sidebar">
Expand All @@ -52,19 +77,13 @@ export function Settings() {
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{filtered.map((section) => (
<button
key={section.id}
type="button"
onClick={() => setActive(section.id)}
className={cn(
"rounded-md px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
active === section.id && "bg-accent text-accent-foreground",
)}
>
{section.label}
</button>
))}
{appSections.map(navButton)}
{projectSections.length > 0 && (
<div className="mt-4 mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Project
</div>
)}
{projectSections.map(navButton)}
</nav>
</aside>

Expand Down
100 changes: 88 additions & 12 deletions internal/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,24 @@ type Service struct {
mu sync.Mutex
sessions map[string]*session
bins BinResolver
// env is the environment every spawned session inherits: the launch
// environment cleaned of AppImage runtime leakage (see childEnv), plus TERM.
env []string
// ws is the local WebSocket transport for terminal I/O (see transport.go);
// nil when it failed to start, leaving the Wails event bridge as the path.
ws *transport
}

// New returns a ready-to-use terminal service that resolves the binary to spawn
// through bins.
func New(bins BinResolver) *Service {
s := &Service{sessions: make(map[string]*session), bins: bins}
// through bins. env is the process environment to derive session environments
// from — callers pass a snapshot taken before any os.Setenv tweaks (main.go
// forces GDK_BACKEND on Linux) so those never leak into spawned shells.
func New(bins BinResolver, env []string) *Service {
s := &Service{
sessions: make(map[string]*session),
bins: bins,
env: append(childEnv(env), "TERM=xterm-256color"),
}
ws, err := newTransport(func(id string, data []byte) { _ = s.writeBytes(id, data) })
if err == nil {
s.ws = ws
Expand Down Expand Up @@ -96,25 +105,92 @@ func resolveCommand(kind, bin, shellEnv string) string {
return resolveBin(bin)
}

// appImageVars are set by the AppImage runtime and must not leak into the shell:
// ARGV0 (the .AppImage's invocation name) makes mise/asdf-style shims misread the
// shell as an invalid shim, and the rest are runtime internals a child never needs.
var appImageVars = map[string]bool{"ARGV0": true, "APPIMAGE": true, "APPDIR": true, "OWD": true}
// appImageVars are injected by the AppImage runtime, AppImageLauncher or our
// AppRun (build/linux/appimage/fix-appimage.sh) and must not leak into the
// shell: ARGV0 (the .AppImage's invocation name) makes mise/asdf-style shims
// misread the shell as an invalid shim, the WEBKIT_ pair would run any
// WebKitGTK app launched from the terminal without its sandbox, and the rest
// are runtime internals a child never needs.
var appImageVars = map[string]bool{
"ARGV0": true,
"APPIMAGE": true,
"APPDIR": true,
"OWD": true,
"TARGET_APPIMAGE": true,
"REDIRECT_APPIMAGE": true,
"DESKTOPINTEGRATION": true,
"WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS": true,
"WEBKIT_DISABLE_DMABUF_RENDERER": true,
}

// childEnv strips AppImage runtime variables from env so spawned shells inherit a
// clean environment. Outside an AppImage none are present and env is returned as-is.
// childEnv returns env cleaned of everything the AppImage runtime injected, so
// spawned shells inherit the environment lich itself was launched with. Outside
// an AppImage (no APPDIR — the deb/rpm/dev case) env is returned unchanged.
// Inside one, appImageVars are dropped and every value is scrubbed of
// colon-separated path entries under the AppImage mount — AppRun prepends the
// mount to LD_LIBRARY_PATH, PATH and XDG_DATA_DIRS, and its bundled Ubuntu libs
// break linkers and GTK apps run from the terminal. Entries the user set
// survive verbatim, so a pre-existing LD_LIBRARY_PATH keeps working.
func childEnv(env []string) []string {
appdir := ""
for _, kv := range env {
if v, ok := strings.CutPrefix(kv, "APPDIR="); ok {
appdir = v
break
}
}
if appdir == "" {
return env
}
out := make([]string, 0, len(env))
for _, kv := range env {
key, _, _ := strings.Cut(kv, "=")
key, value, _ := strings.Cut(kv, "=")
if appImageVars[key] {
continue
}
out = append(out, kv)
scrubbed, changed := scrubPathList(value, appdir)
switch {
case changed && scrubbed == "":
// The variable only pointed into the mount (AppRun's "${VAR:-}"
// expansion can also leave a dangling empty entry): drop it.
case changed:
out = append(out, key+"="+scrubbed)
default:
out = append(out, kv)
}
}
return out
}

// scrubPathList drops colon-separated entries of value that live under dir.
// changed reports whether anything was dropped; values without such entries are
// returned untouched, so non-path variables are never rewritten. When only
// empty entries remain the returned value is "".
func scrubPathList(value, dir string) (string, bool) {
if !strings.Contains(value, dir) {
return value, false
}
var kept []string
changed, nonEmpty := false, false
for entry := range strings.SplitSeq(value, ":") {
if entry == dir || strings.HasPrefix(entry, dir+"/") {
changed = true
continue
}
if entry != "" {
nonEmpty = true
}
kept = append(kept, entry)
}
if !changed {
return value, false
}
if !nonEmpty {
return "", true
}
return strings.Join(kept, ":"), true
}

// Start spawns the binary for session id under project projectID — the user's
// shell when kind is "shell", otherwise the Claude Code binary resolved from the
// project's settings (falling back to "claude" via $PATH) — attached to a new
Expand All @@ -139,7 +215,7 @@ func (s *Service) Start(id, projectID, cwd, kind string, cols, rows int) error {

cmd := exec.Command(resolveCommand(kind, s.bins.ClaudeBin(projectID), os.Getenv("SHELL")))
cmd.Dir = cwd
cmd.Env = append(childEnv(os.Environ()), "TERM=xterm-256color")
cmd.Env = s.env

ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)})
if err != nil {
Expand Down
Loading