@danypops/papyrus 0.30.0 → 0.31.0

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.
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * discuss-ask-view.ts — Discuss's own live:true synchronous ask UI: searchable single-select
3
3
  * with a split-pane description preview, a checkbox multi-select, an integrated freeform
4
- * editor, an optional post-selection comment, overlay/inline display modes, toggle shortcuts,
5
- * and an auto-dismiss timeout. Owned end-to-end by Papyrus/Discuss -- no runtime dependency on
6
- * or delegation to another package's registered tool.
4
+ * editor, an optional post-selection comment, docked in the real input editor (never a floating
5
+ * overlay), and an auto-dismiss timeout. Owned end-to-end by Papyrus/Discuss -- no runtime
6
+ * dependency on or delegation to another package's registered tool.
7
7
  *
8
8
  * Substantially adapted from pi-ask-user's index.ts (MIT, Copyright (c) 2026 Enzo Lucchesi --
9
9
  * full notice in THIRD_PARTY_LICENSES.md), with the standalone-tool plumbing (schema, tool
@@ -27,8 +27,6 @@ import {
27
27
  Markdown,
28
28
  type MarkdownTheme,
29
29
  matchesKey,
30
- type OverlayHandle,
31
- type OverlayOptions,
32
30
  Spacer,
33
31
  Text,
34
32
  type TUI,
@@ -52,8 +50,6 @@ function safeMarkdownTheme(): MarkdownTheme | undefined {
52
50
  }
53
51
  }
54
52
 
55
- export type AskDisplayMode = "overlay" | "inline" | "editor";
56
-
57
53
  export interface AskQuestionParams {
58
54
  question: string;
59
55
  context?: string;
@@ -64,7 +60,6 @@ export interface AskQuestionParams {
64
60
  allowMultiple?: boolean;
65
61
  allowFreeform?: boolean;
66
62
  allowComment?: boolean;
67
- displayMode?: AskDisplayMode;
68
63
  timeout?: number;
69
64
  /**
70
65
  * Streamed once before blocking on the human, matching pi-ask-user's own original code (the
@@ -232,17 +227,17 @@ function resolveShortcut(paramValue: string | null | undefined, envValue: string
232
227
 
233
228
  type AskMode = "select" | "freeform" | "comment";
234
229
 
235
- const OVERLAY_MAX_HEIGHT_RATIO = 0.85;
236
- const OVERLAY_MIN_RENDER_LINES = 8;
237
- const OVERLAY_WIDTH = "92%";
238
- const OVERLAY_MIN_WIDTH = 40;
230
+ // Docked in the input area: growing past this ceiling pushes the conversation transcript above
231
+ // it out of view, so the scroll keys below do real work on a long question instead of the picker
232
+ // consuming the terminal outright.
233
+ const ASK_MAX_HEIGHT_RATIO = 0.5;
234
+ const ASK_MIN_RENDER_LINES = 8;
239
235
  const SPLIT_PANE_MIN_WIDTH = 84;
240
236
  const SPLIT_PANE_LEFT_MIN_WIDTH = 32;
241
237
  const SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
242
238
  const SPLIT_PANE_SEPARATOR = " │ ";
243
239
  const FREEFORM_SENTINEL = "\u270f\ufe0f Type a custom answer...";
244
240
  const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
245
- const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
246
241
  const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
247
242
 
248
243
  const VIM_SELECT_UP_KEY = Key.ctrl("k");
@@ -254,11 +249,11 @@ const PROMPT_SCROLL_END_KEY = Key.end;
254
249
  const PROMPT_SCROLL_HALF_PAGE_UP_KEY = Key.ctrl("u");
255
250
  const PROMPT_SCROLL_HALF_PAGE_DOWN_KEY = Key.ctrl("d");
256
251
 
257
- function getOverlayMaxRenderLinesForRows(rows: number): number {
252
+ function getAskMaxRenderLinesForRows(rows: number): number {
258
253
  const normalizedRows = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
259
254
  const availableRows = Math.max(1, normalizedRows - 2);
260
- const ratioRows = Math.max(1, Math.floor(normalizedRows * OVERLAY_MAX_HEIGHT_RATIO));
261
- const minimumRows = Math.min(OVERLAY_MIN_RENDER_LINES, availableRows);
255
+ const ratioRows = Math.max(1, Math.floor(normalizedRows * ASK_MAX_HEIGHT_RATIO));
256
+ const minimumRows = Math.min(ASK_MIN_RENDER_LINES, availableRows);
262
257
  return Math.min(availableRows, Math.max(minimumRows, ratioRows));
263
258
  }
264
259
 
@@ -270,15 +265,6 @@ function matchesSelectDown(data: string, keybindings: KeybindingsManager): boole
270
265
  return keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab) || matchesKey(data, VIM_SELECT_DOWN_KEY);
271
266
  }
272
267
 
273
- function buildCustomUIOptions(displayMode: AskDisplayMode, onHandle?: (handle: OverlayHandle) => void): { overlay?: boolean; overlayOptions?: OverlayOptions; onHandle?: (handle: OverlayHandle) => void } | undefined {
274
- if (displayMode === "inline") return undefined;
275
- return {
276
- overlay: true,
277
- overlayOptions: { anchor: "center" as const, width: OVERLAY_WIDTH, minWidth: OVERLAY_MIN_WIDTH, maxHeight: "85%", margin: 1 },
278
- ...(onHandle ? { onHandle } : {}),
279
- };
280
- }
281
-
282
268
  class MultiSelectList implements Component {
283
269
  private selectedIndex = 0;
284
270
  private checked = new Set<number>();
@@ -591,7 +577,6 @@ class WrappedSingleSelectList implements Component {
591
577
  }
592
578
 
593
579
  interface ResolvedAskShortcuts {
594
- overlayToggle: ResolvedShortcut;
595
580
  commentToggle: ResolvedShortcut;
596
581
  }
597
582
 
@@ -630,7 +615,6 @@ class AskComponent extends Container {
630
615
  private allowMultiple: boolean,
631
616
  private allowFreeform: boolean,
632
617
  private allowComment: boolean,
633
- private displayMode: AskDisplayMode,
634
618
  private tui: TUI,
635
619
  private theme: Theme,
636
620
  private keybindings: KeybindingsManager,
@@ -673,25 +657,23 @@ class AskComponent extends Container {
673
657
 
674
658
  override render(width: number): string[] {
675
659
  const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
676
- if (this.displayMode === "overlay") return this.renderOverlayLayout(width, innerWidth);
677
- if (this.mode === "select" && !this.allowMultiple) this.ensureSingleSelectList().setMaxVisibleRows(12);
678
- return this.frameRawLines(super.render(innerWidth), width, innerWidth);
660
+ return this.renderBudgetedLayout(width, innerWidth);
679
661
  }
680
662
 
681
- private getOverlayMaxRenderLines(): number {
663
+ private getAskMaxRenderLines(): number {
682
664
  const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
683
- return getOverlayMaxRenderLinesForRows(rows);
665
+ return getAskMaxRenderLinesForRows(rows);
684
666
  }
685
667
 
686
- private renderOverlayLayout(width: number, innerWidth: number): string[] {
687
- const maxLines = this.getOverlayMaxRenderLines();
668
+ private renderBudgetedLayout(width: number, innerWidth: number): string[] {
669
+ const maxLines = this.getAskMaxRenderLines();
688
670
  if (maxLines <= 1) return [this.renderTopBorder(width)];
689
671
  if (maxLines === 2) return [this.renderTopBorder(width), this.renderBottomBorder(width)];
690
672
 
691
673
  const bodyCapacity = Math.max(0, maxLines - 2);
692
674
  const promptLines = this.buildPromptLines(innerWidth);
693
675
  const helpFullLines = this.helpText.render(innerWidth);
694
- const helpBudget = this.getOverlayHelpBudget(bodyCapacity, helpFullLines.length);
676
+ const helpBudget = this.getHelpBudget(bodyCapacity, helpFullLines.length);
695
677
  const contentRows = Math.max(0, bodyCapacity - helpBudget);
696
678
 
697
679
  let promptBudget = 0;
@@ -741,7 +723,7 @@ class AskComponent extends Container {
741
723
  return [...this.titleText.render(width), ...this.questionText.render(width), ...(this.contextComponent ? ["", ...this.contextComponent.render(width)] : [])];
742
724
  }
743
725
 
744
- private getOverlayHelpBudget(bodyCapacity: number, renderedHelpRows: number): number {
726
+ private getHelpBudget(bodyCapacity: number, renderedHelpRows: number): number {
745
727
  if (renderedHelpRows <= 0 || bodyCapacity <= 0) return 0;
746
728
  return bodyCapacity >= 12 ? Math.min(2, renderedHelpRows) : 1;
747
729
  }
@@ -787,7 +769,7 @@ class AskComponent extends Container {
787
769
  ];
788
770
  }
789
771
  // Only meaningful when reached by escaping OUT of a real select list -- see showFreeformMode's
790
- // identical guard for the non-overlay layout.
772
+ // identical guard.
791
773
  if (this.options.length === 0) return [];
792
774
  return [...new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0).render(width), ""];
793
775
  }
@@ -861,15 +843,6 @@ class AskComponent extends Container {
861
843
  ];
862
844
  }
863
845
 
864
- private frameRawLines(rawLines: string[], width: number, innerWidth: number): string[] {
865
- const borderColor = (s: string) => this.theme.fg("accent", s);
866
- return rawLines.map((line, index) => {
867
- if (index === 0) return this.renderTopBorder(width);
868
- if (index === rawLines.length - 1) return this.renderBottomBorder(width);
869
- return `${borderColor(BOX_BORDER_LEFT)}${truncateToWidth(line, innerWidth, "", true)}${borderColor(BOX_BORDER_RIGHT)}`;
870
- });
871
- }
872
-
873
846
  private updateStaticText(): void {
874
847
  const theme = this.theme;
875
848
  // Reuses the same slot for two different purposes: a plain "which discussion is this" subtitle
@@ -886,8 +859,7 @@ class AskComponent extends Container {
886
859
 
887
860
  private updateHelpText(): void {
888
861
  const theme = this.theme;
889
- const overlayHint = this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled ? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide") : null;
890
- const promptScrollHint = this.displayMode === "overlay" ? literalHint(theme, "PgUp/PgDn", "prompt") : null;
862
+ const promptScrollHint = literalHint(theme, "PgUp/PgDn", "prompt");
891
863
  const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled ? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context") : null;
892
864
 
893
865
  if (this.mode === "freeform" || this.mode === "comment") {
@@ -897,7 +869,6 @@ class AskComponent extends Container {
897
869
  keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
898
870
  keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
899
871
  literalHint(theme, "esc", canGoBack ? "back" : "cancel"),
900
- overlayHint,
901
872
  canGoBack && alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
902
873
  ].filter((hint): hint is string => !!hint).join(" • ");
903
874
  this.helpText.setText(theme.fg("dim", hints));
@@ -906,7 +877,7 @@ class AskComponent extends Container {
906
877
 
907
878
  if (this.allowMultiple) {
908
879
  const hints = [
909
- literalHint(theme, "↑↓", "navigate"), literalHint(theme, "space", "toggle"), commentHint, promptScrollHint, overlayHint,
880
+ literalHint(theme, "↑↓", "navigate"), literalHint(theme, "space", "toggle"), commentHint, promptScrollHint,
910
881
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
911
882
  keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
912
883
  ].filter((hint): hint is string => !!hint).join(" • ");
@@ -916,7 +887,7 @@ class AskComponent extends Container {
916
887
  const hints = [
917
888
  literalHint(theme, "type", "filter"), commentHint, promptScrollHint,
918
889
  keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
919
- literalHint(theme, "↑↓", "navigate"), overlayHint,
890
+ literalHint(theme, "↑↓", "navigate"),
920
891
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
921
892
  literalHint(theme, "esc", "clear/cancel"),
922
893
  alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
@@ -1028,7 +999,7 @@ class AskComponent extends Container {
1028
999
  }
1029
1000
 
1030
1001
  private setPromptScrollOffset(nextOffset: number): boolean {
1031
- if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
1002
+ if (this.promptMaxScrollOffset <= 0) return false;
1032
1003
  const clamped = Math.max(0, Math.min(Math.floor(nextOffset), this.promptMaxScrollOffset));
1033
1004
  const changed = clamped !== this.promptScrollOffset;
1034
1005
  this.promptScrollOffset = clamped;
@@ -1036,7 +1007,7 @@ class AskComponent extends Container {
1036
1007
  }
1037
1008
 
1038
1009
  private handlePromptScrollInput(data: string): boolean {
1039
- if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
1010
+ if (this.promptMaxScrollOffset <= 0) return false;
1040
1011
  if (this.mode !== "select") return false;
1041
1012
  const pageRows = Math.max(1, this.promptViewportRows - 1);
1042
1013
  const halfPageRows = Math.max(1, Math.floor(this.promptViewportRows / 2));
@@ -1065,7 +1036,7 @@ class AskComponent extends Container {
1065
1036
  }
1066
1037
  }
1067
1038
 
1068
- /** RPC/headless fallback: ctx.ui.custom() returns undefined outside a real TUI, so degrade to dialog methods (select/input). */
1039
+ /** Plain dialog fallback (select/input) for a UI mode without setEditorComponent support. */
1069
1040
  async function askViaDialogs(
1070
1041
  ui: { select: Function; input: Function },
1071
1042
  question: string,
@@ -1235,9 +1206,6 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1235
1206
  const allowMultiple = params.allowMultiple ?? false;
1236
1207
  const allowFreeform = params.allowFreeform ?? true;
1237
1208
  const allowComment = params.allowComment ?? parseBooleanPreference(process.env["PAPYRUS_DISCUSS_ALLOW_COMMENT"]) ?? false;
1238
- const envMode = process.env["PAPYRUS_DISCUSS_DISPLAY_MODE"]?.trim().toLowerCase();
1239
- const envDisplayMode: AskDisplayMode | undefined = envMode === "overlay" || envMode === "inline" || envMode === "editor" ? envMode : undefined;
1240
- const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
1241
1209
  const normalizedContext = params.context?.trim() || undefined;
1242
1210
 
1243
1211
  if (isTypingCourtesyEnabled()) ensureTypingCourtesyTracking(ctx.ui);
@@ -1247,7 +1215,7 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1247
1215
  // see isRecentlyTyping's own comment for why the common case must stay synchronous.
1248
1216
  if (isTypingCourtesyEnabled() && isRecentlyTyping()) await waitForTypingCourtesy(params);
1249
1217
  params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1250
- return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
1218
+ return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, normalizedContext);
1251
1219
  } finally {
1252
1220
  livePendingCount -= 1;
1253
1221
  }
@@ -1255,12 +1223,12 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1255
1223
 
1256
1224
  /**
1257
1225
  * Hosts an AskComponent in place of the real input editor (ctx.ui.setEditorComponent), the same
1258
- * mechanism Pi's own slash-command menu ecosystem uses to render attached to the prompt rather
1259
- * than as a floating overlay. getText() always returns the human's real in-progress draft,
1260
- * captured once before swapping in -- setEditorComponent's own swap logic reads getText() off
1261
- * the OUTGOING editor to carry a draft forward when restoring the previous one afterward; if
1262
- * this returned anything else, restoring would silently overwrite a real draft with an empty
1263
- * string. Implements EditorComponent directly rather than extending CustomEditor: CustomEditor's
1226
+ * mechanism Pi's own slash-command menu ecosystem uses. getText() always returns the human's
1227
+ * real in-progress draft, captured once before swapping in -- setEditorComponent's own swap
1228
+ * logic reads getText() off the OUTGOING editor to carry a draft forward when restoring the
1229
+ * previous one afterward; if this returned anything else, restoring would silently overwrite a
1230
+ * real draft with an empty string. Implements EditorComponent directly rather than extending
1231
+ * CustomEditor: CustomEditor's
1264
1232
  * duck-typed actionHandlers Map would otherwise get every app-level action (model switching,
1265
1233
  * clear, suspend) copied onto it by Pi's own editor-swap code, none of which this host uses or
1266
1234
  * forwards -- avoiding the inheritance sidesteps that dead weight entirely.
@@ -1304,7 +1272,7 @@ async function askViaEditorSwap(
1304
1272
  if (params.signal) params.signal.addEventListener("abort", () => finish(null), { once: true });
1305
1273
  if (params.timeout && params.timeout > 0) setTimeout(() => finish(null), params.timeout);
1306
1274
  ctx.ui.setEditorComponent((tui: TUI, _editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
1307
- const ask = new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, "inline", tui, theme, keybindings, shortcuts, finish);
1275
+ const ask = new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, tui, theme, keybindings, shortcuts, finish);
1308
1276
  return new DiscussEditorHost(ask, preservedText);
1309
1277
  });
1310
1278
  });
@@ -1317,53 +1285,17 @@ async function askQuestionBlocking(
1317
1285
  allowMultiple: boolean,
1318
1286
  allowFreeform: boolean,
1319
1287
  allowComment: boolean,
1320
- displayMode: AskDisplayMode,
1321
1288
  normalizedContext: string | undefined,
1322
1289
  ): Promise<AskAnswer | undefined> {
1323
- // A freeform-only ask (no options) still goes through the same rich AskComponent/ctx.ui.custom()
1324
- // path below, not a bare ctx.ui.input() -- otherwise it renders as a plain, contextless single
1325
- // line while every options-bearing ask gets the full bordered box, title, and markdown context.
1326
1290
  const shortcuts: ResolvedAskShortcuts = {
1327
- overlayToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_OVERLAY_TOGGLE_KEY"], DEFAULT_OVERLAY_TOGGLE_KEY),
1328
1291
  commentToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_COMMENT_TOGGLE_KEY"], DEFAULT_COMMENT_TOGGLE_KEY),
1329
1292
  };
1330
1293
 
1331
- // "editor" hosts the picker in place of the real input editor (Pi's own slash-command menu's
1332
- // mechanism) instead of a floating overlay -- degrades to "inline" if setEditorComponent isn't
1333
- // available in this UI mode (interactive-only, like onTerminalInput below).
1334
- if (displayMode === "editor" && typeof ctx.ui.setEditorComponent === "function" && typeof ctx.ui.getEditorComponent === "function" && typeof ctx.ui.getEditorText === "function") {
1294
+ // Falls to the plain dialog fallback if setEditorComponent isn't available in this UI mode.
1295
+ if (typeof ctx.ui.setEditorComponent === "function" && typeof ctx.ui.getEditorComponent === "function" && typeof ctx.ui.getEditorText === "function") {
1335
1296
  const response = await askViaEditorSwap(ctx, params, options, allowMultiple, allowFreeform, allowComment, normalizedContext, shortcuts);
1336
1297
  return response ? toAskAnswer(response) : undefined;
1337
1298
  }
1338
- const effectiveDisplayMode: AskDisplayMode = displayMode === "editor" ? "inline" : displayMode;
1339
-
1340
- let overlayHandle: OverlayHandle | undefined;
1341
- let removeOverlayInputListener: (() => void) | undefined;
1342
- let hasAnnouncedHide = false;
1343
- let response: AskResponse | null;
1344
- try {
1345
- const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
1346
- if (params.signal) params.signal.addEventListener("abort", () => done(null), { once: true });
1347
- if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
1348
- return new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, effectiveDisplayMode, tui, theme, keybindings, shortcuts, done);
1349
- };
1350
-
1351
- const overlayToggle = shortcuts.overlayToggle;
1352
- if (displayMode === "overlay" && !overlayToggle.disabled && typeof ctx.ui.onTerminalInput === "function") {
1353
- removeOverlayInputListener = ctx.ui.onTerminalInput((data) => {
1354
- if (!overlayToggle.matches(data) || !overlayHandle) return undefined;
1355
- const nextHidden = !overlayHandle.isHidden();
1356
- overlayHandle.setHidden(nextHidden);
1357
- if (nextHidden && !hasAnnouncedHide) { hasAnnouncedHide = true; ctx.ui.notify?.(`Question hidden — press ${overlayToggle.spec} to reopen`, "info"); }
1358
- return { consume: true };
1359
- });
1360
- }
1361
-
1362
- const customResult = await ctx.ui.custom<AskResponse | null>(factory, buildCustomUIOptions(effectiveDisplayMode, (handle) => { overlayHandle = handle; }));
1363
- response = customResult !== undefined ? customResult : await askViaDialogs(ctx.ui, params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, params.timeout);
1364
- } finally {
1365
- removeOverlayInputListener?.();
1366
- }
1367
-
1299
+ const response = await askViaDialogs(ctx.ui, params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, params.timeout);
1368
1300
  return response ? toAskAnswer(response) : undefined;
1369
1301
  }
@@ -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, type AskDisplayMode } from "./discuss-ask-view.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";
@@ -68,11 +68,7 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
68
68
  if (anyDescription) params.option_descriptions = descriptions;
69
69
  }
70
70
 
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> {
71
+ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
76
72
  if (!ctx.hasUI) return undefined;
77
73
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
78
74
  // The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
@@ -89,10 +85,9 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestCon
89
85
  allowMultiple: pending.pendingOptionsMode === "multi",
90
86
  onUpdate,
91
87
  signal,
92
- displayMode,
93
88
  });
94
89
  }
95
- return askQuestion(ctx, { question, subtitle, onUpdate, signal, displayMode });
90
+ return askQuestion(ctx, { question, subtitle, onUpdate, signal });
96
91
  }
97
92
 
98
93
  /**
@@ -745,7 +740,7 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
745
740
  pi.registerTool({
746
741
  name: "discuss",
747
742
  label: "Discuss",
748
- 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.",
743
+ 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. The live picker docks in the input area itself (falls back to a plain text prompt 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.",
749
744
  parameters: Type.Object({
750
745
  action: Type.String(),
751
746
  id: Type.Optional(Type.String()),
@@ -768,7 +763,6 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
768
763
  options_mode: Type.Optional(Type.String()),
769
764
  selected: Type.Optional(Type.Array(Type.String())),
770
765
  live: Type.Optional(Type.Boolean()),
771
- display_mode: Type.Optional(Type.String()),
772
766
  }),
773
767
  // Blocks other tool calls in the same assistant turn until live:true's human answer comes
774
768
  // back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
@@ -794,7 +788,7 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
794
788
  ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
795
789
  : text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
796
790
  if (params.live !== true) return fallback;
797
- const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal, parseDisplayMode(rawParams.display_mode));
791
+ const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
798
792
  if (!answer) return fallback;
799
793
  const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
800
794
  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.30.0",
3
+ "version": "0.31.0",
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/cli.ts CHANGED
@@ -1441,7 +1441,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1441
1441
  case "list": {
1442
1442
  if (id) throw new Error("tasks list accepts no positional arguments");
1443
1443
  const rows = await client.call<Record<string, unknown>, CliArtifact[]>("tasks.list", {
1444
- status, text, limit, project_root: projectRoot, scope: listScope, root_task_id: rootTaskId, ...sessionScope,
1444
+ status, text, limit, labels, project_root: projectRoot, scope: listScope, root_task_id: rootTaskId, ...sessionScope,
1445
1445
  });
1446
1446
  result = rows;
1447
1447
  human = rows.length === 0 ? "No tasks found." : rows.map((row) => artifactLabel(row)).join("\n");
@@ -1571,7 +1571,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1571
1571
  const graph = await client.call<Record<string, unknown>, {
1572
1572
  nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
1573
1573
  rootIds: string[];
1574
- }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1, project_root: projectRoot, ...sessionScope });
1574
+ }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1, labels, project_root: projectRoot, scope: listScope, root_task_id: rootTaskId, ...sessionScope });
1575
1575
  result = graph;
1576
1576
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
1577
1577
  const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
package/src/db.ts CHANGED
@@ -21,9 +21,8 @@ interface SqliteBackendModule {
21
21
  }
22
22
 
23
23
  const backend = require_(IS_BUN ? "bun:sqlite" : "node:sqlite") as SqliteBackendModule;
24
- // IIFE + explicit return type, not a bare `const DatabaseCtor = backend.DatabaseSync ?? backend.Database`
25
- // with a following throw-guard: that guard's narrowing wouldn't propagate into openDb() below, a
26
- // separate function closing over this module-level binding.
24
+ // Explicit return type + IIFE: TS narrowing from a plain guard here wouldn't propagate into
25
+ // openDb() below, a separate function closing over this module-level binding.
27
26
  const DatabaseCtor: new (path: string, opts?: { create?: boolean }) => Db = (() => {
28
27
  const ctor = backend.DatabaseSync ?? backend.Database;
29
28
  if (!ctor) throw new Error("no compatible sqlite backend found (expected bun:sqlite's Database or node:sqlite's DatabaseSync)");
@@ -74,6 +74,7 @@ const taskFilter = (input: OperationInput) => ({
74
74
  scope: optionalString(input, "scope") as TaskViewMode | undefined,
75
75
  rootTaskId: optionalString(input, "root_task_id"),
76
76
  sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
77
+ labels: optionalStringArray(input, "labels"),
77
78
  });
78
79
 
79
80
  /**
package/src/service.ts CHANGED
@@ -189,9 +189,8 @@ export interface PapyrusService {
189
189
  }
190
190
 
191
191
  function handlers(
192
- // The composition root's own handler table -- the one place that genuinely needs trash
193
- // lifecycle and event-log reading alongside core CRUD/graph, unlike every domain-service
194
- // module (which only ever depends on the narrower ArtifactStore).
192
+ // The composition root's own handler table needs trash lifecycle and event-log reading
193
+ // alongside core CRUD/graph.
195
194
  artifacts: ArtifactStore & ArtifactTrashStore & ArtifactEventReader,
196
195
  gates: GateRunner,
197
196
  tasks: Tasks,
@@ -38,6 +38,8 @@ export interface TaskFilter {
38
38
  rootTaskId?: string;
39
39
  /** Requesting agent session id — scopes Task Focus reads so concurrent agents see only their own Focus. Defaults to a shared "global" scope when omitted. */
40
40
  sessionId?: string;
41
+ /** AND semantics: a task must carry every requested label, matching ArtifactStore.query's own labels filter. */
42
+ labels?: string[];
41
43
  }
42
44
 
43
45
  export type TaskStatus = TaskLifecycleStatus;
@@ -225,17 +227,19 @@ export class Tasks {
225
227
  throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
226
228
  }
227
229
  if (selection.mode === "all") {
228
- return this.artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, status: filter.status, text: filter.text, limit });
230
+ return this.artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, status: filter.status, text: filter.text, labels: filter.labels, limit });
229
231
  }
230
232
  const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
231
233
  if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
232
234
  const selectedIds = selection.mode === "graph" ? this.descendantIds(selection.rootTaskId!, ids) : new Set(ids);
233
235
  const text = filter.text?.toLowerCase();
236
+ const labels = filter.labels ?? [];
234
237
  return [...selectedIds]
235
238
  .map((id) => this.artifacts.get(id))
236
239
  .filter((task): task is Artifact => task?.kind === "task" && !isDiscussionArtifact(task))
237
240
  .filter((task) => filter.status === undefined || task.status === filter.status)
238
241
  .filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
242
+ .filter((task) => labels.every((label) => task.labels.includes(label)))
239
243
  .sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
240
244
  .slice(0, limit);
241
245
  }