@expitc/termad 0.1.2 → 0.1.4

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,5 +1,55 @@
1
1
  #!/usr/bin/env node
2
- import { runWithAdOverlay } from "@expitc/termad/dist/surfaces/pty-inject.js";
2
+ /**
3
+ * Launcher for the PTY overlay.
4
+ *
5
+ * Must work from any install layout:
6
+ * <pkg>/dist/assets/pty-overlay.mjs (published package, global install)
7
+ * <pkg>/src/assets/pty-overlay.mjs (running from source)
8
+ * ~/.termad/pty-overlay.mjs (written by `termad install`)
9
+ *
10
+ * Resolves pty-inject.js relative to itself first, then by package name.
11
+ * If nothing resolves, exit non-zero WITHOUT touching the CLI — the
12
+ * wrapper's own pre-banner fallback takes over and the CLI still runs.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const candidates = [
21
+ join(here, "..", "surfaces", "pty-inject.js"), // dist/assets → dist/surfaces
22
+ join(here, "surfaces", "pty-inject.js"), // src/assets → src/surfaces
23
+ ];
24
+
25
+ let runWithAdOverlay = null;
26
+
27
+ for (const c of candidates) {
28
+ if (!existsSync(c)) continue;
29
+ try {
30
+ ({ runWithAdOverlay } = await import(pathToFileURL(c).href));
31
+ break;
32
+ } catch {}
33
+ }
34
+
35
+ if (!runWithAdOverlay) {
36
+ try {
37
+ ({ runWithAdOverlay } = await import("@expitc/termad/dist/surfaces/pty-inject.js"));
38
+ } catch {}
39
+ }
40
+
41
+ if (typeof runWithAdOverlay !== "function") {
42
+ console.error("termad: pty-inject.js not found — falling back to banner mode");
43
+ process.exit(1);
44
+ }
45
+
3
46
  const binary = process.argv[2];
4
- const args = process.argv.slice(3);
5
- runWithAdOverlay(binary, args);
47
+ const surface = process.argv[3];
48
+ const args = process.argv.slice(4);
49
+
50
+ if (!binary) {
51
+ console.error("termad: usage: pty-overlay.mjs <binary> [args...]");
52
+ process.exit(1);
53
+ }
54
+
55
+ await runWithAdOverlay(binary, args, surface);
@@ -9,18 +9,18 @@
9
9
  TERMAD_AD_FILE="__TERMAD_AD__"
10
10
  TERMAD_BACKUP="__TERMAD_BACKUP__"
11
11
  TERMAD_COUNT="__TERMAD_COUNT__"
12
- TERMAD_HOME_DIR="$(dirname "$(dirname "$TERMAD_AD_FILE")")"
12
+ TERMAD_HOME_DIR="$(dirname "$TERMAD_AD_FILE")"
13
13
 
14
14
  # Try PTY overlay via node script bundled with termad
15
15
  OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
16
16
  if [ ! -f "$OVERLAY_SCRIPT" ]; then
17
17
  # Try to find in node_modules or global install
18
- OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/surfaces/pty-inject.mjs"
18
+ OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/assets/pty-overlay.mjs"
19
19
  fi
20
20
 
21
21
  if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
22
22
  export TERMAD_AD_FILE TERMAD_BACKUP TERMAD_COUNT
23
- exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "$@"
23
+ exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "__TERMAD_SURFACE__" "$@"
24
24
  fi
25
25
 
26
26
  # Fallback: pre-banner
@@ -1,5 +1,55 @@
1
1
  #!/usr/bin/env node
2
- import { runWithAdOverlay } from "@expitc/termad/dist/surfaces/pty-inject.js";
2
+ /**
3
+ * Launcher for the PTY overlay.
4
+ *
5
+ * Must work from any install layout:
6
+ * <pkg>/dist/assets/pty-overlay.mjs (published package, global install)
7
+ * <pkg>/src/assets/pty-overlay.mjs (running from source)
8
+ * ~/.termad/pty-overlay.mjs (written by `termad install`)
9
+ *
10
+ * Resolves pty-inject.js relative to itself first, then by package name.
11
+ * If nothing resolves, exit non-zero WITHOUT touching the CLI — the
12
+ * wrapper's own pre-banner fallback takes over and the CLI still runs.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const candidates = [
21
+ join(here, "..", "surfaces", "pty-inject.js"), // dist/assets → dist/surfaces
22
+ join(here, "surfaces", "pty-inject.js"), // src/assets → src/surfaces
23
+ ];
24
+
25
+ let runWithAdOverlay = null;
26
+
27
+ for (const c of candidates) {
28
+ if (!existsSync(c)) continue;
29
+ try {
30
+ ({ runWithAdOverlay } = await import(pathToFileURL(c).href));
31
+ break;
32
+ } catch {}
33
+ }
34
+
35
+ if (!runWithAdOverlay) {
36
+ try {
37
+ ({ runWithAdOverlay } = await import("@expitc/termad/dist/surfaces/pty-inject.js"));
38
+ } catch {}
39
+ }
40
+
41
+ if (typeof runWithAdOverlay !== "function") {
42
+ console.error("termad: pty-inject.js not found — falling back to banner mode");
43
+ process.exit(1);
44
+ }
45
+
3
46
  const binary = process.argv[2];
4
- const args = process.argv.slice(3);
5
- runWithAdOverlay(binary, args);
47
+ const surface = process.argv[3];
48
+ const args = process.argv.slice(4);
49
+
50
+ if (!binary) {
51
+ console.error("termad: usage: pty-overlay.mjs <binary> [args...]");
52
+ process.exit(1);
53
+ }
54
+
55
+ await runWithAdOverlay(binary, args, surface);
@@ -9,18 +9,18 @@
9
9
  TERMAD_AD_FILE="__TERMAD_AD__"
10
10
  TERMAD_BACKUP="__TERMAD_BACKUP__"
11
11
  TERMAD_COUNT="__TERMAD_COUNT__"
12
- TERMAD_HOME_DIR="$(dirname "$(dirname "$TERMAD_AD_FILE")")"
12
+ TERMAD_HOME_DIR="$(dirname "$TERMAD_AD_FILE")"
13
13
 
14
14
  # Try PTY overlay via node script bundled with termad
15
15
  OVERLAY_SCRIPT="$TERMAD_HOME_DIR/pty-overlay.mjs"
16
16
  if [ ! -f "$OVERLAY_SCRIPT" ]; then
17
17
  # Try to find in node_modules or global install
18
- OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/surfaces/pty-inject.mjs"
18
+ OVERLAY_SCRIPT="$(npm root -g 2>/dev/null)/@expitc/termad/dist/assets/pty-overlay.mjs"
19
19
  fi
20
20
 
21
21
  if [ -f "$OVERLAY_SCRIPT" ] && command -v node >/dev/null 2>&1; then
22
22
  export TERMAD_AD_FILE TERMAD_BACKUP TERMAD_COUNT
23
- exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "$@"
23
+ exec node "$OVERLAY_SCRIPT" "$TERMAD_BACKUP" "__TERMAD_SURFACE__" "$@"
24
24
  fi
25
25
 
26
26
  # Fallback: pre-banner
@@ -6,6 +6,8 @@ export declare const paths: {
6
6
  adCache: () => string;
7
7
  /** Claude statusline launcher installed into ~/.claude/settings.json. */
8
8
  statuslineScript: () => string;
9
+ /** PTY overlay launcher the shell wrappers exec. Written by `termad install`. */
10
+ ptyOverlay: () => string;
9
11
  /** Captured pre-install statusLine (chain capture, like claude does natively). */
10
12
  prevStatusline: () => string;
11
13
  log: () => string;
@@ -22,3 +24,10 @@ export declare const paths: {
22
24
  export declare function backupPath(target: string): string;
23
25
  export declare const WRAPPER_MARKER = "===== TERMAD-CLI =====";
24
26
  export declare const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
27
+ /** Every marker termad can write into a wrapped target.
28
+ * A target is termad's own if it carries ANY of them — checking only one
29
+ * makes overlay-wrapped binaries look untouched, which breaks uninstall
30
+ * (the original backup gets discarded) and misreports audit status. */
31
+ export declare const WRAPPER_MARKERS: readonly ["===== TERMAD-CLI =====", "===== TERMAD-OVERLAY ====="];
32
+ /** True if the content carries any termad wrapper marker. */
33
+ export declare function hasWrapperMarker(content: string): boolean;
@@ -10,6 +10,8 @@ export const paths = {
10
10
  adCache: () => join(termadHome(), "ad.txt"),
11
11
  /** Claude statusline launcher installed into ~/.claude/settings.json. */
12
12
  statuslineScript: () => join(termadHome(), "statusline.mjs"),
13
+ /** PTY overlay launcher the shell wrappers exec. Written by `termad install`. */
14
+ ptyOverlay: () => join(termadHome(), "pty-overlay.mjs"),
13
15
  /** Captured pre-install statusLine (chain capture, like claude does natively). */
14
16
  prevStatusline: () => join(termadHome(), "prev-statusline.json"),
15
17
  log: () => join(termadHome(), "termad.log"),
@@ -28,3 +30,12 @@ export function backupPath(target) {
28
30
  }
29
31
  export const WRAPPER_MARKER = "===== TERMAD-CLI =====";
30
32
  export const OVERLAY_MARKER = "===== TERMAD-OVERLAY =====";
33
+ /** Every marker termad can write into a wrapped target.
34
+ * A target is termad's own if it carries ANY of them — checking only one
35
+ * makes overlay-wrapped binaries look untouched, which breaks uninstall
36
+ * (the original backup gets discarded) and misreports audit status. */
37
+ export const WRAPPER_MARKERS = [WRAPPER_MARKER, OVERLAY_MARKER];
38
+ /** True if the content carries any termad wrapper marker. */
39
+ export function hasWrapperMarker(content) {
40
+ return WRAPPER_MARKERS.some((m) => content.includes(m));
41
+ }
package/dist/install.js CHANGED
@@ -7,7 +7,8 @@
7
7
  import { loadConfig, saveConfig, writeFileAtomic } from "./core/config.js";
8
8
  import { paths, termadHome, backupPath } from "./core/paths.js";
9
9
  import { fetchAd, writeAdCache } from "./core/source.js";
10
- import { existsSync, mkdirSync } from "node:fs";
10
+ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
11
12
  import { getSurface } from "./surfaces/surfaces.js";
12
13
  import { installWrapper, uninstallWrapper, isWrapped } from "./surfaces/wrap.js";
13
14
  import { installStatusline, uninstallStatusline, settingsPath, isStatuslineInstalled } from "./surfaces/statusline.js";
@@ -46,11 +47,47 @@ export function planInstall(surfaces, force = false) {
46
47
  }
47
48
  return items;
48
49
  }
50
+ /** Absolute path to the overlay implementation shipped with THIS install.
51
+ * dist/surfaces/pty-inject.js in a published package, src/… when run from
52
+ * source. */
53
+ function ptyInjectPath() {
54
+ const here = dirname(new URL(import.meta.url).pathname);
55
+ const js = join(here, "surfaces", "pty-inject.js");
56
+ const ts = join(here, "surfaces", "pty-inject.ts");
57
+ return existsSync(js) ? js : ts;
58
+ }
59
+ /** Write the overlay launcher into termad home so the wrapper can always
60
+ * find it. The absolute path to pty-inject is baked in, which keeps the
61
+ * launcher working regardless of how termad itself was installed. */
62
+ function installOverlayLauncher() {
63
+ const target = paths.ptyOverlay();
64
+ try {
65
+ const mod = ptyInjectPath();
66
+ const body = `#!/usr/bin/env node
67
+ // Written by \`termad install\`. Launches the PTY ad overlay for a CLI binary.
68
+ // Regenerated on every install — safe to edit/delete; termad rewrites it.
69
+ import { pathToFileURL } from "node:url";
70
+ const mod = await import(pathToFileURL(${JSON.stringify(mod)}).href);
71
+ if (typeof mod.runWithAdOverlay !== "function") {
72
+ console.error("termad: overlay unavailable");
73
+ process.exit(1);
74
+ }
75
+ await mod.runWithAdOverlay(process.argv[2], process.argv.slice(4), process.argv[3]);
76
+ `;
77
+ writeFileSync(target, body);
78
+ chmodSync(target, 0o755);
79
+ return target;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
49
85
  /** Fetch the ad, write the cache, then install the opted-in surfaces. */
50
86
  export async function install(surfaces, opts = {}) {
51
87
  const force = opts.force ?? false;
52
88
  const cfg = loadConfig();
53
89
  mkdirSync(termadHome(), { recursive: true });
90
+ installOverlayLauncher();
54
91
  const ad = await fetchAd(surfaces?.[0] ?? "claude");
55
92
  writeAdCache(ad);
56
93
  const wanted = surfaces ?? Object.entries(cfg.surfaces).filter(([, v]) => v).map(([k]) => k);
@@ -84,7 +121,7 @@ export async function install(surfaces, opts = {}) {
84
121
  }
85
122
  const wasWrapped = isWrapped(bin);
86
123
  const shaBefore = sha256File(bin);
87
- const r = installWrapper(bin, paths.adCache(), force);
124
+ const r = installWrapper(bin, paths.adCache(), force, id);
88
125
  if (r.ok) {
89
126
  const entry = loadManifest().entries.find((e) => e.surface === id && e.target === bin);
90
127
  if (!entry) {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
15
  import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
- import { WRAPPER_MARKER } from "../core/paths.js";
16
+ import { hasWrapperMarker } from "../core/paths.js";
17
17
  export const INLINE_MARKER = "===== TERMAD-INLINE =====";
18
18
  export const INLINE_BACKUP_EXT = ".termad-bundle-orig";
19
19
  export function inlineBackupPath(target) {
@@ -141,7 +141,7 @@ export function installInline(target) {
141
141
  function isWrappedCheck(target) {
142
142
  try {
143
143
  const head = readFileSync(target, "utf8").slice(0, 256);
144
- return head.includes(WRAPPER_MARKER);
144
+ return hasWrapperMarker(head);
145
145
  }
146
146
  catch {
147
147
  return false;
@@ -1 +1,2 @@
1
- export declare function runWithAdOverlay(binary: string, args?: string[]): Promise<void>;
1
+ import { type SurfaceId } from "../core/config.js";
2
+ export declare function runWithAdOverlay(binary: string, args?: string[], surface?: SurfaceId): Promise<void>;
@@ -1,9 +1,13 @@
1
- import { readFileSync, appendFileSync } from "node:fs";
1
+ import { readFileSync, appendFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { isValidAd } from "../core/ad.js";
5
+ import { writeAdCache } from "../core/source.js";
4
6
  const TERMAD_HOME = process.env.TERMAD_HOME || join(homedir(), ".termad");
5
7
  const AD_FILE = join(TERMAD_HOME, "ad.txt");
6
8
  const COUNT_FILE = join(TERMAD_HOME, "display.log");
9
+ const CONFIG_FILE = join(TERMAD_HOME, "config.json");
10
+ const STATS_FILE = join(TERMAD_HOME, "stats.json");
7
11
  function readAd() {
8
12
  try {
9
13
  const raw = readFileSync(AD_FILE, "utf8").replace(/\r/g, "").split("\n");
@@ -18,6 +22,17 @@ function readAd() {
18
22
  return null;
19
23
  }
20
24
  }
25
+ function readStyle() {
26
+ try {
27
+ if (!existsSync(CONFIG_FILE))
28
+ return "line";
29
+ const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
30
+ return cfg.style === "banner" ? "banner" : "line";
31
+ }
32
+ catch {
33
+ return "line";
34
+ }
35
+ }
21
36
  function count(id) {
22
37
  if (!id)
23
38
  return;
@@ -26,20 +41,98 @@ function count(id) {
26
41
  }
27
42
  catch { }
28
43
  }
29
- function buildFooter(ad) {
44
+ function recordPrompt() {
45
+ try {
46
+ mkdirSync(TERMAD_HOME, { recursive: true });
47
+ let stats = { prompts: 0, sessions: 0 };
48
+ try {
49
+ stats = { ...stats, ...JSON.parse(readFileSync(STATS_FILE, "utf8")) };
50
+ }
51
+ catch { }
52
+ stats.prompts++;
53
+ writeFileSync(STATS_FILE, JSON.stringify(stats));
54
+ }
55
+ catch { }
56
+ }
57
+ function refreshAd(surface) {
58
+ try {
59
+ const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
60
+ if (!cfg.endpoint || !cfg.uid)
61
+ return Promise.resolve(false);
62
+ const url = `${String(cfg.endpoint).replace(/\/+$/, "")}/v1/ad?surface=${surface}&uid=${encodeURIComponent(cfg.uid)}`;
63
+ return fetch(url, { signal: AbortSignal.timeout(4000) }).then(async (res) => {
64
+ if (!res.ok)
65
+ return false;
66
+ const body = await res.json();
67
+ if (!isValidAd(body.ad))
68
+ return false;
69
+ writeAdCache(body.ad);
70
+ return true;
71
+ }).catch(() => false);
72
+ }
73
+ catch {
74
+ return Promise.resolve(false);
75
+ }
76
+ }
77
+ function osc8(url, text) {
78
+ if (!url)
79
+ return text;
80
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
81
+ }
82
+ function getDomain(url) {
83
+ if (!url)
84
+ return null;
85
+ try {
86
+ const u = new URL(url);
87
+ return u.hostname.replace(/^www\./, "");
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ function truncate(s, n) {
94
+ if (s.length <= n)
95
+ return s;
96
+ return s.slice(0, Math.max(0, n - 3)) + "...";
97
+ }
98
+ function buildFooter(ad, style, width, offset = 0) {
30
99
  const label = "\x1b[2m\x1b[33m[ad]\x1b[0m";
31
- const text = ad.text.length > 80 ? ad.text.slice(0, 77) + "..." : ad.text;
32
- const url = ad.url && ad.url.length > 60 ? ad.url.slice(0, 57) + "..." : ad.url;
33
- let line = ` ${label} ${text}`;
34
- if (url)
35
- line += ` \x1b[36m${url}\x1b[0m`;
100
+ const domain = getDomain(ad.url);
101
+ const w = Math.max(40, width - 4);
102
+ if (style === "banner") {
103
+ // Boxed style like Kickbacks
104
+ const innerW = Math.max(30, w - 2);
105
+ let displayText = ad.text;
106
+ // Marquee for long text
107
+ if (displayText.length > innerW - 20) {
108
+ const padded = displayText + " • " + displayText;
109
+ const start = offset % displayText.length;
110
+ displayText = padded.slice(start, start + innerW - 25);
111
+ }
112
+ const linkText = domain || ad.url;
113
+ const linked = ad.url ? osc8(ad.url, `\x1b[36m${truncate(linkText, 30)}\x1b[0m`) : "";
114
+ const content = ` ${label} ${truncate(displayText, innerW - 15)}${linked ? " " + linked : ""}`;
115
+ const padded = content + " ".repeat(Math.max(0, innerW - content.replace(/\x1b\[[0-9;]*m/g, "").length));
116
+ return `\x1b[90m┌${"─".repeat(innerW)}┐\x1b[0m\n\x1b[90m│\x1b[0m${padded}\x1b[90m│\x1b[0m\n\x1b[90m└${"─".repeat(innerW)}┘\x1b[0m`;
117
+ }
118
+ // Line style (minimal, clean)
119
+ let displayText = ad.text;
120
+ if (displayText.length > w - 40) {
121
+ const padded = displayText + " ";
122
+ const start = offset % (displayText.length + 5);
123
+ displayText = padded.slice(start, start + w - 45);
124
+ }
125
+ const linkText = domain ? `\x1b[2m•\x1b[0m \x1b[36m${domain}\x1b[0m` : (ad.url ? `\x1b[2m•\x1b[0m \x1b[36m${truncate(ad.url, 40)}\x1b[0m` : "");
126
+ const linked = ad.url ? osc8(ad.url, linkText) : linkText;
127
+ let line = ` ${label} ${truncate(displayText, w - 35)}`;
128
+ if (linked)
129
+ line += ` ${linked}`;
36
130
  return line;
37
131
  }
38
- export function runWithAdOverlay(binary, args = []) {
39
- return runWithAdOverlayAsync(binary, args);
132
+ export function runWithAdOverlay(binary, args = [], surface = "codex") {
133
+ return runWithAdOverlayAsync(binary, args, surface);
40
134
  }
41
- async function runWithAdOverlayAsync(binary, args = []) {
42
- // Try to use node-pty if available
135
+ async function runWithAdOverlayAsync(binary, args = [], surface) {
43
136
  let pty;
44
137
  try {
45
138
  // @ts-ignore
@@ -53,12 +146,11 @@ async function runWithAdOverlayAsync(binary, args = []) {
53
146
  catch { }
54
147
  }
55
148
  if (!pty) {
56
- // No PTY available - fall back to printing banner then execing
57
149
  const ad = readAd();
58
150
  if (ad) {
59
- process.stdout.write("\n [ad] " + ad.text + "\n");
60
- if (ad.url)
61
- process.stdout.write(" " + ad.url + "\n\n");
151
+ const style = readStyle();
152
+ const width = process.stdout.columns || 120;
153
+ console.log(buildFooter(ad, style, width).replace(/\n/g, "\n"));
62
154
  count(ad.id);
63
155
  }
64
156
  const { spawn } = await import("node:child_process");
@@ -71,6 +163,7 @@ async function runWithAdOverlayAsync(binary, args = []) {
71
163
  count(ad.id);
72
164
  const cols = process.stdout.columns || 120;
73
165
  const rows = process.stdout.rows || 40;
166
+ const style = readStyle();
74
167
  const ptyProcess = pty.spawn(binary, args, {
75
168
  name: "xterm-256color",
76
169
  cols,
@@ -80,32 +173,93 @@ async function runWithAdOverlayAsync(binary, args = []) {
80
173
  });
81
174
  let footerVisible = !!ad;
82
175
  let lastAd = ad;
176
+ let marqueeOffset = 0;
177
+ let fetching = false;
178
+ let fetchFrame = 0;
179
+ let promptBytes = [];
83
180
  function drawFooter() {
181
+ if (fetching) {
182
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
183
+ const status = ` \x1b[36m${frames[fetchFrame++ % frames.length]} validating prompt • selecting an ad\x1b[0m`;
184
+ const termRows = ptyProcess.rows || rows;
185
+ const footerRow = style === "banner" ? Math.max(1, termRows - 2) : Math.max(1, termRows - 1);
186
+ process.stdout.write(`\x1b[s\x1b[${footerRow};1H\x1b[2K${status}\x1b[u`);
187
+ return;
188
+ }
84
189
  if (!footerVisible || !lastAd)
85
190
  return;
86
- const footer = buildFooter(lastAd);
87
- // Move to bottom row, clear line, print footer
191
+ const width = process.stdout.columns || cols;
88
192
  const termRows = ptyProcess.rows || rows;
89
- process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
193
+ const footer = buildFooter(lastAd, style, width, marqueeOffset);
194
+ marqueeOffset++;
195
+ if (style === "banner") {
196
+ // Draw 3 lines for boxed
197
+ process.stdout.write(`\x1b[s\x1b[${termRows - 2};1H\x1b[2K${footer.split('\n')[0]}\x1b[u`);
198
+ process.stdout.write(`\x1b[s\x1b[${termRows - 1};1H\x1b[2K${footer.split('\n')[1]}\x1b[u`);
199
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer.split('\n')[2]}\x1b[u`);
200
+ }
201
+ else {
202
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K${footer}\x1b[u`);
203
+ }
90
204
  }
91
205
  function hideFooter() {
92
206
  const termRows = ptyProcess.rows || rows;
93
- process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
207
+ if (style === "banner") {
208
+ process.stdout.write(`\x1b[s\x1b[${termRows - 2};1H\x1b[2K\x1b[${termRows - 1};1H\x1b[2K\x1b[${termRows};1H\x1b[2K\x1b[u`);
209
+ }
210
+ else {
211
+ process.stdout.write(`\x1b[s\x1b[${termRows};1H\x1b[2K\x1b[u`);
212
+ }
94
213
  }
95
- // Pipe PTY output to stdout with footer redrawn after each chunk
96
214
  ptyProcess.onData((data) => {
97
215
  process.stdout.write(data);
98
- // Redraw footer after a short delay to stay on top
99
- setTimeout(drawFooter, 10);
216
+ setTimeout(drawFooter, 15);
100
217
  });
101
- // Pipe stdin to PTY
102
218
  if (process.stdin.isTTY) {
103
- process.stdin.setRawMode(true);
219
+ try {
220
+ process.stdin.setRawMode(true);
221
+ }
222
+ catch { }
104
223
  }
105
224
  process.stdin.on("data", (data) => {
225
+ for (const byte of data) {
226
+ if (byte === 3 || byte === 4) {
227
+ promptBytes = [];
228
+ continue;
229
+ }
230
+ if (byte === 13 || byte === 10) {
231
+ const hasPrompt = promptBytes.some((b) => b >= 32 && b !== 127);
232
+ promptBytes = [];
233
+ if (hasPrompt && !fetching) {
234
+ recordPrompt();
235
+ fetching = true;
236
+ drawFooter();
237
+ void refreshAd(surface).then((updated) => {
238
+ if (updated) {
239
+ const next = readAd();
240
+ if (next) {
241
+ lastAd = next;
242
+ footerVisible = true;
243
+ if (next.id)
244
+ count(next.id);
245
+ }
246
+ }
247
+ fetching = false;
248
+ drawFooter();
249
+ });
250
+ }
251
+ }
252
+ else if (byte === 127 || byte === 8) {
253
+ promptBytes.pop();
254
+ }
255
+ else if (byte >= 32 && byte !== 127) {
256
+ promptBytes.push(byte);
257
+ if (promptBytes.length > 20000)
258
+ promptBytes = promptBytes.slice(-20000);
259
+ }
260
+ }
106
261
  ptyProcess.write(data);
107
262
  });
108
- // Handle resize
109
263
  process.stdout.on("resize", () => {
110
264
  const c = process.stdout.columns || cols;
111
265
  const r = process.stdout.rows || rows;
@@ -115,7 +269,6 @@ async function runWithAdOverlayAsync(binary, args = []) {
115
269
  catch { }
116
270
  setTimeout(drawFooter, 50);
117
271
  });
118
- // Handle exit
119
272
  ptyProcess.onExit(({ exitCode }) => {
120
273
  if (process.stdin.isTTY) {
121
274
  try {
@@ -127,7 +280,6 @@ async function runWithAdOverlayAsync(binary, args = []) {
127
280
  hideFooter();
128
281
  process.exit(exitCode ?? 0);
129
282
  });
130
- // Initial draw
131
- setTimeout(drawFooter, 100);
132
- setInterval(drawFooter, 500); // Keep footer visible on redraws
283
+ setTimeout(drawFooter, 150);
284
+ setInterval(drawFooter, style === "banner" ? 800 : 1000);
133
285
  }
@@ -7,7 +7,7 @@ export declare function isWrapped(target: string): boolean;
7
7
  /** Wrap a binary/shim with the ad-printing wrapper. Idempotent.
8
8
  * `force` re-bakes an existing wrapper (e.g. after a template upgrade)
9
9
  * while keeping the pristine backup untouched. */
10
- export declare function installWrapper(target: string, adCachePath: string, force?: boolean): WrapResult;
10
+ export declare function installWrapper(target: string, adCachePath: string, force?: boolean, surface?: string): WrapResult;
11
11
  /** Restore the pristine binary and remove wrapper + ad cache. Idempotent. */
12
12
  export declare function uninstallWrapper(target: string, adCachePath: string): WrapResult;
13
13
  /**
@@ -8,7 +8,7 @@
8
8
  import { chmodSync, copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
9
9
  import { delimiter, dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
- import { WRAPPER_MARKER, backupPath, paths } from "../core/paths.js";
11
+ import { hasWrapperMarker, backupPath, paths } from "../core/paths.js";
12
12
  const SH_TEMPLATE_OVERLAY = "wrapper.sh.overlay";
13
13
  const SH_TEMPLATE = "wrapper.sh.asset";
14
14
  const CMD_TEMPLATE = "wrapper.cmd.asset";
@@ -21,7 +21,10 @@ function templatePath(isWin, useOverlay = true) {
21
21
  }
22
22
  export function isWrapped(target) {
23
23
  try {
24
- return readFileSync(target, "utf8").includes(WRAPPER_MARKER);
24
+ // Any termad marker counts — the overlay variant is just as much
25
+ // "ours" as the plain wrapper. Missing it makes uninstall discard the
26
+ // pristine backup and leaves the shim behind.
27
+ return hasWrapperMarker(readFileSync(target, "utf8"));
25
28
  }
26
29
  catch {
27
30
  return false;
@@ -30,7 +33,7 @@ export function isWrapped(target) {
30
33
  /** Wrap a binary/shim with the ad-printing wrapper. Idempotent.
31
34
  * `force` re-bakes an existing wrapper (e.g. after a template upgrade)
32
35
  * while keeping the pristine backup untouched. */
33
- export function installWrapper(target, adCachePath, force = false) {
36
+ export function installWrapper(target, adCachePath, force = false, surface = "codex") {
34
37
  if (!existsSync(target))
35
38
  return { ok: false, reason: `target not found: ${target}` };
36
39
  if (isWrapped(target) && !force)
@@ -50,7 +53,8 @@ export function installWrapper(target, adCachePath, force = false) {
50
53
  const wrapper = template
51
54
  .split("__TERMAD_AD__").join(adCachePath)
52
55
  .split("__TERMAD_COUNT__").join(paths.displayLog())
53
- .split("__TERMAD_BACKUP__").join(bak);
56
+ .split("__TERMAD_BACKUP__").join(bak)
57
+ .split("__TERMAD_SURFACE__").join(surface);
54
58
  if (wrapper.includes("__TERMAD_"))
55
59
  return { ok: false, reason: "unbaked template token" };
56
60
  const mode = statSync(target).mode & 0o777;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expitc/termad",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Opt-in, fully reversible text ads for terminal AI CLIs (claude, codex, opencode, gemini). Consent-first, tamper-evident audit, 50% runner revenue share.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "dist",
33
33
  "assets",
34
+ "scripts/ensure-pty-helper.mjs",
34
35
  "README.md",
35
36
  "POLICY.md",
36
37
  "LICENSE"
@@ -39,6 +40,7 @@
39
40
  "build": "tsc && node scripts/copy-assets.mjs",
40
41
  "dev": "tsc --watch",
41
42
  "server": "node server/server.mjs",
43
+ "postinstall": "node scripts/ensure-pty-helper.mjs || exit 0",
42
44
  "prepublishOnly": "npm run build"
43
45
  },
44
46
  "engines": {
@@ -47,5 +49,11 @@
47
49
  "devDependencies": {
48
50
  "@types/node": "^22.10.0",
49
51
  "typescript": "^5.7.0"
52
+ },
53
+ "optionalDependencies": {
54
+ "node-pty": "^1.1.0"
55
+ },
56
+ "allowScripts": {
57
+ "node-pty@1.1.0": true
50
58
  }
51
59
  }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * node-pty ships its prebuilt `spawn-helper` (the tiny helper that opens the
4
+ * pty) inside the npm tarball WITHOUT the executable bit, and npm >= 11
5
+ * blocks dependency install scripts by default — so the helper is never
6
+ * rebuilt and never made executable. The result is a confusing
7
+ * "posix_spawnp failed" error and a silent downgrade to banner mode.
8
+ *
9
+ * This postinstall just fixes the mode. It is a no-op if the helper is
10
+ * already executable, and a no-op if node-pty is not installed at all
11
+ * (it is an optional dependency).
12
+ */
13
+ import { chmodSync, existsSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { createRequire } from "node:module";
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ function findHelper() {
20
+ let root;
21
+ try {
22
+ root = join(require.resolve("node-pty/package.json"), "..");
23
+ } catch {
24
+ return null;
25
+ }
26
+ const candidates = [
27
+ join(root, "build", "Release", "spawn-helper"),
28
+ join(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
29
+ ];
30
+ return candidates.find((c) => existsSync(c)) ?? null;
31
+ }
32
+
33
+ const helper = findHelper();
34
+ if (!helper) process.exit(0);
35
+
36
+ try {
37
+ const mode = statSync(helper).mode & 0o777;
38
+ if (mode & 0o111) process.exit(0); // already executable
39
+ chmodSync(helper, 0o755);
40
+ console.log("termad: made node-pty spawn-helper executable");
41
+ } catch (e) {
42
+ // Non-fatal — the overlay degrades to banner mode.
43
+ console.log(`termad: could not make node-pty spawn-helper executable: ${e.message}`);
44
+ }