diff --git a/.local/bin/lightcontrol b/.local/bin/lightcontrol index 2fc0340..f39acb2 100755 --- a/.local/bin/lightcontrol +++ b/.local/bin/lightcontrol @@ -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 @@ -41,11 +48,13 @@ main() { ;; -d) shift + require_value "$@" delta_change "$1" exit 0 ;; -s) shift + require_value "$@" set_brightness "$1" exit 0 ;; diff --git a/home/.pi/agent/extensions/copy.ts b/home/.pi/agent/extensions/copy.ts new file mode 100644 index 0000000..e530316 --- /dev/null +++ b/home/.pi/agent/extensions/copy.ts @@ -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; + +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 { + 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 { + 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"); + } + }, + }); +} diff --git a/home/.pi/agent/extensions/tests/copy.test.ts b/home/.pi/agent/extensions/tests/copy.test.ts new file mode 100644 index 0000000..776653f --- /dev/null +++ b/home/.pi/agent/extensions/tests/copy.test.ts @@ -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"] }, + ]); +}); diff --git a/tests/compress-preserves-existing-archive.sh b/tests/compress-preserves-existing-archive.sh deleted file mode 100644 index 0b96195..0000000 --- a/tests/compress-preserves-existing-archive.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT - -mkdir -p "$tmp/bin" "$tmp/input" -printf 'sentinel\n' >"$tmp/archive.tar" -printf 'payload\n' >"$tmp/input/file.txt" - -cat >"$tmp/bin/zstd" <<'ZSTD' -#!/usr/bin/env bash -set -euo pipefail - -remove=false -input= -output= - -while (($#)); do - case "$1" in - --rm) - remove=true - shift - ;; - -o) - output=$2 - shift 2 - ;; - -*) - shift - ;; - *) - input=$1 - shift - ;; - esac -done - -if [[ -z $input || -z $output ]]; then - echo 'zstd stub missing input or output' >&2 - exit 1 -fi - -cp "$input" "$output" -if [[ $remove == true ]]; then - rm -f "$input" -fi -ZSTD -chmod +x "$tmp/bin/zstd" - -( - cd "$tmp" - PATH="$tmp/bin:$PATH" "$repo_root/.local/bin/compress" input >/dev/null -) - -actual=$(cat "$tmp/archive.tar") -if [[ $actual != 'sentinel' ]]; then - echo 'compress modified an existing archive.tar in the working directory' >&2 - exit 1 -fi - -[[ -s "$tmp/archive.tar.zst" ]] \ No newline at end of file diff --git a/tests/dumpcert-default-port.sh b/tests/dumpcert-default-port.sh deleted file mode 100644 index 650152a..0000000 --- a/tests/dumpcert-default-port.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT - -cat >"$tmp/openssl" <<'OPENSSL' -#!/usr/bin/env bash -set -euo pipefail - -if [[ ${1:-} == s_client ]]; then - printf '%s\n' "$*" >"$OPENSSL_ARGS_FILE" - printf '%s\n' '-----BEGIN CERTIFICATE-----' 'stub' '-----END CERTIFICATE-----' - exit 0 -fi - -if [[ ${1:-} == x509 ]]; then - cat >/dev/null - printf 'decoded certificate\n' - exit 0 -fi - -echo "unexpected openssl invocation: $*" >&2 -exit 1 -OPENSSL -chmod +x "$tmp/openssl" - -OPENSSL_ARGS_FILE="$tmp/openssl.args" PATH="$tmp:$PATH" domain=example.com "$repo_root/.local/bin/dumpcert" >/dev/null - -args=$(cat "$tmp/openssl.args") -case "$args" in - *'-connect example.com:443'*) ;; - *) - echo "dumpcert did not add the default HTTPS port: $args" >&2 - exit 1 - ;; -esac \ No newline at end of file diff --git a/tests/lightcontrol-missing-value.test.sh b/tests/lightcontrol-missing-value.test.sh new file mode 100644 index 0000000..f1be877 --- /dev/null +++ b/tests/lightcontrol-missing-value.test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +lightcontrol="$repo_root/.local/bin/lightcontrol" + +for option in -d -s; do + output_file=$(mktemp) + set +e + "$lightcontrol" "$option" >"$output_file" 2>&1 + status=$? + set -e + + if [[ $status -eq 0 ]]; then + echo "expected lightcontrol $option without a value to fail" >&2 + rm -f "$output_file" + exit 1 + fi + + if grep -Fq 'unbound variable' "$output_file"; then + echo "expected usage error, got shell unbound variable for $option" >&2 + cat "$output_file" >&2 + rm -f "$output_file" + exit 1 + fi + + if ! grep -Fq 'usage lightcontrol [OPTIONS]' "$output_file"; then + echo "expected usage output for $option" >&2 + cat "$output_file" >&2 + rm -f "$output_file" + exit 1 + fi + + rm -f "$output_file" +done diff --git a/tests/local-bin-stdin.test.sh b/tests/local-bin-stdin.test.sh deleted file mode 100755 index 383c39f..0000000 --- a/tests/local-bin-stdin.test.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -tmp_dir="$(mktemp -d)" -cleanup() { rm -rf "$tmp_dir"; } -trap cleanup EXIT - -cat >"$tmp_dir/fzf" <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF -chmod +x "$tmp_dir/fzf" - -PATH="$tmp_dir:$PATH" -export PATH - -printf '{"hello":"world"}\n' | "$repo_dir/.local/bin/ljq" -printf 'hello world\n' | "$repo_dir/.local/bin/lawk"