@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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
140
|
-
const
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "46dd7c10b5f8d5f1bb1f82e6e789d61397fbd42f"
|
|
62
62
|
}
|