Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/copy-url-osc52.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kitlangton/ghui": patch
---

Add a shortcut and command palette action for copying the selected pull request URL, plus an OSC52 clipboard fallback for terminals that support it.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ running.
- `l`: manage labels
- `o`: open PR in browser
- `y`: copy PR metadata
- `shift-y`: copy PR URL
- `q`: quit

Review submission:
Expand Down
11 changes: 10 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2419,7 +2419,14 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
const copySelectedPullRequestMetadata = () => {
if (!selectedPullRequest) return
void copyToClipboard(pullRequestMetadataText(selectedPullRequest))
.then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
.then((result) => flashNotice(result === "sent-osc52" ? `Sent OSC52 copy for #${selectedPullRequest.number} metadata` : `Copied #${selectedPullRequest.number} metadata`))
.catch((error) => flashNotice(errorMessage(error)))
}

const copySelectedPullRequestUrl = () => {
if (!selectedPullRequest) return
void copyToClipboard(selectedPullRequest.url)
.then((result) => flashNotice(result === "sent-osc52" ? `Sent OSC52 copy for #${selectedPullRequest.number} URL` : `Copied #${selectedPullRequest.number} URL`))
.catch((error) => flashNotice(errorMessage(error)))
}

Expand Down Expand Up @@ -3003,6 +3010,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
if (selectedPullRequest) openSelectedPullRequestInBrowser(selectedPullRequest)
},
copyPullRequestMetadata: copySelectedPullRequestMetadata,
copyPullRequestUrl: copySelectedPullRequestUrl,
quit: () => renderer.destroy(),
},
})
Expand Down Expand Up @@ -3335,6 +3343,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
refresh: () => runCommandById("pull.refresh"),
openInBrowser: () => runCommandById("pull.open-browser"),
copyMetadata: () => runCommandById("pull.copy-metadata"),
copyUrl: () => runCommandById("pull.copy-url"),
},
commentsView: {
halfPage,
Expand Down
9 changes: 9 additions & 0 deletions src/appCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface AppCommandActions {
readonly openCloseModal: () => void
readonly openPullRequestInBrowser: () => void
readonly copyPullRequestMetadata: () => void
readonly copyPullRequestUrl: () => void
readonly quit: () => void
}

Expand Down Expand Up @@ -445,6 +446,14 @@ export const buildAppCommands = ({
keywords: ["clipboard", "url", "title"],
run: actions.copyPullRequestMetadata,
}),
forSelected({
id: "pull.copy-url",
title: "Copy pull request URL",
scope: "Pull request",
shortcut: "shift-y",
keywords: ["clipboard", "url", "link"],
run: actions.copyPullRequestUrl,
}),
defineCommand({
id: "app.quit",
title: "Quit ghui",
Expand Down
2 changes: 2 additions & 0 deletions src/keymap/detailView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface DetailViewCtx extends Scrollable {
readonly refresh: () => void
readonly openInBrowser: () => void
readonly copyMetadata: () => void
readonly copyUrl: () => void
}

const Detail = context<DetailViewCtx>()
Expand All @@ -31,4 +32,5 @@ export const detailViewKeymap = Detail(
{ id: "detail.refresh", title: "Refresh", keys: ["r"], run: (s) => s.refresh() },
{ id: "detail.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() },
{ id: "detail.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.copyMetadata() },
{ id: "detail.copy-url", title: "Copy URL", keys: ["shift+y"], run: (s) => s.copyUrl() },
)
1 change: 1 addition & 0 deletions src/keymap/listNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const listNavKeymap = List(
{ id: "list.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.runCommandById("pull.open-browser") },
{ id: "list.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.runCommandById("pull.toggle-draft") },
{ id: "list.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.runCommandById("pull.copy-metadata") },
{ id: "list.copy-url", title: "Copy URL", keys: ["shift+y"], run: (s) => s.runCommandById("pull.copy-url") },
{ id: "list.detail.open", title: "Open details", keys: ["return"], run: (s) => s.runCommandById("detail.open") },

// Queue mode tabs
Expand Down
57 changes: 49 additions & 8 deletions src/services/Clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,53 @@ const clipboardCommands: readonly (readonly [string, ...(readonly string[])])[]
? [...(process.env.WAYLAND_DISPLAY ? [["wl-copy"] as const] : []), ["xclip", "-selection", "clipboard"] as const, ["xsel", "--clipboard", "--input"] as const]
: []

const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
const unavailableDetail = `Clipboard is not available.${installHint}`
export type ClipboardCopyResult = "copied" | "sent-osc52"

const unknownErrorMessage = (error: unknown) => {
if (error instanceof Error && error.message.length > 0) return error.message
return String(error)
}

const errorDetail = (error: unknown) => {
if (typeof error === "object" && error !== null) {
const detail = "detail" in error ? (error as { readonly detail?: unknown }).detail : undefined
if (typeof detail === "string" && detail.length > 0) return detail
const message = "message" in error ? (error as { readonly message?: unknown }).message : undefined
if (typeof message === "string" && message.length > 0) return message
}
return unknownErrorMessage(error)
}

const commandLabel = (command: string, args: readonly string[]) => [command, ...args].join(" ")

const osc52FailureDetail = (cause: unknown, commandFailures: readonly string[]) => {
const osc52Detail = `Failed to send OSC52 clipboard sequence: ${unknownErrorMessage(cause)}`
if (commandFailures.length === 0) return osc52Detail
return `Clipboard tools failed (${commandFailures.join("; ")}); ${osc52Detail}`
}

const copyWithOsc52 = (text: string, commandFailures: readonly string[]) =>
Effect.try({
try() {
writeOsc52(text)
return "sent-osc52" as const
},
catch: (cause) => new ClipboardError({ detail: osc52FailureDetail(cause, commandFailures) }),
})

const writeOsc52 = (text: string) => {
const sequence = `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`
if (process.env.TMUX) {
process.stdout.write(`\x1bPtmux;${sequence.split("\x1b").join("\x1b\x1b")}\x1b\\`)
return
}
process.stdout.write(sequence)
}

export class Clipboard extends Context.Service<
Clipboard,
{
readonly copy: (text: string) => Effect.Effect<void, ClipboardError>
readonly copy: (text: string) => Effect.Effect<ClipboardCopyResult, ClipboardError>
}
>()("ghui/Clipboard") {
static readonly layerNoDeps = Layer.effect(
Expand All @@ -28,15 +68,16 @@ export class Clipboard extends Context.Service<

const copy = Effect.fn("Clipboard.copy")(function* (text: string) {
if (clipboardCommands.length === 0) {
return yield* new ClipboardError({ detail: unavailableDetail })
return yield* copyWithOsc52(text, [])
}
let lastDetail = ""
const commandFailures: string[] = []
for (const [cmd, ...args] of clipboardCommands) {
const result = yield* command.run(cmd, args, { stdin: text }).pipe(Effect.result)
if (result._tag === "Success") return
lastDetail = result.failure.detail
if (result._tag === "Success") return "copied"
const failure = errorDetail(result.failure)
commandFailures.push(`${commandLabel(cmd, args)}: ${failure}`)
}
return yield* new ClipboardError({ detail: lastDetail || unavailableDetail })
return yield* copyWithOsc52(text, commandFailures)
})

return Clipboard.of({ copy })
Expand Down
2 changes: 2 additions & 0 deletions test/appCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const selectedPullRequest: PullRequestItem = {
repository: "owner/repo",
author: "kit",
headRefOid: "abc123",
headRefName: "feat/review-ux",
number: 42,
title: "Review UX",
body: "",
Expand Down Expand Up @@ -91,6 +92,7 @@ const buildCommands = (overrides: Partial<Parameters<typeof buildAppCommands>[0]
openCloseModal: noop,
openPullRequestInBrowser: noop,
copyPullRequestMetadata: noop,
copyPullRequestUrl: noop,
quit: noop,
},
...overrides,
Expand Down
51 changes: 27 additions & 24 deletions test/cacheService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,32 @@ const tempCachePath = async () => {

const view: PullRequestView = { _tag: "Queue", mode: "authored", repository: null }

const pullRequest = (number: number, overrides: Partial<PullRequestItem> = {}): PullRequestItem => ({
repository: "owner/repo",
author: "author",
headRefOid: `sha-${number}`,
number,
title: `PR ${number}`,
body: "Body",
labels: [{ name: "bug", color: "#d73a4a" }],
additions: 10,
deletions: 2,
changedFiles: 3,
state: "open",
reviewStatus: "none",
checkStatus: "passing",
checkSummary: "1/1",
checks: [{ name: "ci", status: "completed", conclusion: "success" }],
autoMergeEnabled: false,
detailLoaded: true,
createdAt: new Date(`2026-01-${String(number).padStart(2, "0")}T00:00:00Z`),
closedAt: null,
url: `https://github.com/owner/repo/pull/${number}`,
...overrides,
})
const pullRequest = (number: number, overrides: Partial<PullRequestItem> = {}): PullRequestItem => {
const base: PullRequestItem = {
repository: "owner/repo",
author: "author",
headRefOid: `sha-${number}`,
headRefName: `feature/pr-${number}`,
number,
title: `PR ${number}`,
body: "Body",
labels: [{ name: "bug", color: "#d73a4a" }],
additions: 10,
deletions: 2,
changedFiles: 3,
state: "open",
reviewStatus: "none",
checkStatus: "passing",
checkSummary: "1/1",
checks: [{ name: "ci", status: "completed", conclusion: "success" }],
autoMergeEnabled: false,
detailLoaded: true,
createdAt: new Date(`2026-01-${String(number).padStart(2, "0")}T00:00:00Z`),
closedAt: null,
url: `https://github.com/owner/repo/pull/${number}`,
}
return { ...base, ...overrides }
}

const load = (data: readonly PullRequestItem[]): PullRequestLoad => ({
view,
Expand Down Expand Up @@ -179,7 +182,7 @@ describe("CacheService", () => {
)

const db = new Database(filename)
db.run("update pull_requests set data_json = ? where pr_key = ?", "{", pullRequestCacheKey({ repository: "owner/repo", number: 1 }))
db.run("update pull_requests set data_json = ? where pr_key = ?", ["{", pullRequestCacheKey({ repository: "owner/repo", number: 1 })])
db.close()

const cached = await runCache(
Expand Down
129 changes: 129 additions & 0 deletions test/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Clipboard, type ClipboardCopyResult } from "../src/services/Clipboard.ts"
import { CommandError, CommandRunner, type CommandResult, type RunOptions } from "../src/services/CommandRunner.ts"

interface RecordedCall {
readonly command: string
readonly args: readonly string[]
readonly stdin: string | undefined
}

type CommandOutcome = "success" | "failure"

const expectedClipboardCommands: readonly (readonly [string, ...(readonly string[])])[] =
process.platform === "darwin"
? [["pbcopy"]]
: process.platform === "linux"
? [...(process.env.WAYLAND_DISPLAY ? [["wl-copy"] as const] : []), ["xclip", "-selection", "clipboard"] as const, ["xsel", "--clipboard", "--input"] as const]
: []

const fakeCommandRunner = (outcomes: readonly CommandOutcome[], recorder: RecordedCall[]) =>
Layer.succeed(
CommandRunner,
CommandRunner.of({
run: (command: string, args: readonly string[], options?: RunOptions) => {
recorder.push({ command, args: [...args], stdin: options?.stdin })
const outcome = outcomes[recorder.length - 1] ?? "failure"
if (outcome === "success") {
const result: CommandResult = { stdout: "", stderr: "", exitCode: 0 }
return Effect.succeed(result)
}
return Effect.fail(new CommandError({ command, args: [...args], detail: `${command} failed`, cause: `${command} failed` }))
},
runSchema: <S extends Schema.Top>() => Effect.die("unused test command runner") as Effect.Effect<S["Type"], CommandError, S["DecodingServices"]>,
}),
)

const runClipboard = <E>(effect: Effect.Effect<ClipboardCopyResult, E, Clipboard>, layer: Layer.Layer<Clipboard>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer)) as Effect.Effect<ClipboardCopyResult>)

const withCapturedStdout = async <A>(run: (writes: string[]) => Promise<A>) => {
const originalWrite = process.stdout.write
const writes: string[] = []
process.stdout.write = ((chunk: string | Uint8Array) => {
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"))
return true
}) as typeof process.stdout.write
try {
return await run(writes)
} finally {
process.stdout.write = originalWrite
}
}

const withTmux = async <A>(value: string | undefined, run: () => Promise<A>) => {
const originalTmux = process.env.TMUX
if (value === undefined) {
delete process.env.TMUX
} else {
process.env.TMUX = value
}
try {
return await run()
} finally {
if (originalTmux === undefined) {
delete process.env.TMUX
} else {
process.env.TMUX = originalTmux
}
}
}

describe("Clipboard", () => {
test("reports copied when a clipboard command succeeds", async () => {
if (expectedClipboardCommands.length === 0) return
const recorder: RecordedCall[] = []
const layer = Clipboard.layerNoDeps.pipe(Layer.provide(fakeCommandRunner(["success"], recorder)))

const result = await runClipboard(
Clipboard.use((clipboard) => clipboard.copy("https://github.com/owner/repo/pull/42")),
layer,
)

expect(result).toBe("copied")
expect(recorder).toEqual([
{
command: expectedClipboardCommands[0]![0],
args: expectedClipboardCommands[0]!.slice(1),
stdin: "https://github.com/owner/repo/pull/42",
},
])
})

test("falls back to a raw OSC52 sequence when clipboard commands fail", async () => {
const recorder: RecordedCall[] = []
const layer = Clipboard.layerNoDeps.pipe(Layer.provide(fakeCommandRunner([], recorder)))

await withTmux(undefined, () =>
withCapturedStdout(async (writes) => {
const result = await runClipboard(
Clipboard.use((clipboard) => clipboard.copy("copy me")),
layer,
)

expect(result).toBe("sent-osc52")
expect(recorder).toHaveLength(expectedClipboardCommands.length)
expect(writes).toEqual([`\x1b]52;c;${Buffer.from("copy me", "utf8").toString("base64")}\x07`])
}),
)
})

test("wraps OSC52 sequences for tmux passthrough", async () => {
const recorder: RecordedCall[] = []
const layer = Clipboard.layerNoDeps.pipe(Layer.provide(fakeCommandRunner([], recorder)))
const sequence = `\x1b]52;c;${Buffer.from("tmux copy", "utf8").toString("base64")}\x07`

await withTmux("/tmp/tmux-1000/default,1,0", () =>
withCapturedStdout(async (writes) => {
const result = await runClipboard(
Clipboard.use((clipboard) => clipboard.copy("tmux copy")),
layer,
)

expect(result).toBe("sent-osc52")
expect(writes).toEqual([`\x1bPtmux;${sequence.split("\x1b").join("\x1b\x1b")}\x1b\\`])
}),
)
})
})
Loading