@xynogen/pix-ssh 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "typebox": "^1.1.38",
40
- "@xynogen/pix-pretty": "^1.16.0",
40
+ "@xynogen/pix-pretty": "^1.19.0",
41
41
  "@xynogen/pix-runtime": "^0.8.0"
42
42
  },
43
43
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-
35
35
  import {
36
36
  dotJoin,
37
37
  fillToolBackground,
38
+ frameToolResult,
38
39
  getTextContent,
39
40
  hideCollapsedToolCall,
40
41
  normalizeLineEndings,
@@ -43,6 +44,7 @@ import {
43
44
  ruleFrame,
44
45
  sectionRule,
45
46
  termW,
47
+ unframeToolResult,
46
48
  } from "@xynogen/pix-pretty/utils";
47
49
  import { SPINNER } from "@xynogen/pix-pretty/widget-format";
48
50
  import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
@@ -310,14 +312,7 @@ export default function (pi: ExtensionAPI): void {
310
312
  "`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
311
313
  promptSnippet: "Run a remote command or transfer files over SSH",
312
314
  promptGuidelines: [
313
- "ssh_run sends commands or transfers files/directories over SSH. Basic cmd, PowerShell, or " +
314
- "pwsh commands may work, but treat Windows shells as best-effort. Back away and tell the user " +
315
- "when correctness depends on explicit shell selection, complex quoting, PowerShell error/stream/encoding " +
316
- "semantics, interactive prompts, or Windows administrator/UAC elevation. `sudo` covers POSIX sudo " +
317
- 'only. Provide `host` as `[user@]host[:port]`. For file transfer, use `action: "file"`, ' +
318
- '`direction: "upload" | "download"`, `source`, `destination`, and optional `recursive`. ' +
319
- "Transfers may overwrite the destination and are warning-level: AFK/YOLO auto-approve when " +
320
- "no login password is missing. Always explain the intent in `reason`.",
315
+ 'ssh_run: `host` as `[user@]host[:port]`; `sudo` covers POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Always set `reason`.',
321
316
  ],
322
317
 
323
318
  renderShell: "self",
@@ -486,7 +481,7 @@ export default function (pi: ExtensionAPI): void {
486
481
  hostApproved(approvedHosts, key) &&
487
482
  promptFor.length === 0;
488
483
  if (alreadyApproved) {
489
- ctx.ui.notify(`🔐 ssh_run auto-approved — ${host} allowed this session`, "warning");
484
+ ctx.ui.notify(`ssh_run: reused session approval for ${host}`, "info");
490
485
  } else if (transferDecision === "allow") {
491
486
  ctx.ui.notify(
492
487
  `⚠ ssh_run file transfer auto-approved — ${mode.toUpperCase()} warning policy`,
@@ -737,21 +732,24 @@ export default function (pi: ExtensionAPI): void {
737
732
  renderCtx: RenderContextLike,
738
733
  ) => {
739
734
  resolveBaseBackground(theme);
740
- const text = renderCtx.lastComponent ?? new Text("", 0, 0);
735
+ const text = unframeToolResult(renderCtx.lastComponent ?? new Text("", 0, 0));
741
736
  const details = result.details as SshResultDetails | undefined;
737
+ const isPartial = (_opt as { isPartial?: boolean } | undefined)?.isPartial === true;
738
+ const completed = (isError: boolean) => frameToolResult(text, theme, isError);
742
739
 
743
740
  if (details?._type !== "sshResult") {
744
741
  if (renderCtx.isError) {
745
742
  text.setText(renderToolError(getTextContent(result) || "Error", theme));
746
743
  } else {
747
744
  text.setText(
748
- fillToolBackground(` ${theme.fg("dim", getTextContent(result) || "done")}`),
745
+ fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "done")}`),
749
746
  );
750
747
  }
751
- return text;
748
+ return isPartial ? text : completed(renderCtx.isError);
752
749
  }
753
750
 
754
751
  if (
752
+ !isPartial &&
755
753
  isTerminal(details) &&
756
754
  tickCollapse(
757
755
  "ssh",
@@ -760,12 +758,7 @@ export default function (pi: ExtensionAPI): void {
760
758
  renderCtx.expanded,
761
759
  )
762
760
  ) {
763
- const status =
764
- details.outcome === "success"
765
- ? "success"
766
- : details.outcome === "error"
767
- ? "error"
768
- : "warning";
761
+ const status = details.outcome === "success" ? "success" : "error";
769
762
  text.setText(
770
763
  renderCollapsedToolRow(
771
764
  theme,
@@ -780,7 +773,7 @@ export default function (pi: ExtensionAPI): void {
780
773
 
781
774
  if (details.outcome === "awaiting-approval" || details.outcome === "running") {
782
775
  text.setText(
783
- fillToolBackground(` ${theme.fg("dim", getTextContent(result) || "working")}`),
776
+ fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "working")}`),
784
777
  );
785
778
  return text;
786
779
  }
@@ -792,7 +785,7 @@ export default function (pi: ExtensionAPI): void {
792
785
  ? renderToolError(diagnostic, theme)
793
786
  : fillToolBackground(` ${theme.fg("warning", diagnostic)}`),
794
787
  );
795
- return text;
788
+ return isPartial ? text : completed(true);
796
789
  }
797
790
 
798
791
  const code = typeof details.exitCode === "number" ? details.exitCode : null;
@@ -803,22 +796,18 @@ export default function (pi: ExtensionAPI): void {
803
796
 
804
797
  if (!rendered) {
805
798
  text.setText(fillToolBackground(` ${summary}`));
806
- return text;
799
+ return isPartial ? text : completed(details.outcome !== "success");
807
800
  }
808
801
 
809
802
  const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
810
803
  const show = lines.slice(0, maxShow);
811
804
  const footer =
812
805
  lineCount > maxShow ? [`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`] : [];
813
- const statusKey = code === null ? "dim" : code === 0 ? "success" : "error";
806
+ const statusKey = details.outcome === "success" ? "success" : "error";
814
807
  const paint = (s: string) => theme.fg(statusKey, s);
815
808
  const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
816
- const out = ruleFrame(
817
- show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`),
818
- footer,
819
- termW(),
820
- paint,
821
- );
809
+ const body = show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`);
810
+ const out = isPartial ? [...body, ...footer] : ruleFrame(body, footer, termW(), paint);
822
811
  text.setText(fillToolBackground(out.join("\n")));
823
812
  return text;
824
813
  }) as never,
package/src/lib.test.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import registerSsh from "./index.ts";
2
4
  import {
3
5
  baseScpArgs,
4
6
  baseSshArgs,
@@ -251,3 +253,68 @@ describe("truncate", () => {
251
253
  expect(out.text.split("\n").length).toBe(2000);
252
254
  });
253
255
  });
256
+
257
+ describe("ssh result renderer", () => {
258
+ const theme = {
259
+ fg: (key: string, text: string) => `[${key}]${text}[/]`,
260
+ bold: (text: string) => text,
261
+ };
262
+ const register = () => {
263
+ let renderer: ((...args: unknown[]) => { render(width: number): string[] }) | undefined;
264
+ registerSsh({
265
+ registerTool(tool: unknown) {
266
+ renderer = (tool as { renderResult: typeof renderer }).renderResult;
267
+ },
268
+ } as unknown as ExtensionAPI);
269
+ if (!renderer) throw new Error("renderResult not registered");
270
+ return renderer;
271
+ };
272
+ const render = (
273
+ renderer: (...args: unknown[]) => { render(width: number): string[] },
274
+ details: Record<string, unknown> | undefined,
275
+ isError = false,
276
+ isPartial = false,
277
+ state: Record<string, unknown> = {},
278
+ expanded = true,
279
+ ) =>
280
+ renderer(
281
+ { content: [{ type: "text", text: String(details?.outcome ?? "done") }], details },
282
+ { isPartial },
283
+ theme,
284
+ { expanded, isError, invalidate: () => {}, state },
285
+ )
286
+ .render(24)
287
+ .join("\n");
288
+
289
+ it("frames open terminal outcomes and generic results by status", () => {
290
+ const renderer = register();
291
+ expect(render(renderer, undefined)).toContain("[success]─");
292
+ expect(render(renderer, undefined, true)).toContain("[error]─");
293
+ for (const outcome of ["denied", "timed-out", "cancelled", "error"]) {
294
+ expect(
295
+ render(renderer, {
296
+ _type: "sshResult",
297
+ command: "id",
298
+ host: "host",
299
+ sudo: false,
300
+ outcome,
301
+ }),
302
+ ).toContain("[error]─");
303
+ }
304
+ });
305
+
306
+ it("leaves partial, running, and collapsed rows unframed", () => {
307
+ const renderer = register();
308
+ const running = {
309
+ _type: "sshResult",
310
+ command: "id",
311
+ host: "host",
312
+ sudo: false,
313
+ outcome: "running",
314
+ };
315
+ expect(render(renderer, running, false, true)).not.toContain("[success]─");
316
+ expect(render(renderer, running)).not.toContain("[success]─");
317
+ const success = { ...running, outcome: "success", exitCode: 0, _render: "done" };
318
+ expect(render(renderer, success, false, false, { collapsed: true }, false)).not.toContain("─");
319
+ });
320
+ });