@yagni-app/code-staging 1.2.1-staging.1521.1 → 1.2.2-staging.1525.1

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.
@@ -17,6 +17,11 @@
17
17
  * No backend changes; no new dependencies. Only pi's TUI primitives. When
18
18
  * there is no interactive UI (print / headless), execute() returns a short
19
19
  * message so the tool never hangs waiting for a keyboard.
20
+ *
21
+ * Hosts that drive pi over RPC (YAGNI Desktop) report `hasUI` but cannot
22
+ * mount a custom component: pi's RPC `ui.custom` resolves `undefined` without
23
+ * ever calling the factory. When that happens the question is re-asked through
24
+ * the plain `select` / `input` dialogs every host can render.
20
25
  */
21
26
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
22
27
  import { Type } from "typebox";
@@ -17,6 +17,11 @@
17
17
  * No backend changes; no new dependencies. Only pi's TUI primitives. When
18
18
  * there is no interactive UI (print / headless), execute() returns a short
19
19
  * message so the tool never hangs waiting for a keyboard.
20
+ *
21
+ * Hosts that drive pi over RPC (YAGNI Desktop) report `hasUI` but cannot
22
+ * mount a custom component: pi's RPC `ui.custom` resolves `undefined` without
23
+ * ever calling the factory. When that happens the question is re-asked through
24
+ * the plain `select` / `input` dialogs every host can render.
20
25
  */
21
26
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
22
27
  import { Type } from "typebox";
@@ -34,6 +39,11 @@ const ASK_TIMEOUT_MS = 120_000;
34
39
  const OTHER_KEY = "__other__";
35
40
  /** Label on the multi-select submit row (mirrors Claude's Submit button). */
36
41
  const SUBMIT_LABEL = "Submit";
42
+ /** Dialog-fallback row that opens a free-text input (the "Other" answer). */
43
+ const DIALOG_OTHER = "Other (type your own answer)";
44
+ /** Dialog-fallback marks for multi-select rows, toggled on each pick. */
45
+ const DIALOG_CHECKED = "[x] ";
46
+ const DIALOG_UNCHECKED = "[ ] ";
37
47
  const optionParameters = Type.Object({
38
48
  label: Type.String({
39
49
  description: "The display text for this option that the user sees and selects. Concise (1-5 words) and clearly describes the choice.",
@@ -513,7 +523,7 @@ async function askOne(ctx, q, qIndex) {
513
523
  if (!ctx.hasUI) {
514
524
  return { answer: undefined, cancelled: false };
515
525
  }
516
- const resolution = await new Promise((resolve) => {
526
+ const outcome = await new Promise((resolve) => {
517
527
  let settled = false;
518
528
  let close;
519
529
  const settle = (r) => {
@@ -521,7 +531,8 @@ async function askOne(ctx, q, qIndex) {
521
531
  return;
522
532
  settled = true;
523
533
  ctx.signal?.removeEventListener("abort", onAbort);
524
- close?.(r); // also closes the overlay (resolves the .custom promise)
534
+ if (r.status !== "unsupported")
535
+ close?.(r); // also closes the overlay (resolves the .custom promise)
525
536
  resolve(r);
526
537
  };
527
538
  const onAbort = () => settle({ status: "cancelled" });
@@ -547,6 +558,19 @@ async function askOne(ctx, q, qIndex) {
547
558
  })
548
559
  .then(() => {
549
560
  clearTimeout(timer);
561
+ // The promise resolved but nothing settled the question. If the
562
+ // factory never ran, this host cannot mount custom UI (pi RPC mode):
563
+ // fall back to plain dialogs. Otherwise the overlay was dismissed from
564
+ // outside: treat it as a cancel. Either way the turn never hangs.
565
+ if (settled)
566
+ return;
567
+ if (close) {
568
+ logAskQuestion({ event: "dismissed", detail: "overlay closed without an answer", qIndex });
569
+ settle({ status: "cancelled" });
570
+ }
571
+ else {
572
+ settle({ status: "unsupported" });
573
+ }
550
574
  })
551
575
  .catch((err) => {
552
576
  logAskQuestion({
@@ -557,6 +581,26 @@ async function askOne(ctx, q, qIndex) {
557
581
  settle({ status: "cancelled" });
558
582
  });
559
583
  });
584
+ let resolution;
585
+ if (outcome.status === "unsupported") {
586
+ logAskQuestion({ event: "fallback", detail: "ui.custom unsupported; using dialogs", multi: q.multiSelect === true, qIndex });
587
+ try {
588
+ resolution = await askViaDialogs(ctx, q, qIndex);
589
+ }
590
+ catch (err) {
591
+ // Named separately so a failing dialog host is not mistaken for the
592
+ // fallback itself breaking.
593
+ logAskQuestion({
594
+ event: "fallback_error",
595
+ detail: err instanceof Error ? `dialog threw: ${err.message}` : "dialog rejected",
596
+ qIndex,
597
+ });
598
+ resolution = { status: "cancelled" };
599
+ }
600
+ }
601
+ else {
602
+ resolution = outcome;
603
+ }
560
604
  if (resolution.status === "cancelled") {
561
605
  logAskQuestion({ event: "resolved", status: "cancelled", qIndex });
562
606
  return { answer: undefined, cancelled: true };
@@ -569,6 +613,75 @@ async function askOne(ctx, q, qIndex) {
569
613
  logAskQuestion({ event: "resolved", status: "selected", detail: `values:${resolution.values.length}`, qIndex });
570
614
  return { answer: resolution.values.join(", "), cancelled: false };
571
615
  }
616
+ /**
617
+ * Dialog rows must be distinct: `select` answers with the row's text, so a
618
+ * pick is mapped back by position in this list. Labels are unique, but two
619
+ * "label: description" rows can still read the same, and an option can read
620
+ * like a control row ("Submit"). If any collide, number every row.
621
+ */
622
+ function distinctRows(rows) {
623
+ return new Set(rows).size === rows.length ? rows : rows.map((row, i) => `${i + 1}. ${row}`);
624
+ }
625
+ /**
626
+ * Ask ONE question through the plain `select` / `input` dialogs, for hosts
627
+ * that cannot mount a custom component. Options show as "label: description";
628
+ * a trailing row opens a free-text input. Multi-select re-shows the list with
629
+ * [x] marks until the user picks Submit. Answers match the TUI component:
630
+ * an empty Other or an empty Submit is an empty answer (the tool moves on),
631
+ * and multi-select Other keeps the options already checked. Only Skip or
632
+ * Stop cancels. No timeout: a dialog is a visible card the user can Skip,
633
+ * and a host-side timeout would leave that card stranded.
634
+ */
635
+ async function askViaDialogs(ctx, q, qIndex) {
636
+ const title = q.header ? `${q.header}: ${q.question}` : q.question;
637
+ const opts = { signal: ctx.signal };
638
+ const rowFor = (o) => (o.description ? `${o.label}: ${o.description}` : o.label);
639
+ const otherIndex = q.options.length;
640
+ const submitIndex = q.options.length + 1;
641
+ /** Show the rows; the picked row's index, or undefined on Skip/Stop or an answer no row matches. */
642
+ const pick = async (rows) => {
643
+ const shown = distinctRows(rows);
644
+ const picked = await ctx.ui.select(title, shown, opts);
645
+ if (picked === undefined)
646
+ return undefined;
647
+ const idx = shown.indexOf(picked);
648
+ if (idx < 0)
649
+ logAskQuestion({ event: "error", detail: "dialog answered with a value no row matches", qIndex });
650
+ return idx < 0 ? undefined : idx;
651
+ };
652
+ /** The typed Other text ("" when submitted empty), or undefined on Skip/Stop. */
653
+ const typeOther = async () => (await ctx.ui.input(q.question, "Type your answer", opts))?.trim();
654
+ if (!q.multiSelect) {
655
+ const idx = await pick([...q.options.map(rowFor), DIALOG_OTHER]);
656
+ if (idx === undefined)
657
+ return { status: "cancelled" };
658
+ if (idx < otherIndex)
659
+ return { status: "selected", values: [q.options[idx].label] };
660
+ const text = await typeOther();
661
+ return text === undefined ? { status: "cancelled" } : { status: "other", text };
662
+ }
663
+ const checked = new Set();
664
+ const checkedLabels = () => [...checked].sort((a, b) => a - b).map((i) => q.options[i].label);
665
+ for (;;) {
666
+ const rows = q.options.map((o, i) => `${checked.has(i) ? DIALOG_CHECKED : DIALOG_UNCHECKED}${rowFor(o)}`);
667
+ const idx = await pick([...rows, DIALOG_OTHER, SUBMIT_LABEL]);
668
+ if (idx === undefined)
669
+ return { status: "cancelled" };
670
+ if (idx === otherIndex) {
671
+ const text = await typeOther();
672
+ if (text === undefined)
673
+ return { status: "cancelled" };
674
+ return { status: "other", text: [...checkedLabels(), ...(text ? [text] : [])].join(", ") };
675
+ }
676
+ if (idx === submitIndex) {
677
+ return checked.size > 0 ? { status: "selected", values: checkedLabels() } : { status: "other", text: "" };
678
+ }
679
+ if (checked.has(idx))
680
+ checked.delete(idx);
681
+ else
682
+ checked.add(idx);
683
+ }
684
+ }
572
685
  function buildTool(grounded = true) {
573
686
  return {
574
687
  name: TOOL_NAME,
@@ -37,7 +37,7 @@ export interface ImagePasteEvent {
37
37
  */
38
38
  export declare function logImagePaste(ev: ImagePasteEvent): void;
39
39
  export interface AskQuestionEvent {
40
- /** Stable event name: "key" | "finish" | "render" | "navigate" | "other_key". */
40
+ /** Stable event name: "key" | "finish" | "render" | "navigate" | "other_key" | "fallback" | "fallback_error" | "dismissed". */
41
41
  event: string;
42
42
  /** Human-readable description; NEVER user-typed text or question content. */
43
43
  detail?: string;
@@ -17,6 +17,13 @@
17
17
  * turn is captured unless it verifiably is.
18
18
  */
19
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ /**
21
+ * This session's error trail. The sink keys every line by the launcher's
22
+ * `YAGNI_SESSION_ID` unless the caller passes one (errorSink `sessionIdFor`),
23
+ * and that is NOT pi's own session id, so reading by pi's id alone found
24
+ * nothing and every report shipped an empty trail. Read both.
25
+ */
26
+ export declare function readFeedbackTrail(piSessionId: string, env: NodeJS.ProcessEnv, maxBytes?: number): string;
20
27
  export interface FeedbackDeps {
21
28
  baseUrl: string;
22
29
  getToken: () => string | undefined;
@@ -19,7 +19,20 @@
19
19
  import { readFileSync } from "node:fs";
20
20
  import { scrubSecrets } from "./pipeline/scrubSecrets.js";
21
21
  import { readSessionTrail } from "./errorSink.js";
22
+ import { isDesktopSurface } from "./surface.js";
22
23
  const MAX_DESCRIPTION = 512;
24
+ const MAX_TRAIL_BYTES = 64 * 1024;
25
+ /**
26
+ * This session's error trail. The sink keys every line by the launcher's
27
+ * `YAGNI_SESSION_ID` unless the caller passes one (errorSink `sessionIdFor`),
28
+ * and that is NOT pi's own session id, so reading by pi's id alone found
29
+ * nothing and every report shipped an empty trail. Read both.
30
+ */
31
+ export function readFeedbackTrail(piSessionId, env, maxBytes = MAX_TRAIL_BYTES) {
32
+ const ids = [...new Set([env.YAGNI_SESSION_ID, piSessionId].filter((id) => !!id))];
33
+ const trail = ids.map((id) => readSessionTrail(id, maxBytes)).filter(Boolean).join("\n");
34
+ return trail.length > maxBytes ? trail.slice(trail.length - maxBytes) : trail;
35
+ }
23
36
  const MAX_TRANSCRIPT_READ_BYTES = 512 * 1024;
24
37
  const notify = (ctx, message, type) => {
25
38
  if (ctx.hasUI)
@@ -46,7 +59,11 @@ function readTranscript(sessionFile) {
46
59
  }
47
60
  }
48
61
  async function handleFeedback(args, ctx, deps) {
49
- const sessionId = ctx.sessionManager.getSessionId?.() ?? deps.env?.YAGNI_SESSION_ID ?? "";
62
+ // The extension's production wiring passes no env (tests inject one), so
63
+ // fall back to the process env: without it the version and surface the
64
+ // launcher forwards were never read, and every report said cli/unknown.
65
+ const env = deps.env ?? process.env;
66
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? env.YAGNI_SESSION_ID ?? "";
50
67
  const sessionFile = ctx.sessionManager.getSessionFile?.();
51
68
  const cwd = ctx.cwd;
52
69
  const description = args.trim()
@@ -61,7 +78,7 @@ async function handleFeedback(args, ctx, deps) {
61
78
  return;
62
79
  }
63
80
  const transcript = readTranscript(sessionFile);
64
- const trail = readSessionTrail(sessionId);
81
+ const trail = readFeedbackTrail(sessionId, env);
65
82
  const doctorReport = deps.getDoctorReport
66
83
  ? await deps.getDoctorReport().catch(() => undefined)
67
84
  : undefined;
@@ -86,8 +103,8 @@ async function handleFeedback(args, ctx, deps) {
86
103
  return;
87
104
  }
88
105
  const payload = {
89
- client: "cli",
90
- clientVersion: deps.env?.YAGNI_CODE_VERSION?.trim() || "unknown",
106
+ client: isDesktopSurface(env) ? "desktop" : "cli",
107
+ clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
91
108
  platform: `${process.platform} ${process.arch}`,
92
109
  description: redact(description).slice(0, MAX_DESCRIPTION),
93
110
  sessionId,
@@ -136,8 +153,9 @@ export function registerFeedbackCommands(pi, deps) {
136
153
  pi.registerCommand("diagnostics", {
137
154
  description: "Show recent error-trail lines for this session.",
138
155
  handler: async (_args, ctx) => {
139
- const sessionId = ctx.sessionManager.getSessionId?.() ?? deps.env?.YAGNI_SESSION_ID ?? "";
140
- const trail = readSessionTrail(sessionId, 16 * 1024);
156
+ const env = deps.env ?? process.env;
157
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? env.YAGNI_SESSION_ID ?? "";
158
+ const trail = readFeedbackTrail(sessionId, env, 16 * 1024);
141
159
  if (!trail) {
142
160
  notify(ctx, "No recent diagnostics for this session.", "info");
143
161
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.1-staging.1521.1",
3
+ "version": "1.2.2-staging.1525.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "d59214b05a4a1c9c33efa56559d684239e0865ac"
61
+ "yagniSourceSha": "46dd7c10b5f8d5f1bb1f82e6e789d61397fbd42f"
62
62
  }