@tintinweb/pi-subagents 0.10.1 → 0.10.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.
@@ -12,6 +12,7 @@ import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
14
  import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
+ import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
15
16
 
16
17
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
17
18
  const CHROME_LINES_BASE = 6;
@@ -27,6 +28,7 @@ export class ConversationViewer implements Component {
27
28
  private closed = false;
28
29
  /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
29
30
  private stopArmed = false;
31
+ private keys: ViewerKeys;
30
32
 
31
33
  constructor(
32
34
  private tui: TUI,
@@ -37,7 +39,10 @@ export class ConversationViewer implements Component {
37
39
  private done: (result: undefined) => void,
38
40
  /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
39
41
  private onStop?: () => void,
42
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
43
+ keybindings?: ViewerKeybindings,
40
44
  ) {
45
+ this.keys = createViewerKeys(keybindings);
41
46
  this.unsubscribe = session.subscribe(() => {
42
47
  if (this.closed) return;
43
48
  this.tui.requestRender();
@@ -71,16 +76,16 @@ export class ConversationViewer implements Component {
71
76
  const viewportHeight = this.viewportHeight();
72
77
  const maxScroll = Math.max(0, totalLines - viewportHeight);
73
78
 
74
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
79
+ if (this.keys.scrollUp(data)) {
75
80
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
76
81
  this.autoScroll = this.scrollOffset >= maxScroll;
77
- } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
82
+ } else if (this.keys.scrollDown(data)) {
78
83
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
79
84
  this.autoScroll = this.scrollOffset >= maxScroll;
80
- } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
85
+ } else if (this.keys.pageUp(data)) {
81
86
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
82
87
  this.autoScroll = false;
83
- } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
88
+ } else if (this.keys.pageDown(data)) {
84
89
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
85
90
  this.autoScroll = this.scrollOffset >= maxScroll;
86
91
  } else if (matchesKey(data, "home")) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * viewer-keys.ts — Scroll key matchers for the conversation viewer.
3
+ *
4
+ * Resolves `tui.select.*` through the user's keybindings when pi provides a
5
+ * manager, falling back to the previous hardcoded keys otherwise. The viewer's
6
+ * k/j and shift+arrow aliases always work alongside whatever is bound.
7
+ */
8
+
9
+ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
10
+
11
+ /** The `tui.select.*` keybinding ids the viewer resolves. */
12
+ export type ViewerScrollKeybinding =
13
+ | "tui.select.up"
14
+ | "tui.select.down"
15
+ | "tui.select.pageUp"
16
+ | "tui.select.pageDown";
17
+
18
+ /** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
19
+ export interface ViewerKeybindings {
20
+ matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
21
+ }
22
+
23
+ export interface ViewerKeys {
24
+ scrollUp(data: string): boolean;
25
+ scrollDown(data: string): boolean;
26
+ pageUp(data: string): boolean;
27
+ pageDown(data: string): boolean;
28
+ }
29
+
30
+ export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
31
+ const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
32
+ keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
33
+ return {
34
+ scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
35
+ scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
36
+ pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
37
+ pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
38
+ };
39
+ }
package/src/worktree.ts CHANGED
@@ -8,17 +8,24 @@
8
8
 
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { randomUUID } from "node:crypto";
11
- import { existsSync } from "node:fs";
11
+ import { existsSync, realpathSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
- import { join } from "node:path";
13
+ import { join, relative } from "node:path";
14
14
 
15
15
  export interface WorktreeInfo {
16
- /** Absolute path to the worktree directory. */
16
+ /** Absolute path to the worktree directory (the copied repo's root). */
17
17
  path: string;
18
18
  /** Branch name created for this worktree (if changes exist). */
19
19
  branch: string;
20
20
  /** Commit SHA that the worktree was created from. */
21
21
  baseSha: string;
22
+ /**
23
+ * Where the agent should work inside the worktree: the equivalent of the
24
+ * cwd the worktree was created from. Equals `path` when that cwd was the
25
+ * repo root; points at the copied subdirectory when it was deeper (e.g. a
26
+ * monorepo package), so the requested scoping survives isolation.
27
+ */
28
+ workPath: string;
22
29
  }
23
30
 
24
31
  export interface WorktreeCleanupResult {
@@ -37,11 +44,20 @@ export interface WorktreeCleanupResult {
37
44
  export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
38
45
  // Verify we're in a git repo with at least one commit (HEAD must exist)
39
46
  let baseSha: string;
47
+ let subdir: string;
40
48
  try {
41
49
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
42
50
  baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
43
51
  .toString()
44
52
  .trim();
53
+ // Where cwd sits inside the repo ("" at the root): the agent must work at
54
+ // the same subdirectory inside the copy, or a monorepo-package cwd would
55
+ // silently widen to the whole repo. realpath both sides — git emits
56
+ // resolved paths while cwd may arrive through a symlink (macOS /tmp).
57
+ const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
58
+ .toString()
59
+ .trim();
60
+ subdir = relative(realpathSync(topLevel), realpathSync(cwd));
45
61
  } catch {
46
62
  return undefined;
47
63
  }
@@ -57,7 +73,7 @@ export function createWorktree(cwd: string, agentId: string): WorktreeInfo | und
57
73
  stdio: "pipe",
58
74
  timeout: 30000,
59
75
  });
60
- return { path: worktreePath, branch, baseSha };
76
+ return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
61
77
  } catch {
62
78
  // If worktree creation fails, return undefined (agent runs in normal cwd)
63
79
  return undefined;