@danypops/papyrus 0.29.1 → 0.29.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.
@@ -1126,6 +1126,99 @@ export function isLiveAskPending(): boolean {
1126
1126
  return livePendingCount > 0;
1127
1127
  }
1128
1128
 
1129
+ const DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS = 100;
1130
+ const DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS = 1_500;
1131
+ const DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS = 300;
1132
+ const DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS = 10_000;
1133
+
1134
+ let typingCourtesyPollMs = DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS;
1135
+ let typingCourtesyInitialQuietMs = DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS;
1136
+ let typingCourtesyQuietFloorMs = DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS;
1137
+ let typingCourtesyDecayHorizonMs = DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS;
1138
+
1139
+ /** Test-only: the real decay curve runs over seconds, too slow to exercise at its real scale in a unit test. */
1140
+ export function setTypingCourtesyTimingForTests(overrides?: { pollMs?: number; initialQuietMs?: number; floorMs?: number; decayHorizonMs?: number }): void {
1141
+ typingCourtesyPollMs = overrides?.pollMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS;
1142
+ typingCourtesyInitialQuietMs = overrides?.initialQuietMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS;
1143
+ typingCourtesyQuietFloorMs = overrides?.floorMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS;
1144
+ typingCourtesyDecayHorizonMs = overrides?.decayHorizonMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS;
1145
+ }
1146
+
1147
+ function isTypingCourtesyEnabled(): boolean {
1148
+ return parseBooleanPreference(process.env["PAPYRUS_DISCUSS_TYPING_COURTESY"]) ?? true;
1149
+ }
1150
+
1151
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1152
+ return new Promise((resolve) => {
1153
+ if (signal?.aborted) { resolve(); return; }
1154
+ const timer = setTimeout(resolve, ms);
1155
+ signal?.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
1156
+ });
1157
+ }
1158
+
1159
+ /**
1160
+ * Required quiet gap (no keystroke) before a live ask may open, as a function of how long we've
1161
+ * already been waiting. Starts wide (a natural inter-word pause shouldn't count as "done typing")
1162
+ * and decays toward a floor -- someone typing continuously gets pickier treatment over time
1163
+ * rather than never being asked. No outer cap: someone typing with sub-floor gaps forever waits
1164
+ * forever, same as the picker itself already waits indefinitely for a real human answer once open.
1165
+ */
1166
+ function requiredQuietMsAt(elapsedMs: number): number {
1167
+ const t = Math.min(1, Math.max(0, elapsedMs / typingCourtesyDecayHorizonMs));
1168
+ return typingCourtesyInitialQuietMs - t * (typingCourtesyInitialQuietMs - typingCourtesyQuietFloorMs);
1169
+ }
1170
+
1171
+ /**
1172
+ * Ambient, session-lifetime keystroke clock -- deliberately NOT scoped per-ask. A per-ask listener
1173
+ * would only see keystrokes from the moment the tool call happens to start, missing typing already
1174
+ * in progress when it began (the exact case this feature exists to protect). Attached once per
1175
+ * distinct ui instance (reference equality; a session's real ui object is stable for its lifetime)
1176
+ * and left attached -- there is no unregister, matching onTerminalInput's own listener-return-value
1177
+ * contract elsewhere in this file.
1178
+ */
1179
+ let lastKeystrokeAt = 0;
1180
+ let trackedUi: ExtensionContext["ui"] | undefined;
1181
+
1182
+ export function ensureTypingCourtesyTracking(ui: ExtensionContext["ui"]): void {
1183
+ if (typeof ui.onTerminalInput !== "function" || trackedUi === ui) return;
1184
+ trackedUi = ui;
1185
+ ui.onTerminalInput(() => { lastKeystrokeAt = Date.now(); return undefined; });
1186
+ }
1187
+
1188
+ /** Test-only: clears the ambient keystroke clock so one test's simulated typing can't bleed into another's. */
1189
+ export function resetTypingCourtesyTrackingForTests(): void {
1190
+ lastKeystrokeAt = 0;
1191
+ trackedUi = undefined;
1192
+ }
1193
+
1194
+ /**
1195
+ * Whether there is real, recent typing activity to wait out right now -- a plain synchronous read
1196
+ * of the ambient keystroke clock so the common case (nobody typing) never forces the caller
1197
+ * through an extra microtask. Deliberately not folded into waitForTypingCourtesy itself: an
1198
+ * unconditional `await` there -- even one that resolves immediately -- still yields once, which is
1199
+ * enough to let a signal aborted synchronously right after invoking askQuestion race past the
1200
+ * abort listener registered deeper in askQuestionBlocking and get missed entirely.
1201
+ */
1202
+ export function isRecentlyTyping(): boolean {
1203
+ return lastKeystrokeAt > 0 && Date.now() - lastKeystrokeAt < typingCourtesyInitialQuietMs;
1204
+ }
1205
+
1206
+ /**
1207
+ * Waits out real keystroke activity (not editor text content -- that can't distinguish "actively
1208
+ * typing" from "a stale draft sitting there", and misses a mid-thought erase-and-resume) before
1209
+ * popping the live ask over it. Only call when isRecentlyTyping() is already true.
1210
+ */
1211
+ export async function waitForTypingCourtesy(params: Pick<AskQuestionParams, "onUpdate" | "signal">): Promise<void> {
1212
+ const startedAt = Date.now();
1213
+ let announced = false;
1214
+ while (lastKeystrokeAt > 0 && !params.signal?.aborted) {
1215
+ const elapsed = Date.now() - startedAt;
1216
+ if (Date.now() - lastKeystrokeAt >= requiredQuietMsAt(elapsed)) return;
1217
+ if (!announced) { announced = true; params.onUpdate?.({ content: [{ type: "text", text: "Waiting for you to finish typing before asking..." }], details: undefined }); }
1218
+ await sleep(typingCourtesyPollMs, params.signal);
1219
+ }
1220
+ }
1221
+
1129
1222
  /**
1130
1223
  * Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
1131
1224
  * dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
@@ -1147,9 +1240,13 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1147
1240
  const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
1148
1241
  const normalizedContext = params.context?.trim() || undefined;
1149
1242
 
1150
- params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1243
+ if (isTypingCourtesyEnabled()) ensureTypingCourtesyTracking(ctx.ui);
1151
1244
  livePendingCount += 1;
1152
1245
  try {
1246
+ // Only actually awaits (yielding a microtask) when there's real typing activity to wait out --
1247
+ // see isRecentlyTyping's own comment for why the common case must stay synchronous.
1248
+ if (isTypingCourtesyEnabled() && isRecentlyTyping()) await waitForTypingCourtesy(params);
1249
+ params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1153
1250
  return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
1154
1251
  } finally {
1155
1252
  livePendingCount -= 1;
@@ -22,7 +22,7 @@ import type { GateResult } from "../../src/domain/gate.ts";
22
22
  import { formatMetadata } from "./artifact-format.ts";
23
23
  import { callService } from "./service-client.ts";
24
24
  import { registerDomainTools } from "./domain-tools.ts";
25
- import { isLiveAskPending } from "./discuss-ask-view.ts";
25
+ import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
26
26
  import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
27
27
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
28
28
  import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
@@ -566,6 +566,10 @@ export default async function (pi: ExtensionAPI) {
566
566
  // intentionally silent -- see comment above
567
567
  }
568
568
  if (!ctx.hasUI) return;
569
+ // Attached from session start, not lazily on first ask -- a per-ask listener would only see
570
+ // keystrokes from the moment that tool call happens to begin, missing typing already in
571
+ // progress when it started (the exact case Discuss's typing-courtesy wait protects against).
572
+ ensureTypingCourtesyTracking(ctx.ui);
569
573
  overlay ??= new TaskOverlay();
570
574
  overlay.setUI(ctx.ui);
571
575
  overlay.setProjectRoot(ctx.cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.29.1",
3
+ "version": "0.29.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"],