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
9 changes: 9 additions & 0 deletions .local/bin/lightcontrol
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ delta_change() {
set_brightness "$((curr_brightness + $1))"
}

require_value() {
if [[ $# -lt 1 ]]; then
echo "$usage" >&2
exit 1
fi
}

main() {
[[ -z ${1:-} ]] && return
case "$1" in
Expand All @@ -41,11 +48,13 @@ main() {
;;
-d)
shift
require_value "$@"
delta_change "$1"
exit 0
;;
-s)
shift
require_value "$@"
set_brightness "$1"
exit 0
;;
Expand Down
178 changes: 178 additions & 0 deletions home/.pi/agent/extensions/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { spawn } from "node:child_process";

export interface ClipboardCandidate {
command: string;
args: string[];
}

type RecordLike = Record<string, unknown>;

function isRecord(value: unknown): value is RecordLike {
return value !== null && typeof value === "object";
}

export function extractAssistantText(message: unknown): string {
if (!isRecord(message) || message.role !== "assistant") {
return "";
}

const content = message.content;
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}

const textBlocks: string[] = [];
for (const block of content) {
if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") {
continue;
}
if (block.text.length > 0) {
textBlocks.push(block.text);
}
}

return textBlocks.join("\n\n");
}

export function findLastAssistantText(entries: readonly unknown[]): string | null {
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
if (!isRecord(entry) || entry.type !== "message") {
continue;
}

const text = extractAssistantText(entry.message);
if (text.length > 0) {
return text;
}
}

return null;
}

export function getClipboardCandidates(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): ClipboardCandidate[] {
if (platform === "darwin") {
return [{ command: "pbcopy", args: [] }];
}

if (platform === "win32") {
return [{ command: "clip.exe", args: [] }];
}

if (platform === "linux") {
const candidates: ClipboardCandidate[] = [];
if (env.WAYLAND_DISPLAY) {
candidates.push({ command: "wl-copy", args: [] });
}
candidates.push(
{ command: "xclip", args: ["-selection", "clipboard"] },
{ command: "xsel", args: ["--clipboard", "--input"] },
);
return candidates;
}

return [];
}

export async function copyTextToClipboard(text: string): Promise<void> {
const candidates = getClipboardCandidates();
const errors: string[] = [];

for (const candidate of candidates) {
try {
await runClipboardCommand(candidate, text);
return;
} catch (error) {
errors.push(formatClipboardError(candidate.command, error));
}
}

if (candidates.length === 0) {
throw new Error(`No clipboard command configured for ${process.platform}`);
}

throw new Error(`Clipboard copy failed: ${errors.join("; ")}`);
}

async function runClipboardCommand(candidate: ClipboardCandidate, text: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(candidate.command, candidate.args, {
stdio: ["pipe", "ignore", "pipe"],
});
let stderr = "";
let settled = false;

function finish(error?: Error) {
if (settled) return;
settled = true;
if (error) {
reject(error);
} else {
resolve();
}
}

child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
child.stdin.on("error", () => {
// The child can close stdin early on failure; the close event reports the failure.
});
child.on("error", finish);
child.on("close", (code) => {
if (code === 0) {
finish();
return;
}
const details = stderr.trim() || `exit code ${code ?? "unknown"}`;
finish(new Error(details));
});

child.stdin.end(text, "utf8");
});
}

function formatClipboardError(command: string, error: unknown): string {
if (error instanceof Error && error.message.length > 0) {
return `${command}: ${error.message}`;
}
return `${command}: unknown error`;
}

export default function (pi: ExtensionAPI) {
pi.registerCommand("copy", {
description: "Copy the last visible assistant message to the clipboard",
handler: async (_args, ctx) => {
await ctx.waitForIdle();

const text = findLastAssistantText(ctx.sessionManager.getBranch());
if (text === null) {
if (ctx.hasUI) {
ctx.ui.notify("No assistant message to copy", "warning");
}
return;
}

try {
await copyTextToClipboard(text);
} catch (error) {
if (ctx.hasUI) {
ctx.ui.notify(formatClipboardError("/copy", error), "error");
}
return;
}

if (ctx.hasUI) {
ctx.ui.notify("Copied last assistant message", "info");
}
},
});
}
71 changes: 71 additions & 0 deletions home/.pi/agent/extensions/tests/copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";

import { extractAssistantText, findLastAssistantText, getClipboardCandidates } from "../copy.ts";

test("extractAssistantText returns visible text blocks only", () => {
const text = extractAssistantText({
role: "assistant",
content: [
{ type: "thinking", thinking: "private reasoning" },
{ type: "text", text: "First visible paragraph." },
{ type: "toolCall", name: "bash", id: "call_1", arguments: { command: "echo hi" } },
{ type: "text", text: "Second visible paragraph." },
],
});

assert.equal(text, "First visible paragraph.\n\nSecond visible paragraph.");
});

test("findLastAssistantText returns newest assistant text from the active branch", () => {
const text = findLastAssistantText([
{
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "Older assistant" }] },
},
{
type: "message",
message: { role: "user", content: [{ type: "text", text: "Please continue" }] },
},
{
type: "message",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "hidden" },
{ type: "text", text: "Newest assistant" },
],
},
},
]);

assert.equal(text, "Newest assistant");
});

test("findLastAssistantText skips assistant messages without visible text", () => {
const text = findLastAssistantText([
{
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "Copy me" }] },
},
{
type: "message",
message: { role: "assistant", content: [{ type: "thinking", thinking: "hidden only" }] },
},
]);

assert.equal(text, "Copy me");
});

test("getClipboardCandidates prefers platform clipboard commands", () => {
assert.deepEqual(getClipboardCandidates("darwin", {}), [{ command: "pbcopy", args: [] }]);
assert.deepEqual(getClipboardCandidates("linux", { WAYLAND_DISPLAY: "wayland-1" }), [
{ command: "wl-copy", args: [] },
{ command: "xclip", args: ["-selection", "clipboard"] },
{ command: "xsel", args: ["--clipboard", "--input"] },
]);
assert.deepEqual(getClipboardCandidates("linux", {}), [
{ command: "xclip", args: ["-selection", "clipboard"] },
{ command: "xsel", args: ["--clipboard", "--input"] },
]);
});
63 changes: 0 additions & 63 deletions tests/compress-preserves-existing-archive.sh

This file was deleted.

38 changes: 0 additions & 38 deletions tests/dumpcert-default-port.sh

This file was deleted.

Loading
Loading