@danypops/papyrus 0.27.13 → 0.27.15

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.
@@ -56,6 +56,9 @@ export type AskDisplayMode = "overlay" | "inline";
56
56
  export interface AskQuestionParams {
57
57
  question: string;
58
58
  context?: string;
59
+ /** Plain orientation line ("which discussion is this"), shown dim above the question -- not a
60
+ * labeled section like context. Typically the Discussion's own title. */
61
+ subtitle?: string;
59
62
  options?: AskOption[];
60
63
  allowMultiple?: boolean;
61
64
  allowFreeform?: boolean;
@@ -621,6 +624,7 @@ class AskComponent extends Container {
621
624
  constructor(
622
625
  private question: string,
623
626
  private context: string | undefined,
627
+ private subtitle: string | undefined,
624
628
  private options: AskOption[],
625
629
  private allowMultiple: boolean,
626
630
  private allowFreeform: boolean,
@@ -658,7 +662,10 @@ class AskComponent extends Container {
658
662
  this.addChild(new BoxBorderBottom((s) => theme.fg("accent", s)));
659
663
 
660
664
  this.updateStaticText();
661
- this.showSelectMode();
665
+ // A freeform-only ask (no options at all) has no select list to show -- start directly in
666
+ // the freeform editor instead of a select mode that would have nothing to render.
667
+ if (this.options.length === 0) this.showFreeformMode();
668
+ else this.showSelectMode();
662
669
  }
663
670
 
664
671
  override invalidate(): void { super.invalidate(); this.updateStaticText(); this.updateHelpText(); }
@@ -778,6 +785,9 @@ class AskComponent extends Container {
778
785
  "",
779
786
  ];
780
787
  }
788
+ // Only meaningful when reached by escaping OUT of a real select list -- see showFreeformMode's
789
+ // identical guard for the non-overlay layout.
790
+ if (this.options.length === 0) return [];
781
791
  return [...new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0).render(width), ""];
782
792
  }
783
793
 
@@ -861,7 +871,11 @@ class AskComponent extends Container {
861
871
 
862
872
  private updateStaticText(): void {
863
873
  const theme = this.theme;
864
- this.titleText.setText(theme.fg("accent", theme.bold(this.mode === "comment" ? "Optional comment" : "Question")));
874
+ // Reuses the same slot for two different purposes: a plain "which discussion is this" subtitle
875
+ // normally, or "Optional comment" while in comment mode. A generic "Question" header above the
876
+ // real question text added nothing beyond what the question itself already says, and read
877
+ // confusingly like the question text WAS the header.
878
+ this.titleText.setText(this.mode === "comment" ? theme.fg("accent", theme.bold("Optional comment")) : this.subtitle ? theme.fg("dim", this.subtitle) : "");
865
879
  this.questionText.setText(theme.fg("text", theme.bold(this.question)));
866
880
  if (this.contextComponent && this.context) {
867
881
  if (this.contextComponent instanceof Markdown) (this.contextComponent as Markdown).setText(`**Context:**\n${this.context}`);
@@ -877,12 +891,13 @@ class AskComponent extends Container {
877
891
 
878
892
  if (this.mode === "freeform" || this.mode === "comment") {
879
893
  const alternateCancelKeys = this.keybindings.getKeys("tui.select.cancel").filter((key) => key !== "escape" && key !== "esc");
894
+ const canGoBack = this.options.length > 0;
880
895
  const hints = [
881
896
  keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
882
897
  keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
883
- literalHint(theme, "esc", "back"),
898
+ literalHint(theme, "esc", canGoBack ? "back" : "cancel"),
884
899
  overlayHint,
885
- alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
900
+ canGoBack && alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
886
901
  ].filter((hint): hint is string => !!hint).join(" • ");
887
902
  this.helpText.setText(theme.fg("dim", hints));
888
903
  return;
@@ -981,8 +996,13 @@ class AskComponent extends Container {
981
996
  const editor = this.ensureEditor();
982
997
  this.setEditorText(this.freeformDraft);
983
998
  (editor as any).focused = this._focused;
984
- this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0));
985
- this.modeContainer.addChild(new Spacer(1));
999
+ // Only meaningful when reached by escaping OUT of a real select list ("instead of these
1000
+ // options, here's a custom one") -- with no options at all there's nothing to contrast
1001
+ // against, so the label is pure noise.
1002
+ if (this.options.length > 0) {
1003
+ this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0));
1004
+ this.modeContainer.addChild(new Spacer(1));
1005
+ }
986
1006
  this.modeContainer.addChild(editor);
987
1007
  this.updateHelpText();
988
1008
  this.invalidate();
@@ -1031,7 +1051,8 @@ class AskComponent extends Container {
1031
1051
  handleInput(data: string): void {
1032
1052
  if (this.handlePromptScrollInput(data)) { this.tui.requestRender(); return; }
1033
1053
  if (this.mode === "freeform" || this.mode === "comment") {
1034
- if (matchesKey(data, Key.escape)) { this.showSelectMode(); return; }
1054
+ // A freeform-only ask has no select mode to go back to -- escape cancels outright.
1055
+ if (matchesKey(data, Key.escape)) { if (this.options.length > 0) this.showSelectMode(); else this.onDone(null); return; }
1035
1056
  if (this.keybindings.matches(data, "tui.select.cancel")) { this.onDone(null); return; }
1036
1057
  this.ensureEditor().handleInput(data);
1037
1058
  this.tui.requestRender();
@@ -1057,6 +1078,11 @@ async function askViaDialogs(
1057
1078
  const dialogOpts = timeout ? { timeout } : undefined;
1058
1079
  const prompt = context ? `${question}\n\nContext:\n${context}` : question;
1059
1080
 
1081
+ if (options.length === 0) {
1082
+ const answer = (await ui.input(prompt, "Type your answer...", dialogOpts)) as string | undefined;
1083
+ return isCancelledInput(answer) ? null : createFreeformResponse(answer);
1084
+ }
1085
+
1060
1086
  if (allowMultiple) {
1061
1087
  const rawSelections = (await ui.input(`${prompt}\n\nOptions (select one or more):\n${formatOptionsForMessage(options)}`, "Type your selection(s)...", dialogOpts)) as string | undefined;
1062
1088
  if (isCancelledInput(rawSelections)) return null;
@@ -1139,13 +1165,9 @@ async function askQuestionBlocking(
1139
1165
  displayMode: AskDisplayMode,
1140
1166
  normalizedContext: string | undefined,
1141
1167
  ): Promise<AskAnswer | undefined> {
1142
- if (options.length === 0) {
1143
- const prompt = normalizedContext ? `${params.question}\n\nContext:\n${normalizedContext}` : params.question;
1144
- const answer = await ctx.ui.input(prompt, "Type your answer...", params.timeout ? { timeout: params.timeout } : undefined);
1145
- const response = createFreeformResponse(answer);
1146
- return response ? toAskAnswer(response) : undefined;
1147
- }
1148
-
1168
+ // A freeform-only ask (no options) still goes through the same rich AskComponent/ctx.ui.custom()
1169
+ // path below, not a bare ctx.ui.input() -- otherwise it renders as a plain, contextless single
1170
+ // line while every options-bearing ask gets the full bordered box, title, and markdown context.
1149
1171
  const shortcuts: ResolvedAskShortcuts = {
1150
1172
  overlayToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_OVERLAY_TOGGLE_KEY"], DEFAULT_OVERLAY_TOGGLE_KEY),
1151
1173
  commentToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_COMMENT_TOGGLE_KEY"], DEFAULT_COMMENT_TOGGLE_KEY),
@@ -1159,7 +1181,7 @@ async function askQuestionBlocking(
1159
1181
  const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
1160
1182
  if (params.signal) params.signal.addEventListener("abort", () => done(null), { once: true });
1161
1183
  if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
1162
- return new AskComponent(params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1184
+ return new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1163
1185
  };
1164
1186
 
1165
1187
  const overlayToggle = shortcuts.overlayToggle;
@@ -82,14 +82,15 @@ 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
- const question = `Reply to "${discussion.title}":`;
86
- // Same fix as the live discuss tool: the title alone isn't the actual question -- show
87
- // the most recent round's real content as context, not a bare title prompt.
85
+ // Same fix as the live discuss tool: the most recent round's own content IS the real
86
+ // question -- the title becomes a plain orientation subtitle, not a labeled-backwards
87
+ // "Context:" section under a generic "Reply to <title>:" wrapper.
88
88
  const transcript = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
89
- const context = transcript.rounds.at(-1)?.content?.trim() || undefined;
89
+ const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
90
+ const subtitle = discussion.title;
90
91
  const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
91
- ? await askQuestion(commandCtx, { question, context, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
92
- : await askQuestion(commandCtx, { question, context });
92
+ ? await askQuestion(commandCtx, { question, subtitle, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
93
+ : await askQuestion(commandCtx, { question, subtitle });
93
94
  if (!answer) return; // canceled
94
95
  await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
95
96
  commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
@@ -42,22 +42,23 @@ function text(message: string, details: unknown = {}) {
42
42
  async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
43
43
  if (!ctx.hasUI) return undefined;
44
44
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
45
- const question = `Reply to "${discussion.title}":`;
46
- // The discussion's title alone is often not the actual question -- a human staring at a bare
47
- // "Reply to '<title>':" prompt with no visible content has no way to tell what's being asked.
48
- // The just-recorded round's own content is the real question text; show it as context.
49
- const context = latestContent?.trim() || undefined;
45
+ // The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
46
+ // wrapper as the primary question, with the real content demoted to "Context:", left a human
47
+ // staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
48
+ // the degenerate case of empty content; the title becomes a plain orientation subtitle instead.
49
+ const question = latestContent?.trim() || `Reply to "${discussion.title}":`;
50
+ const subtitle = discussion.title;
50
51
  if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
51
52
  return askQuestion(ctx, {
52
53
  question,
53
- context,
54
+ subtitle,
54
55
  options: pending.pendingOptions.map((title) => ({ title })),
55
56
  allowMultiple: pending.pendingOptionsMode === "multi",
56
57
  onUpdate,
57
58
  signal,
58
59
  });
59
60
  }
60
- return askQuestion(ctx, { question, context, onUpdate, signal });
61
+ return askQuestion(ctx, { question, subtitle, onUpdate, signal });
61
62
  }
62
63
 
63
64
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.13",
3
+ "version": "0.27.15",
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"],