@danypops/papyrus 0.29.2 → 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,14 +1126,26 @@ export function isLiveAskPending(): boolean {
1126
1126
  return livePendingCount > 0;
1127
1127
  }
1128
1128
 
1129
- const DISCUSS_TYPING_COURTESY_POLL_MS = 400;
1130
- const DISCUSS_TYPING_COURTESY_DEFAULT_MAX_WAIT_MS = 15_000;
1131
-
1132
- function resolveTypingCourtesyMaxWaitMs(): number {
1133
- const raw = process.env["PAPYRUS_DISCUSS_TYPING_COURTESY_MS"];
1134
- if (raw === undefined) return DISCUSS_TYPING_COURTESY_DEFAULT_MAX_WAIT_MS;
1135
- const parsed = Number(raw);
1136
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : DISCUSS_TYPING_COURTESY_DEFAULT_MAX_WAIT_MS;
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;
1137
1149
  }
1138
1150
 
1139
1151
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -1145,31 +1157,65 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1145
1157
  }
1146
1158
 
1147
1159
  /**
1148
- * Whether there is a genuine editor draft to wait out right now -- a plain synchronous check so
1149
- * the common case (no draft) never forces the caller through an extra microtask. Deliberately not
1150
- * folded into waitForEditorCourtesy itself: an unconditional `await` there -- even one that
1151
- * resolves immediately -- still yields once, which is enough to let a signal aborted synchronously
1152
- * right after invoking askQuestion race past the abort listener registered deeper in
1153
- * askQuestionBlocking and get missed entirely.
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.
1154
1201
  */
1155
- export function hasEditorCourtesyDraft(ctx: ExtensionContext): boolean {
1156
- return typeof ctx.ui.getEditorText === "function" && ctx.ui.getEditorText().length > 0;
1202
+ export function isRecentlyTyping(): boolean {
1203
+ return lastKeystrokeAt > 0 && Date.now() - lastKeystrokeAt < typingCourtesyInitialQuietMs;
1157
1204
  }
1158
1205
 
1159
1206
  /**
1160
- * Waits for a non-empty editor draft to clear before popping the live ask over it. Bounded
1161
- * (PAPYRUS_DISCUSS_TYPING_COURTESY_MS, default 15s, 0 disables): a draft left sitting unattended
1162
- * must not withhold the question indefinitely, only a genuinely in-progress reply gets the
1163
- * courtesy. Only call when hasEditorCourtesyDraft(ctx) is already true.
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.
1164
1210
  */
1165
- export async function waitForEditorCourtesy(ctx: ExtensionContext, params: Pick<AskQuestionParams, "onUpdate" | "signal">): Promise<void> {
1166
- const maxWaitMs = resolveTypingCourtesyMaxWaitMs();
1167
- if (maxWaitMs <= 0) return;
1168
- const deadline = Date.now() + maxWaitMs;
1169
- params.onUpdate?.({ content: [{ type: "text", text: "Waiting for you to finish typing before asking..." }], details: undefined });
1170
- while (Date.now() < deadline && !params.signal?.aborted) {
1171
- await sleep(DISCUSS_TYPING_COURTESY_POLL_MS, params.signal);
1172
- if (typeof ctx.ui.getEditorText !== "function" || ctx.ui.getEditorText().length === 0) return;
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);
1173
1219
  }
1174
1220
  }
1175
1221
 
@@ -1194,11 +1240,12 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1194
1240
  const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
1195
1241
  const normalizedContext = params.context?.trim() || undefined;
1196
1242
 
1243
+ if (isTypingCourtesyEnabled()) ensureTypingCourtesyTracking(ctx.ui);
1197
1244
  livePendingCount += 1;
1198
1245
  try {
1199
- // Only actually awaits (yielding a microtask) when there's a real draft to wait out --
1200
- // see hasEditorCourtesyDraft's own comment for why the common case must stay synchronous.
1201
- if (hasEditorCourtesyDraft(ctx)) await waitForEditorCourtesy(ctx, params);
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);
1202
1249
  params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1203
1250
  return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
1204
1251
  } finally {
@@ -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.2",
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"],