Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -459,6 +459,7 @@ export function FilePane({
|
||||
preview,
|
||||
supportsEditing,
|
||||
});
|
||||
const canToggleMarkdownMode = isMarkdown && editable;
|
||||
const lineCount =
|
||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
|
||||
@@ -471,8 +472,8 @@ export function FilePane({
|
||||
preview={preview}
|
||||
version={version}
|
||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||
markdownMode={isMarkdown ? markdownMode : undefined}
|
||||
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
|
||||
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
|
||||
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
|
||||
lineCount={lineCount}
|
||||
editable={editable}
|
||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
import { formatPairingInstructions } from "../../output/pairing.js";
|
||||
|
||||
interface PairOptions {
|
||||
home?: string;
|
||||
@@ -97,6 +98,11 @@ function outputPairingResult(
|
||||
return;
|
||||
}
|
||||
|
||||
const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
|
||||
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
|
||||
process.stdout.write(
|
||||
formatPairingInstructions({
|
||||
url: pairing.url,
|
||||
qr: pairing.qr,
|
||||
columns: process.stdout.columns,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type DaemonStartOptions,
|
||||
} from "./daemon/local-daemon.js";
|
||||
import { tryConnectToDaemon } from "../utils/client.js";
|
||||
import { formatPairingInstructions } from "../output/pairing.js";
|
||||
|
||||
interface OnboardOptions extends DaemonStartOptions {
|
||||
timeout?: string;
|
||||
@@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
renderNote(
|
||||
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
|
||||
"Scan to pair",
|
||||
process.stdout.write(
|
||||
formatPairingInstructions({
|
||||
url: pairing.url,
|
||||
qr: pairing.qr,
|
||||
columns: process.stdout.columns,
|
||||
}),
|
||||
);
|
||||
renderNote(pairing.url, "Pairing link");
|
||||
printNextSteps(pairing.url, paseoHome, richUi);
|
||||
if (richUi) {
|
||||
outro("Paseo is ready!");
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPairingInstructions } from "./pairing.js";
|
||||
|
||||
const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m";
|
||||
const URL = "https://app.paseo.sh/#offer=pairing-offer";
|
||||
|
||||
describe("formatPairingInstructions", () => {
|
||||
it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => {
|
||||
const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 });
|
||||
|
||||
expect(output).toContain(QR);
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
|
||||
it("does not print a QR that would reach the terminal edge", () => {
|
||||
const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 });
|
||||
|
||||
expect(output).not.toContain(QR);
|
||||
expect(output).toContain("Resize the terminal to at least 7 columns");
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
|
||||
it("does not risk printing a QR when terminal width is unknown", () => {
|
||||
const output = formatPairingInstructions({ qr: QR, url: URL });
|
||||
|
||||
expect(output).not.toContain(QR);
|
||||
expect(output).toContain("terminal width could not be detected");
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
||||
|
||||
interface PairingInstructions {
|
||||
url: string;
|
||||
qr: string | null;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
function visibleWidth(value: string): number {
|
||||
return Math.max(
|
||||
...value
|
||||
.replace(ANSI_PATTERN, "")
|
||||
.split("\n")
|
||||
.map((line) => line.length),
|
||||
);
|
||||
}
|
||||
|
||||
function formatQr(qr: string | null, columns: number | undefined): string {
|
||||
if (!qr) {
|
||||
return "QR code is unavailable. Use the pairing link below.";
|
||||
}
|
||||
|
||||
if (columns === undefined) {
|
||||
return "QR code not shown because terminal width could not be detected.";
|
||||
}
|
||||
|
||||
const width = visibleWidth(qr);
|
||||
if (columns <= width) {
|
||||
return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`;
|
||||
}
|
||||
|
||||
return qr;
|
||||
}
|
||||
|
||||
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
|
||||
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`;
|
||||
}
|
||||
@@ -277,6 +277,35 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders a live system-notice custom message as a synthetic tool call", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.runPrompt("hello OMP", "done");
|
||||
omp
|
||||
.runtime()
|
||||
.acceptCustomMessage(
|
||||
[
|
||||
"<system-notice>",
|
||||
"Background job DocsSmokeTwo has completed.",
|
||||
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
|
||||
"<output>done</output>",
|
||||
"</task-result>",
|
||||
"</system-notice>",
|
||||
].join("\n"),
|
||||
);
|
||||
omp.runtime().acceptCustomMessage("plain custom status text");
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "tool_call")).toMatchObject([
|
||||
{ callId: "omp-notice:DocsSmokeTwo", name: "task_notification", status: "completed" },
|
||||
]);
|
||||
// Non-notice custom messages still fall through as assistant messages.
|
||||
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([
|
||||
{ text: "done" },
|
||||
{ text: "plain custom status text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not complete a queued model turn from OMP's local-only hint", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
@@ -68,6 +68,7 @@ export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-c
|
||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
||||
import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
||||
import { getUserMessageText } from "./message-history.js";
|
||||
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
|
||||
import { materializeProviderImage } from "../provider-image-output.js";
|
||||
import { OmpCliRuntime } from "./cli-runtime.js";
|
||||
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
|
||||
@@ -2068,12 +2069,14 @@ export class OmpAgentSession implements AgentSession {
|
||||
if (shouldDisplayOmpCustomMessage(event.message)) {
|
||||
const text = getUserMessageText(event.message.content);
|
||||
if (text) {
|
||||
const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text);
|
||||
const item =
|
||||
mapOmpAdvisorMessageToToolCall(event.message, text) ??
|
||||
mapOmpSystemNoticeToToolCall(text);
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: advisorItem ?? { type: "assistant_message", text },
|
||||
item: item ?? { type: "assistant_message", text },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as QRCode from "qrcode";
|
||||
import { renderPairingQr } from "./pairing-qr.js";
|
||||
|
||||
const ESCAPE = String.fromCharCode(0x1b);
|
||||
const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g");
|
||||
|
||||
describe("renderPairingQr", () => {
|
||||
it("renders a theme-independent QR code with a four-module quiet zone", async () => {
|
||||
const url = "https://app.paseo.sh/#offer=test-pairing-offer";
|
||||
const qr = await renderPairingQr(url);
|
||||
const styledLines = qr.split("\n");
|
||||
const visibleLines = qr.replace(ANSI_PATTERN, "").split("\n");
|
||||
const moduleCount = QRCode.create(url).modules.size;
|
||||
|
||||
expect(
|
||||
styledLines.every(
|
||||
(line) => line.startsWith(`${ESCAPE}[47m${ESCAPE}[30m`) && line.endsWith(`${ESCAPE}[0m`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(visibleLines.every((line) => line.length === moduleCount + 8)).toBe(true);
|
||||
expect(visibleLines.slice(0, 2).every((line) => line.trim() === "")).toBe(true);
|
||||
expect(visibleLines.slice(-2).every((line) => line.trim() === "")).toBe(true);
|
||||
expect(visibleLines.every((line) => line.startsWith(" ") && line.endsWith(" "))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,18 @@
|
||||
import * as QRCode from "qrcode";
|
||||
import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode";
|
||||
import type { Logger } from "pino";
|
||||
import type { QRCodeToStringOptionsOther } from "qrcode";
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldPrintPairingQr(): boolean {
|
||||
const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR);
|
||||
if (env !== undefined) return env;
|
||||
return process.stdout.isTTY ?? false;
|
||||
}
|
||||
const BLACK_ON_WHITE = "\u001b[47m\u001b[30m";
|
||||
const RESET_COLORS = "\u001b[0m";
|
||||
|
||||
export async function renderPairingQr(url: string): Promise<string> {
|
||||
const terminalOptions: QRCodeToStringOptionsTerminal = {
|
||||
type: "terminal",
|
||||
small: true,
|
||||
};
|
||||
|
||||
const utf8Options: QRCodeToStringOptionsOther = {
|
||||
type: "utf8",
|
||||
margin: 4,
|
||||
};
|
||||
|
||||
try {
|
||||
return await QRCode.toString(url, terminalOptions);
|
||||
} catch {
|
||||
return await QRCode.toString(url, utf8Options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function printPairingQrIfEnabled(args: {
|
||||
url: string;
|
||||
logger?: Logger;
|
||||
}): Promise<void> {
|
||||
if (!shouldPrintPairingQr()) return;
|
||||
|
||||
const qr = await renderPairingQr(args.url);
|
||||
const out = `\nScan to pair:\n${qr}\n${args.url}\n`;
|
||||
|
||||
try {
|
||||
process.stdout.write(out);
|
||||
} catch (error) {
|
||||
args.logger?.debug({ error }, "Failed to print pairing QR");
|
||||
}
|
||||
const qr = await QRCode.toString(url, utf8Options);
|
||||
return qr
|
||||
.split("\n")
|
||||
.map((line) => `${BLACK_ON_WHITE}${line}${RESET_COLORS}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user