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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
waits for one rather than being dropped — including one that came due while
lich was closed, which lands when lich is next up.

- **A GitHub issue can now be the worktree.**
The New worktree dialog's name field takes an issue — `#128`, or the URL copied
out of the browser — beside the branch names and plain words it already took.
lich looks the issue up through `gh` on the project's own account, prints its
title and the exact branch it will create under the field before anything
exists, and hands the issue to the session that opens on it: the number, the
title, the link and the body, waiting unsent at the agent's prompt for you to
read, edit and send. Nothing is auto-generated on your behalf — a worktree
named `#128` used to be a branch called `128`, and typing the task in by hand
was the only way the agent learned what it was for. A bare number is still just
a branch name, because that is what git makes of it; a pull request's number is
refused with the screen that does open pull requests named, since GitHub counts
both in one sequence and `gh` will answer for either.

- **`lich open` and its MCP tool now open a project, not only a session in one.**
`--project` took the name of a project already on screen, so an agent could
fan work out across the projects you had open and no further: putting a
Expand Down
7 changes: 4 additions & 3 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,10 @@ work when nobody knows it and that the call site never shows. The mechanism and
`draftIdle` for nothing. The stale-draft release is what keeps that a delay instead of a wedged relay. Two gaps
stay open: input arriving between the paste and its Enter still rides along — a window that is `defaultSubmitDelay`
at best and lasts until the target's PTY goes quiet at worst (`internal/relay`, `awaitSettled`) — and a provider
that takes keystrokes through anything other than this PTY is invisible here. The pull request handoff waits on
the same answer (`frontend/src/lib/terminal/write-at-prompt.ts`) with no Enter of its own to justify it: hand a
conflict to a session you left half a sentence in, and nothing appears at that prompt until the draft goes stale.
that takes keystrokes through anything other than this PTY is invisible here. The pull request and issue handoffs
wait on the same answer (`frontend/src/lib/terminal/write-at-prompt.ts`) with no Enter of their own to justify it:
hand a conflict to a session you left half a sentence in, and nothing appears at that prompt until the draft goes
stale.
- **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich
presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's
screen to know it has taken the paste in. On Windows that quiet is the whole instrument — ConPTY hands a child key
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/sidebar/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { useProjects } from "@/providers/projects"
import { queueFork } from "@/lib/terminal/fork-queue"
import { queueSetup } from "@/lib/terminal/setup-queue"
import { writeAtPrompt } from "@/lib/terminal/write-at-prompt"
import { filterSessions } from "@/lib/session/session-filter"
import { requestTerminalFocus } from "@/lib/terminal/focus-request"
import {
Expand Down Expand Up @@ -287,6 +288,7 @@ export function SessionSidebar({ onCollapse }: SessionSidebarProps) {
base: string,
baseIsRemote: boolean,
sandbox: string,
prompt: string,
) => {
const wt = await ProjectService.CreateWorktree(path, projectId, name, base, baseIsRemote)
if (wt) {
Expand All @@ -299,6 +301,15 @@ export function SessionSidebar({ onCollapse }: SessionSidebarProps) {
// A fresh checkout is the one moment the project's setup script runs;
// reopening an existing worktree never queues it.
queueSetup(opened)
// The issue the worktree was named after, written at that session's
// prompt once it has one — the setup script and the provider's boot come
// first, which is what writeAtPrompt waits out. Not awaited: the dialog
// closes on a worktree that exists, and the wait can be minutes.
if (prompt) {
void writeAtPrompt(opened, prompt).catch((err: unknown) => {
toast.error(`Couldn’t hand the issue to the session: ${errorText(err)}`)
})
}
}
setWorktreeOpen(false)
}
Expand Down
92 changes: 82 additions & 10 deletions frontend/src/components/sidebar/WorktreeDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"
import type { KeyboardEvent } from "react"
import { ProjectService } from "@/lib/rpc"
import type { Branches, Worktree } from "@/lib/api-types"
import type { Branches, Issue, Worktree } from "@/lib/api-types"
import { SearchInput } from "@/components/common/SearchInput"
import { WorktreeSandboxRow } from "@/components/sidebar/WorktreeSandboxRow"
import { WorktreeScriptRows } from "@/components/sidebar/WorktreeScriptRows"
Expand All @@ -18,9 +18,15 @@ import {
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toBranchName } from "@/lib/git/branch-name"
import { issueBrief, issueName, parseIssueRef } from "@/lib/issue"
import { useSandboxChoice, type SandboxAnswer } from "@/lib/use-sandbox-choice"
import { cn, errorText } from "@/lib/utils"

// How long the field has to settle before the issue behind a reference is
// looked up. Every keystroke of "#381" is a valid reference on its way to the
// one that was meant, so without this one issue costs three gh calls.
const ISSUE_LOOKUP_MS = 400

interface WorktreeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
Expand All @@ -33,12 +39,15 @@ interface WorktreeDialogProps {
currentBranch: string
/** Create the worktree and open its session; rejections show in the dialog.
* sandbox is the confinement answer for that session ("on"/"off", "" when the
* machine cannot confine and nothing was asked). */
* machine cannot confine and nothing was asked). prompt is what that session
* is handed once it comes up — the issue this worktree is for, or "" when the
* name was not one. */
onCreate: (
name: string,
base: string,
baseIsRemote: boolean,
sandbox: SandboxAnswer,
prompt: string,
) => Promise<void>
/** Reopen a session on an already-existing worktree. */
onResume: (wt: { name: string; path: string }) => void
Expand Down Expand Up @@ -133,6 +142,8 @@ export function WorktreeDialog({
}: WorktreeDialogProps) {
const [branches, setBranches] = useState<Branches | null>(null)
const [name, setName] = useState("")
const [issue, setIssue] = useState<Issue | null>(null)
const [issueError, setIssueError] = useState("")
const [base, setBase] = useState("")
const [filter, setFilter] = useState("")
const [loadError, setLoadError] = useState("")
Expand All @@ -145,9 +156,22 @@ export function WorktreeDialog({
const vis = filterBranches(branches, filter)
const flat = flatValues(vis)
const noMatches = branches !== null && flat.length === 0
// The issue the field names, if it names one. The reference is what was
// typed; the issue is what GitHub answered for it, and only that carries the
// title the branch is named after.
const issueRef = parseIssueRef(name)
const lookingUp = issueRef !== null && !issue && !issueError
// What the typed name creates: itself when git takes it, a slug of the first
// few words when it is a phrase, blank when nothing usable was typed.
const newBranch = toBranchName(name)
//
// A reference is never named by the "#381" that was typed, resolved or not.
// git accepts "#381" as a branch name, and most shells read the "#" as the
// start of a comment — `git checkout #381` is `git checkout`. So the issue
// names it once GitHub has answered, and its bare number until then, which is
// also what is left when the lookup fails and the worktree is made anyway.
const newBranch = toBranchName(
issue ? issueName(issue) : issueRef !== null ? String(issueRef) : name,
)
const isResume = base.startsWith("worktree:")

// Keep the selected base in view as it changes — the preselected current
Expand All @@ -156,6 +180,36 @@ export function WorktreeDialog({
listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" })
}, [base, branches])

// The issue behind the typed reference, looked up while the field is still
// being typed in. A failure is shown but never blocks: the branch under the
// field is still a branch, and creating the worktree without the issue's text
// is a worse outcome than not creating it at all only if lich decides so.
useEffect(() => {
setIssue(null)
setIssueError("")
if (issueRef === null) {
return
}
let stale = false
const timer = setTimeout(() => {
ProjectService.Issue(projectPath, issueRef)
.then((found) => {
if (!stale) {
setIssue(found)
}
})
.catch((err: unknown) => {
if (!stale) {
setIssueError(errorText(err))
}
})
}, ISSUE_LOOKUP_MS)
return () => {
stale = true
clearTimeout(timer)
}
}, [issueRef, projectPath])

useEffect(() => {
if (!open) {
return
Expand Down Expand Up @@ -236,7 +290,13 @@ export function WorktreeDialog({
setSubmitting(true)
setSubmitError("")
try {
await onCreate(newBranch, id, group === "remote", sandbox.answer)
await onCreate(
newBranch,
id,
group === "remote",
sandbox.answer,
issue ? issueBrief(issue) : "",
)
} catch (err) {
setSubmitError(errorText(err))
setSubmitting(false)
Expand Down Expand Up @@ -282,15 +342,27 @@ export function WorktreeDialog({
id="worktree-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="A branch name, or the task in plain words (e.g. fix the auth redirect)"
placeholder="A branch name, an issue (#128), or the task in plain words"
disabled={isResume}
autoFocus
/>
<span className="font-mono text-xs text-muted-foreground">
{isResume
? "Opens the selected worktree"
: `Branch: ${newBranch || "<auto-generated>"}`}
</span>
<div className="flex flex-col font-mono text-xs text-muted-foreground">
{isResume ? (
<span>Opens the selected worktree</span>
) : lookingUp ? (
<span>Looking up #{issueRef}…</span>
) : (
<>
{issue && (
<span className="text-foreground">
#{issue.number} {issue.title}
</span>
)}
{issueError && <span className="font-sans text-destructive">{issueError}</span>}
<span>Branch: {newBranch || "<auto-generated>"}</span>
</>
)}
</div>
</div>

<div className="flex min-h-0 flex-col gap-1.5">
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ export interface Branches {
worktrees: Worktree[] | null
}

/** internal/project.Issue — the little of a GitHub issue the New-worktree
* dialog needs: the title names the branch, the body is what the session it
* opens finds at its prompt. The body arrives already cut (maxIssueBody). */
export interface Issue {
number: number
title: string
body: string
url: string
}

/** internal/project.WorktreeSetup — what the New-worktree dialog shows about
* the project's setup: the .lich/setup-worktree.sh content, or a suggestion
* detected from the repository root when the file is absent. */
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/lib/issue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest"
import { issueBrief, issueName, parseIssueRef } from "@/lib/issue"
import { toBranchName } from "@/lib/git/branch-name"

describe("parseIssueRef", () => {
it("reads GitHub's own spelling", () => {
expect(parseIssueRef("#381")).toBe(381)
expect(parseIssueRef(" #1 ")).toBe(1)
})

it("reads a copied issue URL, with or without a tail", () => {
expect(parseIssueRef("https://github.com/omartelo/lich/issues/381")).toBe(381)
expect(parseIssueRef("https://github.com/omartelo/lich/issues/381#issuecomment-9")).toBe(381)
expect(parseIssueRef("https://ghe.corp.example/team/app/issues/12?foo=1")).toBe(12)
})

it("leaves a bare number alone — git takes it as a branch name", () => {
expect(parseIssueRef("381")).toBeNull()
})

it("is not a pull request URL", () => {
expect(parseIssueRef("https://github.com/omartelo/lich/pull/453")).toBeNull()
})

it("names no issue for ordinary branch names and typed tasks", () => {
expect(parseIssueRef("")).toBeNull()
expect(parseIssueRef("fix the auth redirect")).toBeNull()
expect(parseIssueRef("feat/issues-381")).toBeNull()
expect(parseIssueRef("#")).toBeNull()
expect(parseIssueRef("#abc")).toBeNull()
expect(parseIssueRef("#12 and more")).toBeNull()
})

it("refuses a number that is not one", () => {
expect(parseIssueRef("#0")).toBeNull()
expect(parseIssueRef(`#${"9".repeat(30)}`)).toBeNull()
})
})

describe("issueName", () => {
it("puts the number in front, so the branch carries it", () => {
const issue = {
number: 381,
title: "Sandbox backend for Windows (unelevated write protection)",
body: "",
url: "",
}
expect(issueName(issue)).toBe("381 Sandbox backend for Windows (unelevated write protection)")
// The name is only ever seen through toBranchName, which is what the dialog
// prints under the field before anything is created.
expect(toBranchName(issueName(issue))).toBe("381-sandbox-backend-for-windows")
})
})

describe("issueBrief", () => {
const issue = {
number: 381,
title: "Sandbox backend for Windows",
body: "Windows has no user namespace.\n\nMap what an unelevated process can protect.",
url: "https://github.com/omartelo/lich/issues/381",
}

it("is one bracketed paste, so the newlines are text and nothing is sent", () => {
const brief = issueBrief(issue)
expect(brief.startsWith("\x1b[200~")).toBe(true)
expect(brief.endsWith("\x1b[201~")).toBe(true)
expect(brief).not.toContain("\r")
})

it("names the issue, links it, and carries its body", () => {
const brief = issueBrief(issue)
expect(brief).toContain("GitHub issue #381 — Sandbox backend for Windows")
expect(brief).toContain("https://github.com/omartelo/lich/issues/381")
expect(brief).toContain("Map what an unelevated process can protect.")
})

it("leaves no dangling blank lines when the issue has no body", () => {
const brief = issueBrief({ ...issue, body: " \n " })
expect(brief).toBe(
"\x1b[200~GitHub issue #381 — Sandbox backend for Windows\n" +
"https://github.com/omartelo/lich/issues/381\x1b[201~",
)
})
})
48 changes: 48 additions & 0 deletions frontend/src/lib/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Issue } from "@/lib/api-types"
import { bracketedPaste } from "@/lib/terminal/bracketed-paste"

// GitHub's own spelling of an issue reference, and the URL you get from copying
// one out of the browser. Both are what a person already has in hand when they
// decide to work on an issue — nothing here is a syntax lich invented.
//
// A bare number is deliberately not a reference: the field it is typed into is
// a branch name first, and git takes "128" as one. The "#" is the marker, so
// there is one field with one meaning rather than a mode to be in.
const ISSUE_HASH = /^#(\d+)$/
const ISSUE_URL = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/(\d+)(?:[/?#].*)?$/

/**
* The issue number a typed worktree name refers to, or null when it names no
* issue — which is every ordinary branch name and every task typed in words.
*/
export function parseIssueRef(input: string): number | null {
const trimmed = input.trim()
const match = ISSUE_HASH.exec(trimmed) ?? ISSUE_URL.exec(trimmed)
if (!match) {
return null
}
const number = Number(match[1])
return Number.isSafeInteger(number) && number > 0 ? number : null
}

/**
* The name an issue's worktree is proposed under: the number first, so the
* branch sorts and greps by it, then the title. toBranchName does the rest —
* this only decides what it is given.
*/
export function issueName(issue: Issue): string {
return `${issue.number} ${issue.title}`
}

/**
* What the issue's session finds at its prompt, ready to write into a PTY.
*
* One bracketed paste, so the newlines in a body are text rather than a run of
* Enters — and so the whole thing lands unsent, which is the rule the pull
* request handoff follows too: lich writes the prompt, the user sends it.
*/
export function issueBrief(issue: Issue): string {
const head = `GitHub issue #${issue.number} — ${issue.title}\n${issue.url}`
const body = issue.body.trim()
return bracketedPaste(body ? `${head}\n\n${body}` : head)
}
4 changes: 4 additions & 0 deletions frontend/src/lib/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
DraftReviewComment,
Attachment,
DropItem,
Issue,
MergeMethod,
PatchNotes as PatchNotesData,
PluginStatus,
Expand Down Expand Up @@ -330,6 +331,9 @@ export const ProjectService = {
base,
baseIsRemote,
]),
/** One GitHub issue, for the New worktree dialog: its title names the branch,
* its body is handed to the session. A pull request's number is refused. */
Issue: (path: string, number: number) => call<Issue>("project.Issue", [path, number]),
/** Check a pull request's head branch out into its own worktree; rejects a fork PR. */
CreateWorktreeFromPR: (projectPath: string, projectID: string, number: number) =>
call<Worktree | null>("project.CreateWorktreeFromPR", [projectPath, projectID, number]),
Expand Down
Loading