@danypops/papyrus 0.30.1 → 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.
- package/extension/src/discuss-ask-view.ts +36 -104
- package/extension/src/domain-tools.ts +5 -11
- package/package.json +1 -1
- package/src/db.ts +2 -3
- package/src/service.ts +2 -3
|
@@ -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,
|
|
5
|
-
* and an auto-dismiss timeout. Owned end-to-end by Papyrus/Discuss -- no runtime
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
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
|
|
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 *
|
|
261
|
-
const minimumRows = Math.min(
|
|
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
|
-
|
|
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
|
|
663
|
+
private getAskMaxRenderLines(): number {
|
|
682
664
|
const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
|
|
683
|
-
return
|
|
665
|
+
return getAskMaxRenderLinesForRows(rows);
|
|
684
666
|
}
|
|
685
667
|
|
|
686
|
-
private
|
|
687
|
-
const maxLines = this.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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"),
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
1259
|
-
*
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
1332
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
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
|
-
//
|
|
25
|
-
//
|
|
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)");
|
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
|
|
193
|
-
//
|
|
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,
|