@danypops/papyrus 0.26.0 → 0.27.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.
@@ -16,10 +16,10 @@ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-a
16
16
  import type { Artifact } from "../../src/domain/artifact.ts";
17
17
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
18
18
  import { readDiscussionExtra } from "../../src/domain/discussion.ts";
19
+ import { askQuestion } from "./discuss-ask-view.ts";
19
20
  import { showArtifactBrowser } from "./artifact-browser.ts";
20
21
  import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
21
22
  import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
22
- import { pickDiscussionOptions } from "./discussion-picker.ts";
23
23
  import { callService } from "./service-client.ts";
24
24
 
25
25
  const SOURCE = "discuss-tui";
@@ -82,25 +82,13 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
82
82
  }
83
83
  if (choice === "Reply") {
84
84
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
85
- if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
86
- const result = await pickDiscussionOptions(commandCtx, pending.pendingOptionsMode, pending.pendingOptions);
87
- if (!result) return; // canceled
88
- if (result.kind === "freeform") {
89
- await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: result.text, source: SOURCE });
90
- commandCtx.ui.notify("Reply added.", "info");
91
- return;
92
- }
93
- const { selected } = result;
94
- const elaboration = await commandCtx.ui.input("Elaborate (optional):", selected.join(", "));
95
- if (elaboration === undefined) return; // canceled
96
- await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: elaboration || selected.join(", "), selected, source: SOURCE });
97
- commandCtx.ui.notify(`Selected: ${selected.join(", ")}`, "info");
98
- return;
99
- }
100
- const content = await commandCtx.ui.input("Reply:", "");
101
- if (!content) return;
102
- await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content, source: SOURCE });
103
- commandCtx.ui.notify("Round added.", "info");
85
+ const question = `Reply to "${discussion.title}":`;
86
+ const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
87
+ ? await askQuestion(commandCtx, { question, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
88
+ : await askQuestion(commandCtx, { question });
89
+ if (!answer) return; // canceled
90
+ await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
91
+ commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
104
92
  return;
105
93
  }
106
94
  if (choice === "Defer") {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import type { Artifact } from "../../src/domain/artifact.ts";
4
4
  import { PROOF_TYPES } from "../../src/domain/checklist.ts";
@@ -9,7 +9,7 @@ import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
10
10
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
11
11
  import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
12
- import { pickDiscussionOptions } from "./discussion-picker.ts";
12
+ import { askQuestion } from "./discuss-ask-view.ts";
13
13
  import type { OperationName } from "../../src/service.ts";
14
14
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
15
15
  import { sessionSecretField } from "./session-identity.ts";
@@ -32,23 +32,26 @@ function text(message: string, details: unknown = {}) {
32
32
  }
33
33
 
34
34
  /**
35
- * live:true's synchronous half: renders the same picker /discuss's own "Reply" action uses when
36
- * the just-created round posed a structured choice, or a plain freeform prompt otherwise -- so
37
- * "ask" covers both a completely open question and a choice tied to this specific Discussion.
38
- * Returns undefined on cancel or when no interactive UI is available, never throws -- an
39
- * unanswered live prompt still leaves the round it already recorded intact.
35
+ * live:true's synchronous half: reuses the same Discuss-owned ask UI (discuss-ask-view.ts) the
36
+ * /discuss TUI's own "Reply" action uses when the just-created round posed a structured choice,
37
+ * or a plain freeform prompt otherwise -- so "ask" covers both a completely open question and a
38
+ * choice tied to this specific Discussion. Returns undefined on cancel or when no interactive UI
39
+ * is available, never throws -- an unanswered live prompt still leaves the round it already
40
+ * recorded intact.
40
41
  */
41
- async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<{ content: string; selected?: string[] } | undefined> {
42
+ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, onUpdate: AgentToolUpdateCallback | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
42
43
  if (!ctx.hasUI) return undefined;
43
44
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
45
+ const question = `Reply to "${discussion.title}":`;
44
46
  if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
45
- const result = await pickDiscussionOptions(ctx, pending.pendingOptionsMode, pending.pendingOptions);
46
- if (!result) return undefined;
47
- if (result.kind === "freeform") return { content: result.text };
48
- return { content: result.selected.join(", "), selected: result.selected };
47
+ return askQuestion(ctx, {
48
+ question,
49
+ options: pending.pendingOptions.map((title) => ({ title })),
50
+ allowMultiple: pending.pendingOptionsMode === "multi",
51
+ onUpdate,
52
+ });
49
53
  }
50
- const content = await ctx.ui.input(`Reply to "${discussion.title}":`, "");
51
- return content ? { content } : undefined;
54
+ return askQuestion(ctx, { question, onUpdate });
52
55
  }
53
56
 
54
57
  /**
@@ -695,9 +698,13 @@ export function registerDomainTools(pi: ExtensionAPI): void {
695
698
  selected: Type.Optional(Type.Array(Type.String())),
696
699
  live: Type.Optional(Type.Boolean()),
697
700
  }),
701
+ // Blocks other tool calls in the same assistant turn until live:true's human answer comes
702
+ // back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
703
+ // bash/edit/write and let those run before the human sees the prompt.
704
+ executionMode: "sequential",
698
705
  renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
699
706
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
700
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
707
+ async execute(_id, rawParams, _signal, onUpdate, ctx) {
701
708
  try {
702
709
  const params: Record<string, unknown> = { ...rawParams };
703
710
  const action = params.action;
@@ -714,7 +721,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
714
721
  ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
715
722
  : text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
716
723
  if (params.live !== true) return fallback;
717
- const answer = await liveAnswer(ctx, result.discussion);
724
+ const answer = await liveAnswer(ctx, result.discussion, onUpdate);
718
725
  if (!answer) return fallback;
719
726
  const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
720
727
  id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
package/src/constants.ts CHANGED
@@ -175,9 +175,6 @@ export const DISCUSSION_ACTOR_MAX_LENGTH = 128;
175
175
  export const DISCUSSION_OPTIONS_MIN_COUNT = 2;
176
176
  export const DISCUSSION_OPTIONS_MAX_COUNT = 10;
177
177
  export const DISCUSSION_OPTION_MAX_LENGTH = 200;
178
- /** The multi-select picker's idle auto-cancel countdown and its render tick (also drives the cursor-row blink). Single-select has no equivalent -- it delegates to Pi's own native ctx.ui.select, whose input loop this package does not control. */
179
- export const DISCUSSION_PICKER_IDLE_TIMEOUT_MS = 30_000;
180
- export const DISCUSSION_PICKER_TICK_MS = 500;
181
178
  /** Bounds for the generic graph projection protocol (external bounded contexts). */
182
179
  export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
183
180
  export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
@@ -1,148 +0,0 @@
1
- /**
2
- * discussion-picker.ts — the structured-choice picker for /discuss's "Reply" action and the
3
- * discuss tool's own live:true synchronous ask.
4
- *
5
- * "single" mode (mutually exclusive) needs nothing bespoke for the pick list itself: the Pi
6
- * extension UI already provides exactly that (ctx.ui.select). "multi" (allow several) has no
7
- * native equivalent anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so
8
- * that one is a small, genuinely domain-specific checkbox-list component, not a generic library
9
- * replacement. Both modes get a numbered quick-select (press the row's digit instead of
10
- * scrolling with arrows) and an appended "type your own answer" row, itself numbered the same
11
- * way -- a genuinely open question is exactly as valid an answer as any of the posed options.
12
- */
13
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
15
- import { DISCUSSION_PICKER_IDLE_TIMEOUT_MS, DISCUSSION_PICKER_TICK_MS } from "../../src/constants.ts";
16
- import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
17
-
18
- const FREEFORM_LABEL = "Something else (type your own answer)";
19
-
20
- export type DiscussionPickResult = { kind: "selected"; selected: string[] } | { kind: "freeform"; text: string };
21
-
22
- /** digit "1".."9" -> index 0-8, "0" -> index 9 (DISCUSSION_OPTIONS_MAX_COUNT is 10) -- standard terminal-menu numbering, not 0-indexed. */
23
- function digitToIndex(data: string, rowCount: number): number | undefined {
24
- if (data === "0") return rowCount >= 10 ? 9 : undefined;
25
- if (data.length === 1 && data >= "1" && data <= "9") {
26
- const index = Number(data) - 1;
27
- return index < rowCount ? index : undefined;
28
- }
29
- return undefined;
30
- }
31
-
32
- function rowNumberLabel(index: number): string {
33
- if (index < 9) return `${index + 1}`;
34
- if (index === 9) return "0";
35
- return " "; // beyond the 1-9,0 quick-select range (should not happen given DISCUSSION_OPTIONS_MAX_COUNT=10, but never crash rendering)
36
- }
37
-
38
- async function promptFreeformAnswer(ctx: ExtensionContext): Promise<DiscussionPickResult | undefined> {
39
- const text = await ctx.ui.input("Your answer:", "");
40
- return text ? { kind: "freeform", text } : undefined;
41
- }
42
-
43
- /**
44
- * Toggle with space or a row's number, confirm with enter (refuses an empty confirm -- at least
45
- * one pick is required), cancel with escape. Picking the freeform row exits the checkbox flow
46
- * entirely rather than adding it to the selection.
47
- *
48
- * Idle countdown: auto-cancels after DISCUSSION_PICKER_IDLE_TIMEOUT_MS of no input at all --
49
- * the first keystroke of any kind stops it permanently (not a pause; it never resumes for this
50
- * picker instance), since a countdown ticking while someone is actively engaging is pressure,
51
- * not a nudge. The same tick also drives a slow, deliberately noticeable blink on the cursor
52
- * row -- checked rows stay steadily highlighted, everything else stays dimmed, so the eye reads
53
- * "what's chosen" at a glance independent of where the cursor happens to be.
54
- */
55
- async function pickMultiple(ctx: ExtensionContext, title: string, options: string[], allowFreeform: boolean, idleTimeoutMs: number, tickMs: number): Promise<DiscussionPickResult | undefined> {
56
- return ctx.ui.custom<DiscussionPickResult | undefined>((tui, theme, _keybindings, done) => {
57
- const rows = allowFreeform ? [...options, FREEFORM_LABEL] : options;
58
- const freeformIndex = allowFreeform ? rows.length - 1 : -1;
59
- const checked = new Set<number>();
60
- let selectedIndex = 0;
61
- let hasInteracted = false;
62
- let remainingMs = idleTimeoutMs;
63
- let blinkOn = true;
64
- const tick = setInterval(() => {
65
- blinkOn = !blinkOn;
66
- if (!hasInteracted) {
67
- remainingMs -= tickMs;
68
- if (remainingMs <= 0) { finish(undefined); return; }
69
- }
70
- tui.requestRender();
71
- }, tickMs);
72
- const finish = (value: DiscussionPickResult | undefined) => { clearInterval(tick); done(value); };
73
- const chooseFreeform = () => { promptFreeformAnswer(ctx).then(finish); };
74
- const toggle = (index: number) => {
75
- if (index === freeformIndex) { chooseFreeform(); return; }
76
- if (checked.has(index)) checked.delete(index); else checked.add(index);
77
- tui.requestRender();
78
- };
79
- return {
80
- invalidate() {},
81
- render(width: number): string[] {
82
- const lines: string[] = [
83
- theme.bold(title),
84
- theme.fg("muted", "number/space toggle \u00b7 enter confirm \u00b7 esc cancel"),
85
- "",
86
- ];
87
- rows.forEach((option, index) => {
88
- const isCursor = index === selectedIndex;
89
- const isChecked = checked.has(index);
90
- const cursorGlyph = isCursor && blinkOn ? theme.fg("accent", "\u276f") : " ";
91
- const box = index === freeformIndex ? " " : isChecked ? theme.fg("success", "[x]") : "[ ]";
92
- let label = option;
93
- if (isChecked) label = theme.bold(theme.fg("success", label));
94
- else if (isCursor) label = theme.bold(theme.fg("accent", label));
95
- else label = theme.fg("dim", label);
96
- lines.push(truncateToWidth(`${cursorGlyph} ${rowNumberLabel(index)}. ${box} ${label}`, width, ""));
97
- });
98
- lines.push("");
99
- lines.push(theme.fg("dim", `${checked.size} selected`));
100
- if (!hasInteracted) lines.push(theme.fg("dim", `auto-cancels in ${Math.max(0, Math.ceil(remainingMs / 1000))}s (press any key to stop)`));
101
- return lines;
102
- },
103
- handleInput(data: string) {
104
- hasInteracted = true;
105
- const digit = digitToIndex(data, rows.length);
106
- if (digit !== undefined) { selectedIndex = digit; toggle(digit); return; }
107
- if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + rows.length) % rows.length;
108
- else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % rows.length;
109
- else if (data === " ") { toggle(selectedIndex); return; }
110
- else if (matchesKey(data, "enter")) {
111
- if (selectedIndex === freeformIndex && checked.size === 0) { chooseFreeform(); return; }
112
- if (checked.size === 0) return; // refuse an empty confirm -- selecting nothing isn't a valid answer
113
- finish({ kind: "selected", selected: [...checked].sort((a, b) => a - b).map((index) => rows[index]!) });
114
- return;
115
- } else if (matchesKey(data, "escape")) { finish(undefined); return; }
116
- else return;
117
- tui.requestRender();
118
- },
119
- };
120
- });
121
- }
122
-
123
- /**
124
- * Picks one (single) or several (multi) of the given options, or a freeform typed answer
125
- * instead, or undefined if the user cancels. Takes the base ExtensionContext (just .ui) rather
126
- * than the wider ExtensionCommandContext, since a tool's execute() only ever receives the
127
- * former -- the discuss tool's own live mode reuses this same picker, not just the /discuss
128
- * TUI panel. Single mode's numbered quick-select is not guaranteed: it delegates to Pi's own
129
- * native ctx.ui.select, which this package does not control the key handling of.
130
- */
131
- export async function pickDiscussionOptions(
132
- ctx: ExtensionContext,
133
- mode: DiscussionOptionsMode,
134
- options: string[],
135
- allowFreeform = true,
136
- /** Test seam: real timers, not faked global time -- pass tiny values to exercise the idle-cancel/blink logic quickly and deterministically. */
137
- idleTimeoutMs = DISCUSSION_PICKER_IDLE_TIMEOUT_MS,
138
- tickMs = DISCUSSION_PICKER_TICK_MS,
139
- ): Promise<DiscussionPickResult | undefined> {
140
- if (mode === "single") {
141
- const rows = allowFreeform ? [...options, FREEFORM_LABEL] : options;
142
- const pick = await ctx.ui.select("Pick one:", rows);
143
- if (!pick) return undefined;
144
- if (allowFreeform && pick === FREEFORM_LABEL) return promptFreeformAnswer(ctx);
145
- return { kind: "selected", selected: [pick] };
146
- }
147
- return pickMultiple(ctx, "Pick one or more:", options, allowFreeform, idleTimeoutMs, tickMs);
148
- }