@monotykamary/pi-localterm 0.2.0 → 0.3.1
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/extensions/agent-notify.ts +7 -3
- package/extensions/index.ts +2 -2
- package/extensions/kitty-images.ts +8 -12
- package/package.json +6 -6
- package/src/constants.ts +8 -0
- package/src/utils/agent-notify-body.ts +50 -4
- package/src/utils/collapse-whitespace.ts +10 -0
- package/src/utils/osc-sequence.ts +2 -2
|
@@ -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", (
|
|
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(
|
|
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/extensions/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { registerAgentNotify } from "./agent-notify.js";
|
|
3
3
|
import { registerBashSecretScrub } from "./bash-secret-scrub.js";
|
|
4
|
-
import {
|
|
4
|
+
import { enableKittyImages } from "./kitty-images.js";
|
|
5
5
|
|
|
6
6
|
// localterm <-> pi integration, inert outside localterm. LOCALTERM=1 is
|
|
7
7
|
// injected into every localterm PTY; when it's absent, nothing is registered
|
|
@@ -15,7 +15,7 @@ import { registerKittyImages } from "./kitty-images.js";
|
|
|
15
15
|
// finished.
|
|
16
16
|
export default (pi: ExtensionAPI): void => {
|
|
17
17
|
if (process.env.LOCALTERM !== "1") return;
|
|
18
|
-
|
|
18
|
+
enableKittyImages();
|
|
19
19
|
registerBashSecretScrub(pi);
|
|
20
20
|
registerAgentNotify(pi);
|
|
21
21
|
};
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { getCapabilities, setCapabilities } from "@earendil-works/pi-tui";
|
|
3
2
|
|
|
4
3
|
// localterm renders xterm.js with the Kitty graphics + OSC 8 hyperlink addons
|
|
5
4
|
// loaded, but sets TERM=xterm-256color and strips terminal-identity env vars so
|
|
6
5
|
// Ink TUIs don't probe for a protocol xterm.js lacks. pi-tui therefore reports
|
|
7
|
-
// images/hyperlinks as unsupported.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (capabilities.images) return;
|
|
16
|
-
setCapabilities({ ...capabilities, images: "kitty", hyperlinks: true });
|
|
17
|
-
});
|
|
6
|
+
// images/hyperlinks as unsupported. Enable them while the extension factory is
|
|
7
|
+
// loading, before TUI.start() checks image support and sends its CSI 16 t cell-
|
|
8
|
+
// metrics query. Waiting for session_start is too late: the first query is then
|
|
9
|
+
// skipped and image sizing keeps pi-tui's fallback cell dimensions.
|
|
10
|
+
export const enableKittyImages = (): void => {
|
|
11
|
+
const capabilities = getCapabilities();
|
|
12
|
+
if (capabilities.images === "kitty" && capabilities.hyperlinks) return;
|
|
13
|
+
setCapabilities({ ...capabilities, images: "kitty", hyperlinks: true });
|
|
18
14
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-localterm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@earendil-works/pi-coding-agent": "^0.80.
|
|
33
|
-
"@earendil-works/pi-tui": "^0.80.
|
|
34
|
-
"@types/node": "^26.1.
|
|
35
|
-
"typescript": "^
|
|
36
|
-
"vite-plus": "^0.2.
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.80.6",
|
|
33
|
+
"@earendil-works/pi-tui": "^0.80.6",
|
|
34
|
+
"@types/node": "^26.1.1",
|
|
35
|
+
"typescript": "^7.0.2",
|
|
36
|
+
"vite-plus": "^0.2.4"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "*",
|
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
|
|
17
|
-
//
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
30
|
+
const cleaned = collapseWhitespace(sanitized);
|
|
31
31
|
const capped = cleaned.slice(0, maxLength);
|
|
32
32
|
return `${OSC9_PREFIX}${capped}${OSC9_BEL}`;
|
|
33
33
|
};
|