@zhuxixi/pi-agent-board 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.
- package/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Helpers for safely delivering prompts to Pi child processes. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Protect a prompt that is delivered as a positional CLI argument.
|
|
5
|
+
* Pi parses argv before it knows which tokens are prompt text, so a leading `-`
|
|
6
|
+
* would be treated as another flag and abort the run.
|
|
7
|
+
* @param {string} text
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function encodePromptForCliArg(text) {
|
|
11
|
+
const raw = String(text ?? "");
|
|
12
|
+
return /^\s*-/.test(raw) ? ` ${raw}` : raw;
|
|
13
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Pure logic for attach jiggle-retry: detect full-clear, schedule backoff retries. */
|
|
2
|
+
|
|
3
|
+
export const BACKOFF_MS = Object.freeze([120, 500, 1500, 3000]);
|
|
4
|
+
export const MAX_RETRIES = BACKOFF_MS.length;
|
|
5
|
+
|
|
6
|
+
// Verified empirically in issue #2: a resize jiggle makes the child pi-tui run
|
|
7
|
+
// fullRender(true), whose output begins with a full clear — a controlled test
|
|
8
|
+
// against a live idle session received 2 clear sequences + ~920KB full repaint
|
|
9
|
+
// per jiggle. So \x1b[2J in the socket output is a reliable "jiggle landed"
|
|
10
|
+
// signal. If pi-tui ever changes fullRender to skip the clear, the detector
|
|
11
|
+
// silently degrades to the old one-shot behavior after MAX_RETRIES.
|
|
12
|
+
const FULL_CLEAR = "\x1b[2J";
|
|
13
|
+
/** Carry enough bytes to catch a split escape sequence at chunk boundary. */
|
|
14
|
+
const CARRY_LEN = FULL_CLEAR.length - 1; // 3 bytes: \x1b, \x1b[, \x1b[2
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} JiggleRetryState
|
|
18
|
+
* @property {number} retryIndex - Current retry round (0 = initial, not yet retried).
|
|
19
|
+
* @property {boolean} clearDetected - Whether a full-clear sequence was seen.
|
|
20
|
+
* @property {boolean} stopped - Whether the retry chain has been stopped.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Create initial retry state. */
|
|
24
|
+
export function createJiggleRetryState() {
|
|
25
|
+
return { retryIndex: 0, clearDetected: false, stopped: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Feed PTY output data into the state machine.
|
|
30
|
+
* Returns new state, updated carry buffer, and whether a clear was found.
|
|
31
|
+
* @param {JiggleRetryState} state
|
|
32
|
+
* @param {string} data - New output data chunk.
|
|
33
|
+
* @param {string} carry - Leftover partial escape sequence from previous chunk.
|
|
34
|
+
* @returns {{ state: JiggleRetryState, carry: string, clearFound: boolean }}
|
|
35
|
+
*/
|
|
36
|
+
export function feedOutput(state, data, carry) {
|
|
37
|
+
const combined = carry + data;
|
|
38
|
+
const clearFound = hasFullClearSequence(combined);
|
|
39
|
+
const newCarry = clearFound ? "" : tailCarry(combined);
|
|
40
|
+
const newState = clearFound ? { ...state, clearDetected: true } : state;
|
|
41
|
+
return { state: newState, carry: newCarry, clearFound };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the delay before the next retry, or null if no more retries.
|
|
46
|
+
* @param {JiggleRetryState} state
|
|
47
|
+
* @returns {number | null}
|
|
48
|
+
*/
|
|
49
|
+
export function nextRetryDelay(state) {
|
|
50
|
+
if (state.stopped || state.clearDetected) return null;
|
|
51
|
+
if (state.retryIndex >= MAX_RETRIES) return null;
|
|
52
|
+
return BACKOFF_MS[state.retryIndex];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Advance to the next retry round.
|
|
57
|
+
* @param {JiggleRetryState} state
|
|
58
|
+
* @returns {JiggleRetryState}
|
|
59
|
+
*/
|
|
60
|
+
export function advanceRetry(state) {
|
|
61
|
+
return { ...state, retryIndex: state.retryIndex + 1 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop the retry chain (attach settled, component closed, etc.).
|
|
66
|
+
* @param {JiggleRetryState} state
|
|
67
|
+
* @returns {JiggleRetryState}
|
|
68
|
+
*/
|
|
69
|
+
export function stopRetry(state) {
|
|
70
|
+
return { ...state, stopped: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if data contains the full-clear escape sequence.
|
|
75
|
+
* @param {string} data
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function hasFullClearSequence(data) {
|
|
79
|
+
return data.includes(FULL_CLEAR);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Extract the tail carry for cross-chunk boundary detection. */
|
|
83
|
+
function tailCarry(data) {
|
|
84
|
+
// We only need to carry up to CARRY_LEN bytes to catch a split \x1b[2J.
|
|
85
|
+
const tail = data.slice(-CARRY_LEN);
|
|
86
|
+
// Find the last \x1b in the tail — everything before it can't be part of
|
|
87
|
+
// a split escape sequence.
|
|
88
|
+
const escIdx = tail.lastIndexOf("\x1b");
|
|
89
|
+
return escIdx >= 0 ? tail.slice(escIdx) : "";
|
|
90
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether the next attach repaint should force a full clear.
|
|
3
|
+
*
|
|
4
|
+
* The first attach paint always forces once so the previous dashboard/session surface
|
|
5
|
+
* cannot ghost behind the overlay. Later paints stay differential unless a caller
|
|
6
|
+
* explicitly requests a hard reset.
|
|
7
|
+
*/
|
|
8
|
+
export function nextAttachRender(firstPaint, force = false) {
|
|
9
|
+
return {
|
|
10
|
+
force: Boolean(force || firstPaint),
|
|
11
|
+
firstPaint: false,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Attach output should repaint only after @xterm/headless finishes parsing the chunk.
|
|
17
|
+
* Status/control messages can repaint immediately because they mutate header/overlay state.
|
|
18
|
+
*/
|
|
19
|
+
export function shouldScheduleAttachRenderForMessage(type) {
|
|
20
|
+
return type === "hello" || type === "status" || type === "exit" || type === "error";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ATTACH_OUTPUT_RENDER_INTERVAL_MS = 40;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Coalesce PTY parser callbacks into a bounded stream of repaint requests.
|
|
27
|
+
*
|
|
28
|
+
* node-pty commonly splits one child-TUI update across many small chunks. Rendering
|
|
29
|
+
* after each @xterm/headless write callback exposes those intermediate frames and can
|
|
30
|
+
* drive the outer TUI near its 60 fps limit. A trailing throttle keeps output live
|
|
31
|
+
* while presenting only parsed snapshots at roughly 25 fps.
|
|
32
|
+
*/
|
|
33
|
+
export function createAttachOutputRenderScheduler(requestRender, delayMs = ATTACH_OUTPUT_RENDER_INTERVAL_MS) {
|
|
34
|
+
let timer = null;
|
|
35
|
+
let disposed = false;
|
|
36
|
+
return {
|
|
37
|
+
request() {
|
|
38
|
+
if (disposed || timer) return;
|
|
39
|
+
timer = setTimeout(() => {
|
|
40
|
+
timer = null;
|
|
41
|
+
if (!disposed) requestRender();
|
|
42
|
+
}, delayMs);
|
|
43
|
+
timer.unref?.();
|
|
44
|
+
},
|
|
45
|
+
dispose() {
|
|
46
|
+
disposed = true;
|
|
47
|
+
if (timer) clearTimeout(timer);
|
|
48
|
+
timer = null;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Helpers for deciding whether local attach shortcuts should be handled. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort detection of Pi's empty prompt/input line from projected terminal text.
|
|
5
|
+
* The attach surface must not steal editing keys (notably ←) while the child Pi editor
|
|
6
|
+
* contains text. Pi renders empty editor lines with prompt/continuation glyphs such as
|
|
7
|
+
* `›`, `┃`, or `│`; once user text is present, non-prompt content remains after this trim.
|
|
8
|
+
* @param {string} line
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isProbablyEmptyPiInputLine(line) {
|
|
12
|
+
const withoutRightPadding = String(line || "").replace(/[\s\u00a0]+$/u, "");
|
|
13
|
+
const content = withoutRightPadding.replace(/^[\s\u00a0›>┃│|┆╎╏:]+/u, "");
|
|
14
|
+
return content.length === 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const HTTP_URL_RE = /https?:\/\/[^\s<>"'`]+/g;
|
|
2
|
+
const WORD_CHAR_RE = /[A-Za-z0-9_]/;
|
|
3
|
+
|
|
4
|
+
export function trimTrailingUrlPunctuation(value) {
|
|
5
|
+
let url = value;
|
|
6
|
+
while (url.length > 0) {
|
|
7
|
+
if (/[.,;:!?]$/.test(url)) {
|
|
8
|
+
url = url.slice(0, -1);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const last = url.at(-1);
|
|
12
|
+
if (last === ")" && countChar(url, "(") < countChar(url, ")")) {
|
|
13
|
+
url = url.slice(0, -1);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (last === "]" && countChar(url, "[") < countChar(url, "]")) {
|
|
17
|
+
url = url.slice(0, -1);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (last === "}" && countChar(url, "{") < countChar(url, "}")) {
|
|
21
|
+
url = url.slice(0, -1);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function findHttpUrlRangeAtCells(cells, col) {
|
|
30
|
+
if (!Array.isArray(cells) || col < 0 || col >= cells.length) return null;
|
|
31
|
+
const text = cells.map(normalizeAsciiCell).join("");
|
|
32
|
+
HTTP_URL_RE.lastIndex = 0;
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = HTTP_URL_RE.exec(text))) {
|
|
35
|
+
const url = trimTrailingUrlPunctuation(match[0]);
|
|
36
|
+
const start = match.index;
|
|
37
|
+
const end = start + url.length - 1;
|
|
38
|
+
if (col >= start && col <= end) return { text: url, start, end };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findHttpUrlAtCells(cells, col) {
|
|
44
|
+
return findHttpUrlRangeAtCells(cells, col)?.text ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findWordRangeAtCells(cells, col) {
|
|
48
|
+
if (!Array.isArray(cells) || col < 0 || col >= cells.length) return null;
|
|
49
|
+
const url = findHttpUrlRangeAtCells(cells, col);
|
|
50
|
+
if (url) return { ...url, kind: "url" };
|
|
51
|
+
const text = cells.map(normalizeAsciiCell);
|
|
52
|
+
if (!WORD_CHAR_RE.test(text[col] ?? "")) return null;
|
|
53
|
+
let start = col;
|
|
54
|
+
let end = col;
|
|
55
|
+
while (start > 0 && WORD_CHAR_RE.test(text[start - 1] ?? "")) start -= 1;
|
|
56
|
+
while (end + 1 < text.length && WORD_CHAR_RE.test(text[end + 1] ?? "")) end += 1;
|
|
57
|
+
return { text: text.slice(start, end + 1).join(""), start, end, kind: "word" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeAsciiCell(cell) {
|
|
61
|
+
if (typeof cell !== "string" || cell.length !== 1) return " ";
|
|
62
|
+
return cell >= " " && cell <= "~" ? cell : " ";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function countChar(text, char) {
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (const ch of text) {
|
|
68
|
+
if (ch === char) count += 1;
|
|
69
|
+
}
|
|
70
|
+
return count;
|
|
71
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export function clampInt(value, min, max) {
|
|
2
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Lines scrolled per mouse-wheel event in the attach viewport when unset/invalid. */
|
|
6
|
+
export const DEFAULT_WHEEL_LINES = 1;
|
|
7
|
+
/** Lower bound for $AGENT_BOARD_WHEEL_LINES. */
|
|
8
|
+
export const MIN_WHEEL_LINES = 1;
|
|
9
|
+
/** Upper bound for $AGENT_BOARD_WHEEL_LINES. */
|
|
10
|
+
export const MAX_WHEEL_LINES = 50;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve how many lines a single mouse-wheel event scrolls in the attach viewport.
|
|
14
|
+
*
|
|
15
|
+
* Reads `AGENT_BOARD_WHEEL_LINES`, clamps it to `[MIN_WHEEL_LINES, MAX_WHEEL_LINES]`,
|
|
16
|
+
* and falls back to `DEFAULT_WHEEL_LINES` when unset, empty, or non-numeric. macOS
|
|
17
|
+
* inertial/momentum scrolling fires a *burst* of wheel events per gesture, so a small
|
|
18
|
+
* value here already scrolls quickly; the previous fixed `5` overshot badly.
|
|
19
|
+
*
|
|
20
|
+
* @param {Record<string, string|undefined>} [env]
|
|
21
|
+
* @returns {number}
|
|
22
|
+
*/
|
|
23
|
+
export function resolveWheelLines(env = process.env) {
|
|
24
|
+
const raw = String(env.AGENT_BOARD_WHEEL_LINES ?? "").trim();
|
|
25
|
+
if (!raw) return DEFAULT_WHEEL_LINES;
|
|
26
|
+
const parsed = Number.parseInt(raw, 10);
|
|
27
|
+
if (!Number.isFinite(parsed)) return DEFAULT_WHEEL_LINES;
|
|
28
|
+
return clampInt(parsed, MIN_WHEEL_LINES, MAX_WHEEL_LINES);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseMouseEventPrefix(data, offset = 0) {
|
|
32
|
+
const input = data.slice(offset);
|
|
33
|
+
const sgr = /^\x1b\[(<|\?)(\d+);(\d+);(\d+)([Mm])/.exec(input);
|
|
34
|
+
if (sgr) {
|
|
35
|
+
const button = Number(sgr[2]);
|
|
36
|
+
const col = Number(sgr[3]);
|
|
37
|
+
const row = Number(sgr[4]);
|
|
38
|
+
if (!Number.isFinite(button) || !Number.isFinite(col) || !Number.isFinite(row)) return null;
|
|
39
|
+
return {
|
|
40
|
+
length: sgr[0].length,
|
|
41
|
+
raw: sgr[0],
|
|
42
|
+
mouse: {
|
|
43
|
+
encoding: sgr[1] === "?" ? "passive" : "sgr",
|
|
44
|
+
button,
|
|
45
|
+
col,
|
|
46
|
+
row,
|
|
47
|
+
action: sgr[5] === "m" ? "release" : button & 32 ? "move" : "press",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// X10/normal mouse: ESC [ M Cb Cx Cy. Cb is encoded as button + 32.
|
|
53
|
+
if (input.startsWith("\x1b[M") && input.length >= 6) {
|
|
54
|
+
const button = input.charCodeAt(3) - 32;
|
|
55
|
+
return {
|
|
56
|
+
length: 6,
|
|
57
|
+
raw: input.slice(0, 6),
|
|
58
|
+
mouse: {
|
|
59
|
+
encoding: "x10",
|
|
60
|
+
button,
|
|
61
|
+
col: input.charCodeAt(4) - 32,
|
|
62
|
+
row: input.charCodeAt(5) - 32,
|
|
63
|
+
action: button & 32 ? "move" : "press",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a terminal mouse report.
|
|
72
|
+
* Supports standard SGR (`CSI < ...`), passive SGR (`CSI ? ...`), and X10 mouse encodings.
|
|
73
|
+
*/
|
|
74
|
+
export function parseMouseEvent(data) {
|
|
75
|
+
const parsed = parseMouseEventPrefix(data);
|
|
76
|
+
return parsed && parsed.length === data.length ? parsed.mouse : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse an input chunk that consists entirely of one or more concatenated mouse reports.
|
|
81
|
+
* Returns each decoded event with its exact raw byte sequence, or null if any non-mouse
|
|
82
|
+
* bytes are present in the chunk.
|
|
83
|
+
*/
|
|
84
|
+
export function parseMouseInputChunk(data) {
|
|
85
|
+
if (!data) return null;
|
|
86
|
+
const events = [];
|
|
87
|
+
let offset = 0;
|
|
88
|
+
while (offset < data.length) {
|
|
89
|
+
const parsed = parseMouseEventPrefix(data, offset);
|
|
90
|
+
if (!parsed) return null;
|
|
91
|
+
events.push(parsed);
|
|
92
|
+
offset += parsed.length;
|
|
93
|
+
}
|
|
94
|
+
return events;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Return +1 for wheel-up (scroll back), -1 for wheel-down, 0 for non-wheel input.
|
|
99
|
+
* Supports standard/passive SGR and X10/normal mouse encodings.
|
|
100
|
+
*/
|
|
101
|
+
export function mouseWheelDirection(data) {
|
|
102
|
+
const mouse = parseMouseEvent(data);
|
|
103
|
+
if (!mouse || (mouse.button & 64) === 0) return 0;
|
|
104
|
+
const wheelButton = mouse.button & 3;
|
|
105
|
+
if (wheelButton === 0) return 1;
|
|
106
|
+
if (wheelButton === 1) return -1;
|
|
107
|
+
return 0; // horizontal wheel: ignore
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compute the next local PTY viewport after a scroll gesture.
|
|
112
|
+
*
|
|
113
|
+
* `viewportTop === null` means follow bottom. `changed` reports whether the gesture
|
|
114
|
+
* would visibly move the local scrollback viewport; callers can forward unconsumed
|
|
115
|
+
* scroll keys/wheel events to the hosted TUI instead.
|
|
116
|
+
*/
|
|
117
|
+
export function scrollViewportTop(viewportTop, bottom, linesUp) {
|
|
118
|
+
const safeBottom = Math.max(0, Math.floor(bottom));
|
|
119
|
+
const current = viewportTop == null ? safeBottom : clampInt(viewportTop, 0, safeBottom);
|
|
120
|
+
const next = clampInt(current - linesUp, 0, safeBottom);
|
|
121
|
+
return {
|
|
122
|
+
viewportTop: next >= safeBottom ? null : next,
|
|
123
|
+
changed: next !== current,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decide whether drag-selecting at/just beyond the viewport edge should auto-scroll.
|
|
129
|
+
* Positive values scroll up into older output; negative values scroll down.
|
|
130
|
+
*/
|
|
131
|
+
export function selectionDragScrollLines(row, bodyHeight) {
|
|
132
|
+
const safeHeight = Math.max(1, Math.floor(bodyHeight));
|
|
133
|
+
const topRow = 2;
|
|
134
|
+
const bottomRow = topRow + safeHeight - 1;
|
|
135
|
+
const pointerRow = Math.floor(row);
|
|
136
|
+
if (pointerRow < topRow) return 2;
|
|
137
|
+
if (pointerRow === topRow) return 1;
|
|
138
|
+
if (pointerRow > bottomRow) return -2;
|
|
139
|
+
if (pointerRow === bottomRow) return -1;
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Return a one-frame alternate PTY size that will force a hosted TUI to receive a
|
|
145
|
+
* SIGWINCH/redraw before restoring the real size. This mirrors the user-visible
|
|
146
|
+
* terminal zoom workaround without leaving the child at the wrong dimensions.
|
|
147
|
+
*/
|
|
148
|
+
export function resizeJiggleSize(cols, rows) {
|
|
149
|
+
const c = Math.max(1, Math.floor(cols));
|
|
150
|
+
const r = Math.max(1, Math.floor(rows));
|
|
151
|
+
if (c > 21 && r > 6) return { cols: c - 1, rows: r - 1 };
|
|
152
|
+
if (r > 6) return { cols: c, rows: r - 1 };
|
|
153
|
+
if (c > 21) return { cols: c - 1, rows: r };
|
|
154
|
+
return null;
|
|
155
|
+
}
|