@monotykamary/pi-localterm 0.2.0 → 0.3.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,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { AGENT_NOTIFY_MIN_ELAPSED_MS } from "../src/constants.js";
3
- import { formatAgentEndBody } from "../src/utils/agent-notify-body.js";
3
+ import { extractAssistantExcerpt, formatAgentEndBody } from "../src/utils/agent-notify-body.js";
4
4
  import { buildOsc9Sequence } from "../src/utils/osc-sequence.js";
5
5
 
6
6
  // pi's only notification primitive is ctx.ui.notify — an in-TUI banner that's
@@ -26,11 +26,15 @@ export const registerAgentNotify = (pi: ExtensionAPI): void => {
26
26
  turnStartedAt = Date.now();
27
27
  });
28
28
 
29
- pi.on("agent_end", (_event, ctx) => {
29
+ pi.on("agent_end", (event, ctx) => {
30
30
  if (ctx.mode !== "tui" || turnStartedAt === undefined) return;
31
31
  const elapsedMs = Date.now() - turnStartedAt;
32
32
  if (elapsedMs < AGENT_NOTIFY_MIN_ELAPSED_MS) return;
33
- const body = formatAgentEndBody(elapsedMs, pi.getSessionName());
33
+ const body = formatAgentEndBody(
34
+ elapsedMs,
35
+ pi.getSessionName(),
36
+ extractAssistantExcerpt(event.messages),
37
+ );
34
38
  process.stdout.write(buildOsc9Sequence(body));
35
39
  });
36
40
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-localterm",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "localterm <-> pi integration: Kitty graphics for the browser renderer, scrubbing localterm-managed secret env vars from the agent's bash-tool children, and OSC 9 desktop notifications when the agent finishes a long turn.",
5
5
  "keywords": [
6
6
  "localterm",
package/src/constants.ts CHANGED
@@ -34,3 +34,11 @@ export const NOTIFICATION_MAX_LENGTH = 1024;
34
34
  // so quick back-and-forth turns don't spam a user actively watching the pi
35
35
  // tab. Tunable: lower to notify sooner, raise to stay quieter while focused.
36
36
  export const AGENT_NOTIFY_MIN_ELAPSED_MS = 30_000;
37
+
38
+ // Cap on the assistant-response excerpt carried by the "pi finished"
39
+ // notification body, counted in UTF-16 code units after whitespace is
40
+ // collapsed. Long enough to surface a sentence or two of the agent's final
41
+ // answer so a user who stepped away can tell what concluded; short enough to
42
+ // stay readable in an OS banner (which truncates visually well before the
43
+ // daemon's 1024-unit OSC cap that NOTIFICATION_MAX_LENGTH mirrors).
44
+ export const AGENT_NOTIFY_EXCERPT_MAX_CHARS = 160;
@@ -1,3 +1,9 @@
1
+ import type { AgentEndEvent } from "@earendil-works/pi-coding-agent";
2
+ import { AGENT_NOTIFY_EXCERPT_MAX_CHARS } from "../constants.js";
3
+ import { collapseWhitespace } from "./collapse-whitespace.js";
4
+
5
+ const ELLIPSIS = "…";
6
+
1
7
  const formatElapsedSeconds = (elapsedMs: number): string => {
2
8
  const totalSeconds = elapsedMs / 1000;
3
9
  if (totalSeconds < 60) {
@@ -11,11 +17,51 @@ const formatElapsedSeconds = (elapsedMs: number): string => {
11
17
  return `${minutes}m ${seconds}s`;
12
18
  };
13
19
 
20
+ const truncateWithEllipsis = (text: string, maxChars: number): string =>
21
+ text.length <= maxChars ? text : `${text.slice(0, maxChars - ELLIPSIS.length)}${ELLIPSIS}`;
22
+
23
+ // Pull a preview of the agent's final answer out of an agent_end's messages:
24
+ // the last assistant message that produced visible text, whitespace-collapsed
25
+ // and capped to AGENT_NOTIFY_EXCERPT_MAX_CHARS with an ellipsis. An assistant
26
+ // message is a stream of (TextContent | ThinkingContent | ToolCall) blocks;
27
+ // only TextContent is prose the user would recognize as the response, so
28
+ // thinking (internal reasoning) and tool calls are skipped. Scans backward
29
+ // from the newest message so a turn that ended on a bare tool call (no
30
+ // trailing prose) still surfaces the most recent text the agent actually said.
31
+ // Returns undefined when no assistant message carried text — e.g. the turn
32
+ // was aborted mid-tool-use — so the caller can fall back to identity + elapsed
33
+ // alone. Pure: unit-testable without a session.
34
+ export const extractAssistantExcerpt = (
35
+ messages: AgentEndEvent["messages"],
36
+ ): string | undefined => {
37
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
38
+ const message = messages[index];
39
+ if (message.role !== "assistant") continue;
40
+ const messageText = message.content
41
+ .map((part) => (part.type === "text" ? part.text : ""))
42
+ .join(" ");
43
+ const collapsed = collapseWhitespace(messageText);
44
+ if (collapsed) return truncateWithEllipsis(collapsed, AGENT_NOTIFY_EXCERPT_MAX_CHARS);
45
+ }
46
+ return undefined;
47
+ };
48
+
14
49
  // Compose the OSC 9 notification body for a finished agent turn: identity +
15
50
  // elapsed time. The notification's arrival already signals "finished", so the
16
- // text carries only who (the pi session name when set, so multiple sessions
17
- // are distinguishable) and how long. Pure: unit-testable without a session.
18
- export const formatAgentEndBody = (elapsedMs: number, sessionName?: string): string => {
51
+ // text carries who (the pi session name when set, so multiple sessions are
52
+ // distinguishable), what (a truncated excerpt of the final answer when the
53
+ // turn produced one), and how long. The excerpt follows the session name,
54
+ // separated by an em dash, so a short label always precedes the prose. With
55
+ // neither, the colon is dropped to avoid a dangling "pi finished: (1.2s)".
56
+ // Pure: unit-testable without a session.
57
+ export const formatAgentEndBody = (
58
+ elapsedMs: number,
59
+ sessionName?: string,
60
+ excerpt?: string,
61
+ ): string => {
19
62
  const elapsed = formatElapsedSeconds(elapsedMs);
20
- return sessionName ? `pi finished: ${sessionName} (${elapsed})` : `pi finished (${elapsed})`;
63
+ const identity = [sessionName, excerpt]
64
+ .filter((part): part is string => Boolean(part))
65
+ .join(" — ");
66
+ return identity ? `pi finished: ${identity} (${elapsed})` : `pi finished (${elapsed})`;
21
67
  };
@@ -0,0 +1,10 @@
1
+ const WHITESPACE_RUN = /\s+/g;
2
+
3
+ // Collapse every run of whitespace (spaces, tabs, newlines) to a single space
4
+ // and strip the leading/trailing edges. The OSC 9 body sanitizer uses this to
5
+ // keep a notification on one visual line, and the agent-end excerpt uses it so
6
+ // truncation is measured against the final visible text rather than raw
7
+ // markdown, which may carry indentation and blank lines that would otherwise
8
+ // eat the character budget before any prose shows. Pure.
9
+ export const collapseWhitespace = (text: string): string =>
10
+ text.replace(WHITESPACE_RUN, " ").trim();
@@ -1,8 +1,8 @@
1
1
  import { NOTIFICATION_MAX_LENGTH } from "../constants.js";
2
+ import { collapseWhitespace } from "./collapse-whitespace.js";
2
3
 
3
4
  const OSC9_PREFIX = "\x1b]9;";
4
5
  const OSC9_BEL = "\x07";
5
- const WHITESPACE_RUN = /\s+/g;
6
6
 
7
7
  // A BEL in the body would terminate the OSC sequence early and leak the rest
8
8
  // as visible garbage; an ESC would let the body forge an ST terminator. Other
@@ -27,7 +27,7 @@ export const buildOsc9Sequence = (
27
27
  maxLength: number = NOTIFICATION_MAX_LENGTH,
28
28
  ): string => {
29
29
  const sanitized = Array.from(body, (char) => (isControlOrDel(char) ? " " : char)).join("");
30
- const cleaned = sanitized.replace(WHITESPACE_RUN, " ").trim();
30
+ const cleaned = collapseWhitespace(sanitized);
31
31
  const capped = cleaned.slice(0, maxLength);
32
32
  return `${OSC9_PREFIX}${capped}${OSC9_BEL}`;
33
33
  };