@danypops/papyrus 0.28.1 → 0.28.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.
@@ -18,6 +18,7 @@ import {
18
18
  CURSOR_MARKER,
19
19
  decodeKittyPrintable,
20
20
  Editor,
21
+ type EditorComponent,
21
22
  type EditorTheme,
22
23
  fuzzyFilter,
23
24
  Key,
@@ -51,7 +52,7 @@ function safeMarkdownTheme(): MarkdownTheme | undefined {
51
52
  }
52
53
  }
53
54
 
54
- export type AskDisplayMode = "overlay" | "inline";
55
+ export type AskDisplayMode = "overlay" | "inline" | "editor";
55
56
 
56
57
  export interface AskQuestionParams {
57
58
  question: string;
@@ -1142,7 +1143,7 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1142
1143
  const allowFreeform = params.allowFreeform ?? true;
1143
1144
  const allowComment = params.allowComment ?? parseBooleanPreference(process.env["PAPYRUS_DISCUSS_ALLOW_COMMENT"]) ?? false;
1144
1145
  const envMode = process.env["PAPYRUS_DISCUSS_DISPLAY_MODE"]?.trim().toLowerCase();
1145
- const envDisplayMode: AskDisplayMode | undefined = envMode === "overlay" || envMode === "inline" ? envMode : undefined;
1146
+ const envDisplayMode: AskDisplayMode | undefined = envMode === "overlay" || envMode === "inline" || envMode === "editor" ? envMode : undefined;
1146
1147
  const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
1147
1148
  const normalizedContext = params.context?.trim() || undefined;
1148
1149
 
@@ -1155,6 +1156,63 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1155
1156
  }
1156
1157
  }
1157
1158
 
1159
+ /**
1160
+ * Hosts an AskComponent in place of the real input editor (ctx.ui.setEditorComponent), the same
1161
+ * mechanism Pi's own slash-command menu ecosystem uses to render attached to the prompt rather
1162
+ * than as a floating overlay. getText() always returns the human's real in-progress draft,
1163
+ * captured once before swapping in -- setEditorComponent's own swap logic reads getText() off
1164
+ * the OUTGOING editor to carry a draft forward when restoring the previous one afterward; if
1165
+ * this returned anything else, restoring would silently overwrite a real draft with an empty
1166
+ * string. Implements EditorComponent directly rather than extending CustomEditor: CustomEditor's
1167
+ * duck-typed actionHandlers Map would otherwise get every app-level action (model switching,
1168
+ * clear, suspend) copied onto it by Pi's own editor-swap code, none of which this host uses or
1169
+ * forwards -- avoiding the inheritance sidesteps that dead weight entirely.
1170
+ */
1171
+ class DiscussEditorHost implements EditorComponent {
1172
+ constructor(
1173
+ private readonly ask: AskComponent,
1174
+ private readonly preservedText: string,
1175
+ ) {}
1176
+ getText(): string { return this.preservedText; }
1177
+ setText(_text: string): void {}
1178
+ render(width: number): string[] { return this.ask.render(width); }
1179
+ handleInput(data: string): void { this.ask.handleInput(data); }
1180
+ invalidate(): void { this.ask.invalidate(); }
1181
+ }
1182
+
1183
+ async function askViaEditorSwap(
1184
+ ctx: ExtensionContext,
1185
+ params: AskQuestionParams,
1186
+ options: AskOption[],
1187
+ allowMultiple: boolean,
1188
+ allowFreeform: boolean,
1189
+ allowComment: boolean,
1190
+ normalizedContext: string | undefined,
1191
+ shortcuts: ResolvedAskShortcuts,
1192
+ ): Promise<AskResponse | null> {
1193
+ const previousFactory = ctx.ui.getEditorComponent();
1194
+ const preservedText = ctx.ui.getEditorText();
1195
+ // setEditorComponent's factory only receives an EditorTheme (borderColor + selectList) --
1196
+ // nowhere near AskComponent's actual dependency on the full Theme surface (.fg(), .bold(),
1197
+ // etc). ctx.ui.theme is the real, rich Theme; captured here rather than from the factory.
1198
+ const theme = ctx.ui.theme;
1199
+ return new Promise<AskResponse | null>((resolve) => {
1200
+ let settled = false;
1201
+ const finish = (result: AskResponse | null) => {
1202
+ if (settled) return;
1203
+ settled = true;
1204
+ ctx.ui.setEditorComponent(previousFactory);
1205
+ resolve(result);
1206
+ };
1207
+ if (params.signal) params.signal.addEventListener("abort", () => finish(null), { once: true });
1208
+ if (params.timeout && params.timeout > 0) setTimeout(() => finish(null), params.timeout);
1209
+ ctx.ui.setEditorComponent((tui: TUI, _editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
1210
+ const ask = new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, "inline", tui, theme, keybindings, shortcuts, finish);
1211
+ return new DiscussEditorHost(ask, preservedText);
1212
+ });
1213
+ });
1214
+ }
1215
+
1158
1216
  async function askQuestionBlocking(
1159
1217
  ctx: ExtensionContext,
1160
1218
  params: AskQuestionParams,
@@ -1173,6 +1231,15 @@ async function askQuestionBlocking(
1173
1231
  commentToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_COMMENT_TOGGLE_KEY"], DEFAULT_COMMENT_TOGGLE_KEY),
1174
1232
  };
1175
1233
 
1234
+ // "editor" hosts the picker in place of the real input editor (Pi's own slash-command menu's
1235
+ // mechanism) instead of a floating overlay -- degrades to "inline" if setEditorComponent isn't
1236
+ // available in this UI mode (interactive-only, like onTerminalInput below).
1237
+ if (displayMode === "editor" && typeof ctx.ui.setEditorComponent === "function" && typeof ctx.ui.getEditorComponent === "function" && typeof ctx.ui.getEditorText === "function") {
1238
+ const response = await askViaEditorSwap(ctx, params, options, allowMultiple, allowFreeform, allowComment, normalizedContext, shortcuts);
1239
+ return response ? toAskAnswer(response) : undefined;
1240
+ }
1241
+ const effectiveDisplayMode: AskDisplayMode = displayMode === "editor" ? "inline" : displayMode;
1242
+
1176
1243
  let overlayHandle: OverlayHandle | undefined;
1177
1244
  let removeOverlayInputListener: (() => void) | undefined;
1178
1245
  let hasAnnouncedHide = false;
@@ -1181,7 +1248,7 @@ async function askQuestionBlocking(
1181
1248
  const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
1182
1249
  if (params.signal) params.signal.addEventListener("abort", () => done(null), { once: true });
1183
1250
  if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
1184
- return new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1251
+ return new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, effectiveDisplayMode, tui, theme, keybindings, shortcuts, done);
1185
1252
  };
1186
1253
 
1187
1254
  const overlayToggle = shortcuts.overlayToggle;
@@ -1195,7 +1262,7 @@ async function askQuestionBlocking(
1195
1262
  });
1196
1263
  }
1197
1264
 
1198
- const customResult = await ctx.ui.custom<AskResponse | null>(factory, buildCustomUIOptions(displayMode, (handle) => { overlayHandle = handle; }));
1265
+ const customResult = await ctx.ui.custom<AskResponse | null>(factory, buildCustomUIOptions(effectiveDisplayMode, (handle) => { overlayHandle = handle; }));
1199
1266
  response = customResult !== undefined ? customResult : await askViaDialogs(ctx.ui, params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, params.timeout);
1200
1267
  } finally {
1201
1268
  removeOverlayInputListener?.();
@@ -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 { askQuestion } from "./discuss-ask-view.ts";
12
+ import { askQuestion, type AskDisplayMode } 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";
@@ -68,7 +68,11 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
68
68
  if (anyDescription) params.option_descriptions = descriptions;
69
69
  }
70
70
 
71
- async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
71
+ function parseDisplayMode(value: unknown): AskDisplayMode | undefined {
72
+ return value === "overlay" || value === "inline" || value === "editor" ? value : undefined;
73
+ }
74
+
75
+ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined, displayMode: AskDisplayMode | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
72
76
  if (!ctx.hasUI) return undefined;
73
77
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
74
78
  // The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
@@ -85,9 +89,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestCon
85
89
  allowMultiple: pending.pendingOptionsMode === "multi",
86
90
  onUpdate,
87
91
  signal,
92
+ displayMode,
88
93
  });
89
94
  }
90
- return askQuestion(ctx, { question, subtitle, onUpdate, signal });
95
+ return askQuestion(ctx, { question, subtitle, onUpdate, signal, displayMode });
91
96
  }
92
97
 
93
98
  /**
@@ -710,7 +715,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
710
715
  pi.registerTool({
711
716
  name: "discuss",
712
717
  label: "Discuss",
713
- description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
718
+ description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. display_mode picks how the live picker renders: 'overlay' (default, a floating dialog), 'inline' (renders in the normal transcript flow, no floating), or 'editor' (hosted in place of the input box itself, like a slash-command menu; falls back to 'inline' if unsupported in the current UI mode). PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
714
719
  parameters: Type.Object({
715
720
  action: Type.String(),
716
721
  id: Type.Optional(Type.String()),
@@ -733,6 +738,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
733
738
  options_mode: Type.Optional(Type.String()),
734
739
  selected: Type.Optional(Type.Array(Type.String())),
735
740
  live: Type.Optional(Type.Boolean()),
741
+ display_mode: Type.Optional(Type.String()),
736
742
  }),
737
743
  // Blocks other tool calls in the same assistant turn until live:true's human answer comes
738
744
  // back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
@@ -758,7 +764,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
758
764
  ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
759
765
  : text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
760
766
  if (params.live !== true) return fallback;
761
- const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
767
+ const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal, parseDisplayMode(rawParams.display_mode));
762
768
  if (!answer) return fallback;
763
769
  const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
764
770
  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.28.1",
3
+ "version": "0.28.3",
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"],