gentle-pi 4.1.0 → 4.2.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/README.md +39 -14
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +16 -11
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +8 -6
- package/docs/gentle-shell.md +17 -10
- package/docs/install-wizard.md +9 -9
- package/docs/permission-modes.md +50 -5
- package/docs/readme-reference.md +48 -12
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +2 -1
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/gentle-agents.ts +54 -23
- package/extensions/gentle-ai.ts +105 -25
- package/extensions/gentle-shell.ts +26 -31
- package/extensions/gentle-todo.ts +26 -16
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-shell-bundled.ts +23 -5
- package/lib/gentle-shell-launcher.ts +1 -1
- package/lib/model-routing-authority.ts +6 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +10 -15
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +20 -8
- package/lib/vim-editor-adapter.ts +22 -4
- package/package.json +5 -5
- package/runtime/gentle-shell-bundled.mjs +23 -5
- package/runtime/gentle-shell-launcher.mjs +1 -1
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +223 -0
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/bundled-install.test.ts +3 -3
- package/tests/engram-safety.test.ts +34 -6
- package/tests/gentle-agents.test.ts +162 -6
- package/tests/gentle-shell-bin.test.ts +17 -17
- package/tests/gentle-shell-bundled.test.ts +67 -33
- package/tests/gentle-shell-launcher.test.ts +18 -13
- package/tests/gentle-shell.test.ts +71 -5
- package/tests/gentle-todo.test.ts +79 -27
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +5 -5
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-profile-freeze.test.ts +14 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/yolo-mode.test.ts +6 -1
|
@@ -15,14 +15,18 @@ interface Token {
|
|
|
15
15
|
}
|
|
16
16
|
const separators = new Set([";", "&&", "||", "|", "&", "\n", "(", ")"]);
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
// Literal, named heredocs are data, not executable shell segments.
|
|
22
|
-
command = command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g, (raw) => {
|
|
18
|
+
/** Literal, named heredocs are data, not executable shell segments; offsets are preserved. */
|
|
19
|
+
function blankHeredocs(command: string): string {
|
|
20
|
+
return command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g, (raw) => {
|
|
23
21
|
const headerLength = raw.indexOf("\n") + 1;
|
|
24
22
|
return raw.slice(0, headerLength) + " ".repeat(raw.length - headerLength);
|
|
25
23
|
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keep quoted arguments intact so harmless prose is never treated as a command. */
|
|
27
|
+
function tokenize(command: string, blank = true): Token[] {
|
|
28
|
+
const tokens: Token[] = [];
|
|
29
|
+
if (blank) command = blankHeredocs(command);
|
|
26
30
|
const pattern = /(?:"(?:\\.|[^"\\])*"|'[^']*'|\\.|[^\s;&|()'"\\])+|&&|\|\||[;&|()\n]/g;
|
|
27
31
|
for (const match of command.matchAll(pattern)) {
|
|
28
32
|
const raw = match[0];
|
|
@@ -38,6 +42,100 @@ function tokenize(command: string): Token[] {
|
|
|
38
42
|
}
|
|
39
43
|
const executable = (value: string) => value.split("/").at(-1) ?? value;
|
|
40
44
|
|
|
45
|
+
const WRAPPER_VALUE_OPTIONS: Record<string, readonly string[]> = {
|
|
46
|
+
env: ["-u", "--unset", "-C", "--chdir"],
|
|
47
|
+
sudo: ["-u", "-g", "-h", "-p", "-C", "--user", "--group", "--host", "--prompt", "--chdir"],
|
|
48
|
+
timeout: ["-s", "--signal", "-k", "--kill-after"],
|
|
49
|
+
xargs: ["-n", "-I", "-P", "--max-args", "--replace", "--max-procs"],
|
|
50
|
+
};
|
|
51
|
+
const WRAPPERS = ["env", "sudo", "command", "exec", "nohup", "timeout", "xargs"];
|
|
52
|
+
|
|
53
|
+
/** Index of the executable token after assignments and familiar wrappers. */
|
|
54
|
+
function headCursor(segment: readonly Token[]): number {
|
|
55
|
+
let cursor = 0;
|
|
56
|
+
while (cursor < segment.length) {
|
|
57
|
+
const value = segment[cursor].value;
|
|
58
|
+
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(value)) {
|
|
59
|
+
cursor++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const name = executable(value);
|
|
63
|
+
if (!WRAPPERS.includes(name)) break;
|
|
64
|
+
cursor++;
|
|
65
|
+
while (cursor < segment.length && segment[cursor].value.startsWith("-")) {
|
|
66
|
+
const flag = segment[cursor++].value;
|
|
67
|
+
if (flag === "--") break;
|
|
68
|
+
if (WRAPPER_VALUE_OPTIONS[name]?.includes(flag)) cursor++;
|
|
69
|
+
}
|
|
70
|
+
if (name === "timeout") cursor++;
|
|
71
|
+
}
|
|
72
|
+
return cursor;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// gentle-shell#2174: commands whose arguments are searched, printed or viewed
|
|
76
|
+
// text, never executed and never fed to an executor. `sed` is absent on
|
|
77
|
+
// purpose: GNU sed executes scripts through `e` with any delimiter or address.
|
|
78
|
+
// The exemption applies only when EVERY segment head is in DATA_COMMANDS or
|
|
79
|
+
// INERT_COMMANDS, so an unknown consumer (`| at now`, `&& ./x.sh`) keeps the
|
|
80
|
+
// lexical guard: a missing entry costs a dialog, never a bypass.
|
|
81
|
+
const DATA_COMMANDS = new Set([
|
|
82
|
+
"grep", "egrep", "fgrep", "rg", "ag", "ack", "echo", "printf", "cat", "head", "tail", "less", "more",
|
|
83
|
+
"wc", "sort", "uniq", "cut", "tr", "jq", "diff", "ls", "file", "stat",
|
|
84
|
+
]);
|
|
85
|
+
const INERT_COMMANDS = new Set(["cd", "pwd", "true", "false", "test", "[", "mkdir", "touch"]);
|
|
86
|
+
const HEREDOC_RE = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Byte ranges of the command that are plain arguments of a known data command
|
|
90
|
+
* (gentle-shell#2174). A guarded or denied pattern matching only inside these
|
|
91
|
+
* ranges is prose, not an operation. Returns no ranges, keeping every lexical
|
|
92
|
+
* match, when the command is opaque: command or process substitution, an
|
|
93
|
+
* interpreter or remote shell anywhere, or quoting the tokenizer cannot
|
|
94
|
+
* account for.
|
|
95
|
+
*/
|
|
96
|
+
export function dataArgumentRanges(command: string): Array<readonly [number, number]> {
|
|
97
|
+
if (/\$\(|`|[<>]\(/.test(command)) return [];
|
|
98
|
+
// A heredoc counts only when its `<<` starts an unquoted token of the raw
|
|
99
|
+
// command; quoted text such as `echo '<<x'` must not blank following lines.
|
|
100
|
+
const rawStarts = new Set(tokenize(command, false).filter((token) => token.raw.startsWith("<<")).map((token) => token.index));
|
|
101
|
+
const heredocs = [...command.matchAll(HEREDOC_RE)];
|
|
102
|
+
if (heredocs.some((heredoc) => !rawStarts.has(heredoc.index))) return [];
|
|
103
|
+
const blanked = blankHeredocs(command);
|
|
104
|
+
const tokens = tokenize(command);
|
|
105
|
+
const covered = new Uint8Array(blanked.length);
|
|
106
|
+
for (const token of tokens) covered.fill(1, token.index, token.index + token.raw.length);
|
|
107
|
+
for (let index = 0; index < blanked.length; index++) {
|
|
108
|
+
if (!covered[index] && !/\s/.test(blanked[index])) return [];
|
|
109
|
+
}
|
|
110
|
+
const ranges: Array<readonly [number, number]> = [];
|
|
111
|
+
let start = 0;
|
|
112
|
+
for (let end = 0; end <= tokens.length; end++) {
|
|
113
|
+
if (end < tokens.length && !tokens[end].operator) continue;
|
|
114
|
+
const segment = tokens.slice(start, end);
|
|
115
|
+
start = end + 1;
|
|
116
|
+
if (segment.length === 0) continue;
|
|
117
|
+
// Only plain assignments, optionally after a flagless `env`, may precede
|
|
118
|
+
// the head; every other wrapper or wrapper flag can change what runs.
|
|
119
|
+
let cursor = 0;
|
|
120
|
+
if (segment[0].value === "env") cursor++;
|
|
121
|
+
while (cursor < segment.length && /^[A-Za-z_][A-Za-z_0-9]*=/.test(segment[cursor].value)) cursor++;
|
|
122
|
+
const head = segment[cursor];
|
|
123
|
+
if (head === undefined || head.value !== head.raw) return [];
|
|
124
|
+
const name = head.value;
|
|
125
|
+
if (INERT_COMMANDS.has(name)) continue;
|
|
126
|
+
if (!DATA_COMMANDS.has(name)) return [];
|
|
127
|
+
ranges.push([head.index + head.raw.length, tokens[end]?.index ?? command.length]);
|
|
128
|
+
}
|
|
129
|
+
// A heredoc body belongs to the segment of its `<<` header, which the
|
|
130
|
+
// header's trailing newline would otherwise split off. Only the body is
|
|
131
|
+
// covered: the rest of the header line can hold further commands.
|
|
132
|
+
for (const heredoc of heredocs) {
|
|
133
|
+
const bodyStart = heredoc.index + heredoc[0].indexOf("\n") + 1;
|
|
134
|
+
if (ranges.some(([from, to]) => heredoc.index >= from && heredoc.index < to)) ranges.push([bodyStart, heredoc.index + heredoc[0].length]);
|
|
135
|
+
}
|
|
136
|
+
return ranges;
|
|
137
|
+
}
|
|
138
|
+
|
|
41
139
|
function sqlDestroys(sql: string): boolean {
|
|
42
140
|
// Mask quoted identifiers as non-keyword atoms, and remove comments and
|
|
43
141
|
// strings. A table named "where" is not a predicate or a statement boundary.
|
|
@@ -60,30 +158,8 @@ export function recognizeDestructiveCommands(command: string, depth = 0): Destru
|
|
|
60
158
|
for (let end = 0; end <= tokens.length; end++) {
|
|
61
159
|
if (end < tokens.length && !tokens[end].operator) continue;
|
|
62
160
|
const segment = tokens.slice(start, end);
|
|
63
|
-
let cursor = 0;
|
|
64
161
|
// Assignments and familiar executable wrappers preserve command position.
|
|
65
|
-
|
|
66
|
-
const value = segment[cursor].value;
|
|
67
|
-
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(value)) {
|
|
68
|
-
cursor++;
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
const name = executable(value);
|
|
72
|
-
if (!["env", "sudo", "command", "exec", "nohup", "timeout", "xargs"].includes(name)) break;
|
|
73
|
-
const valueOptions: Record<string, readonly string[]> = {
|
|
74
|
-
env: ["-u", "--unset", "-C", "--chdir"],
|
|
75
|
-
sudo: ["-u", "-g", "-h", "-p", "-C", "--user", "--group", "--host", "--prompt", "--chdir"],
|
|
76
|
-
timeout: ["-s", "--signal", "-k", "--kill-after"],
|
|
77
|
-
xargs: ["-n", "-I", "-P", "--max-args", "--replace", "--max-procs"],
|
|
78
|
-
};
|
|
79
|
-
cursor++;
|
|
80
|
-
while (cursor < segment.length && segment[cursor].value.startsWith("-")) {
|
|
81
|
-
const flag = segment[cursor++].value;
|
|
82
|
-
if (flag === "--") break;
|
|
83
|
-
if (valueOptions[name]?.includes(flag)) cursor++;
|
|
84
|
-
}
|
|
85
|
-
if (name === "timeout") cursor++;
|
|
86
|
-
}
|
|
162
|
+
const cursor = headCursor(segment);
|
|
87
163
|
const head = segment[cursor];
|
|
88
164
|
const name = executable(head?.value ?? "");
|
|
89
165
|
const args = segment.slice(cursor + 1).map((token) => token.value);
|
|
@@ -213,10 +213,26 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
|
|
|
213
213
|
// memory is allowed locally; otherwise the Shell-owned guard, so no provider,
|
|
214
214
|
// whatever loaded it, runs an Engram binary against an unverified store.
|
|
215
215
|
export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
|
|
216
|
-
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled:
|
|
216
|
+
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
|
|
217
217
|
// `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
|
|
218
218
|
export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
|
|
219
219
|
export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
|
|
220
|
+
// The first Engram release whose server answers GET /compatibility (PR #1792).
|
|
221
|
+
export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
|
|
222
|
+
export function engramOutdatedServerNotice(version: string): string {
|
|
223
|
+
return `Engram memory disabled: the running Engram server reports version ${version}, which cannot prove store compatibility. Back up your Engram data, update that server to ${ENGRAM_COMPATIBILITY_MIN_VERSION} or later with the method you installed it with, and restart it; updating Shell alone does not replace a running server. Shell rechecks on every launch and remains usable without memory.`;
|
|
224
|
+
}
|
|
225
|
+
// A canonical numeric x.y.z (optionally v-prefixed, no leading zeros) older than
|
|
226
|
+
// the minimum. The version comes from the server, so anything else is never
|
|
227
|
+
// echoed to the user.
|
|
228
|
+
function outdatedServerVersion(version: string | undefined): string | undefined {
|
|
229
|
+
const match = /^v?(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})$/.exec(version ?? "");
|
|
230
|
+
if (match === null) return undefined;
|
|
231
|
+
const parts = match.slice(1).map(Number);
|
|
232
|
+
const minimum = ENGRAM_COMPATIBILITY_MIN_VERSION.split(".").map(Number);
|
|
233
|
+
const index = parts.findIndex((part, i) => part !== minimum[i]);
|
|
234
|
+
return index >= 0 && parts[index] < minimum[index] ? parts.join(".") : undefined;
|
|
235
|
+
}
|
|
220
236
|
// The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
|
|
221
237
|
export const ENGRAM_DEFAULT_PORT = 7437;
|
|
222
238
|
export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
|
|
@@ -238,11 +254,13 @@ export function engramProbeAllowed(env: Env): boolean {
|
|
|
238
254
|
// Evidence only: never run an executable, open SQLite or infer compatibility
|
|
239
255
|
// from versions. `provenance` means Shell's own record matched the store
|
|
240
256
|
// (directory, .instance-id, core digest) and its header showed no newer schema.
|
|
257
|
+
// A server version only selects the wording of a disabled decision.
|
|
241
258
|
export function classifyEngramStartup(input: { env: Env; store: "absent" | "present" | "unknown"; endpoint: EngramProbe; core: boolean;
|
|
242
259
|
coreVersion: string; provenance?: { instanceId: string } }): EngramStartup {
|
|
243
|
-
|
|
244
|
-
if (!engramProbeAllowed(input.env)) return unknown;
|
|
260
|
+
if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
|
|
245
261
|
const endpoint = input.endpoint;
|
|
262
|
+
const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
|
|
263
|
+
const unknown: EngramStartup = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
|
|
246
264
|
if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
|
|
247
265
|
if (endpoint.kind === "refused" && input.store === "absent") {
|
|
248
266
|
return input.core ? { policy: "allow", fresh: true } : { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
|
|
@@ -333,9 +351,9 @@ export function engramProvenanceMatches(record: EngramProvenance | undefined, ob
|
|
|
333
351
|
}
|
|
334
352
|
|
|
335
353
|
// The bundled core's own schema (SQLite user_version), verified from the
|
|
336
|
-
// tagged
|
|
354
|
+
// tagged sources v3.3.2 and v3.4.0: internal/store/store.go `const schemaVersion = 1`.
|
|
337
355
|
// A pin without a verified entry leaves the header check unavailable.
|
|
338
|
-
const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1 };
|
|
356
|
+
const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1 };
|
|
339
357
|
|
|
340
358
|
export function bundledEngramSchema(coreVersion: string): number | undefined {
|
|
341
359
|
return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
|
|
@@ -392,7 +392,7 @@ export function missingPiMessage(): string {
|
|
|
392
392
|
|
|
393
393
|
// --- pi version gate ---------------------------------------------------------
|
|
394
394
|
|
|
395
|
-
export const MIN_PI_VERSION = "
|
|
395
|
+
export const MIN_PI_VERSION = "1.2.0";
|
|
396
396
|
|
|
397
397
|
export type PiVersionCheck = { ok: true; version: string } | { ok: false; message: string; version?: string };
|
|
398
398
|
|
|
@@ -28,6 +28,11 @@ export type ModelConfigFileResult =
|
|
|
28
28
|
export const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
|
|
29
29
|
const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/;
|
|
30
30
|
|
|
31
|
+
/** Shared agent-name policy; the same test normalizeModelConfig applies per key. */
|
|
32
|
+
export function isSafeAgentName(name: string): boolean {
|
|
33
|
+
return SAFE_AGENT_NAME_PATTERN.test(name);
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
37
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
38
|
}
|
|
@@ -78,7 +83,7 @@ export function normalizeModelConfig(value: unknown): AgentModelConfig | undefin
|
|
|
78
83
|
if (!isRecord(value)) return undefined;
|
|
79
84
|
const cleaned: AgentModelConfig = {};
|
|
80
85
|
for (const [name, entryValue] of Object.entries(value)) {
|
|
81
|
-
if (!
|
|
86
|
+
if (!isSafeAgentName(name)) continue;
|
|
82
87
|
const entry = normalizeRoutingEntry(entryValue);
|
|
83
88
|
if (entry) cleaned[name] = entry;
|
|
84
89
|
}
|
package/lib/selection-engine.ts
CHANGED
|
@@ -131,7 +131,10 @@ export function withReverseSpan(row: string, startCu: number, endCu: number): st
|
|
|
131
131
|
// A nested SGR reset (e.g. the cursor block's own, when the cursor
|
|
132
132
|
// sits inside the span) clears reverse for everything after it:
|
|
133
133
|
// re-arm reverse right after it.
|
|
134
|
-
|
|
134
|
+
// Pi 1.2 resolves the APC fake-cursor end to SGR 27 later in
|
|
135
|
+
// the host TUI. Preserve the marker for hardware-cursor suppression,
|
|
136
|
+
// but rearm our selection after it just as after a legacy reset.
|
|
137
|
+
if (opened && cu < endCu && (isSgrReset(seq) || seq === "\x1b_pi:/fc\x07")) out += "\x1b[7m";
|
|
135
138
|
i += seq.length;
|
|
136
139
|
continue;
|
|
137
140
|
}
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// The live resolution applies only in `follow` mode (GENTLE_PI_PROFILE_FOLLOW=1,
|
|
17
17
|
// for CI and headless runs), in subagent children (their routing comes from the
|
|
18
18
|
// parent through --model), and when no session id is available. In `follow`
|
|
19
|
-
// mode the orchestrator still
|
|
20
|
-
//
|
|
19
|
+
// mode the orchestrator is still chosen at startup while children follow later
|
|
20
|
+
// defaults: an accepted, documented limitation.
|
|
21
21
|
//
|
|
22
22
|
// The frozen state is in-process only, like the explicit binding of slice 2: it
|
|
23
23
|
// lives on `globalThis` behind a `Symbol.for` key because Pi evaluates this
|
|
@@ -114,19 +114,14 @@ function isChildSession(env: NodeJS.ProcessEnv): boolean {
|
|
|
114
114
|
* is never part of the routing. `undefined` means no layer has a profile.
|
|
115
115
|
*/
|
|
116
116
|
export function resolveInheritedProfile(options: InheritedProfileOptions): InheritedProfile | undefined {
|
|
117
|
-
// One worktree lookup serves both the pin layers and the non-Git check
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return identity;
|
|
126
|
-
};
|
|
127
|
-
const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree });
|
|
128
|
-
if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
|
|
129
|
-
if (readProfilePinStatus(options.cwd, resolveWorktree) === undefined) {
|
|
117
|
+
// One worktree lookup serves both the pin layers and the non-Git check,
|
|
118
|
+
// whether the caller passes a resolver or the pin module's default runs.
|
|
119
|
+
const status = readProfilePinStatus(options.cwd, options.resolveWorktree);
|
|
120
|
+
if (status !== undefined) {
|
|
121
|
+
const identity: WorktreeIdentity = { root: status.root, commonDir: status.commonDir };
|
|
122
|
+
const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree: () => identity });
|
|
123
|
+
if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
|
|
124
|
+
} else {
|
|
130
125
|
const declaration = resolveUnversionedProjectProfile(options.cwd, options.configHome);
|
|
131
126
|
if (declaration !== undefined) return { name: declaration.profile, origin: "repo", modelProfiles: declaration.modelProfiles };
|
|
132
127
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Standalone v1 codec and pure active-branch replay (gentle-shell#1064).
|
|
2
|
+
// No Pi API, disk access, shared binding store, or orchestrator application.
|
|
3
|
+
import { isValidProfileName } from "./agent-profiles.ts";
|
|
4
|
+
import {
|
|
5
|
+
isSafeAgentName,
|
|
6
|
+
isThinkingLevel,
|
|
7
|
+
normalizeModelId,
|
|
8
|
+
normalizeRoutingEntry,
|
|
9
|
+
type AgentModelConfig,
|
|
10
|
+
type AgentRoutingEntry,
|
|
11
|
+
} from "./model-routing-authority.ts";
|
|
12
|
+
|
|
13
|
+
export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1";
|
|
14
|
+
const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/";
|
|
15
|
+
|
|
16
|
+
export type SessionProfileOrigin = "user" | "local" | "repo" | "global";
|
|
17
|
+
|
|
18
|
+
export interface SessionProfileBindPayload {
|
|
19
|
+
kind: "bind";
|
|
20
|
+
origin: SessionProfileOrigin;
|
|
21
|
+
name: string;
|
|
22
|
+
modelProfiles: AgentModelConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionProfileClearPayload {
|
|
26
|
+
kind: "clear";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Public structural entry shape only; ordinary messages need no custom fields. */
|
|
30
|
+
export interface SessionProfileEntry {
|
|
31
|
+
type: string;
|
|
32
|
+
customType?: string;
|
|
33
|
+
data?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SessionProfileReadResult =
|
|
37
|
+
| { status: "absent" }
|
|
38
|
+
| { status: "bound"; binding: SessionProfileBindPayload }
|
|
39
|
+
| { status: "cleared" }
|
|
40
|
+
| { status: "invalid" }
|
|
41
|
+
| { status: "unsupported" };
|
|
42
|
+
|
|
43
|
+
export type SessionProfileReplayResult =
|
|
44
|
+
| { status: "absent" }
|
|
45
|
+
| (Exclude<SessionProfileReadResult, { status: "absent" }> & {
|
|
46
|
+
entryIndex: number;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
54
|
+
return Object.hasOwn(value, key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isOrigin(value: unknown): value is SessionProfileOrigin {
|
|
58
|
+
return (
|
|
59
|
+
value === "user" ||
|
|
60
|
+
value === "local" ||
|
|
61
|
+
value === "repo" ||
|
|
62
|
+
value === "global"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validate known fields before the permissive routing helper can drop them. */
|
|
67
|
+
function readRoute(
|
|
68
|
+
value: unknown,
|
|
69
|
+
encoderInput = false,
|
|
70
|
+
): AgentRoutingEntry | undefined {
|
|
71
|
+
if (typeof value === "string") return normalizeRoutingEntry(value);
|
|
72
|
+
if (!isRecord(value)) return undefined;
|
|
73
|
+
// Typed normalized routes can own optional undefined fields. Omit only those
|
|
74
|
+
// encoder fields; persisted fields and legacy effort retain strict validation.
|
|
75
|
+
const route = encoderInput
|
|
76
|
+
? Object.fromEntries(
|
|
77
|
+
Object.entries(value).filter(
|
|
78
|
+
([key, field]) =>
|
|
79
|
+
!((key === "model" || key === "thinking") && field === undefined),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
: value;
|
|
83
|
+
if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking))
|
|
86
|
+
return undefined;
|
|
87
|
+
if (hasOwn(route, "effort") && !isThinkingLevel(route.effort))
|
|
88
|
+
return undefined;
|
|
89
|
+
// Project only own known fields. Unknown extras, even an unknown-only route,
|
|
90
|
+
// carry no routing meaning. Thinking wins over legacy effort as elsewhere.
|
|
91
|
+
const known = Object.fromEntries(
|
|
92
|
+
["model", "thinking", "effort"]
|
|
93
|
+
.filter((key) => hasOwn(route, key))
|
|
94
|
+
.map((key) => [key, route[key]]),
|
|
95
|
+
);
|
|
96
|
+
const normalized = normalizeRoutingEntry(known);
|
|
97
|
+
if (!normalized) return undefined;
|
|
98
|
+
return Object.fromEntries(
|
|
99
|
+
Object.entries(normalized).filter(([, field]) => field !== undefined),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readSnapshot(
|
|
104
|
+
value: unknown,
|
|
105
|
+
encoderInput = false,
|
|
106
|
+
): AgentModelConfig | undefined {
|
|
107
|
+
if (!isRecord(value)) return undefined;
|
|
108
|
+
const entries: Array<[string, AgentRoutingEntry]> = [];
|
|
109
|
+
for (const [name, rawRoute] of Object.entries(value)) {
|
|
110
|
+
// Shared agent-name policy, applied here without the forgiving whole-config
|
|
111
|
+
// normalization that would silently discard a bad route. Output below uses
|
|
112
|
+
// own data properties, never prototype setters.
|
|
113
|
+
if (!isSafeAgentName(name)) return undefined;
|
|
114
|
+
const route = readRoute(rawRoute, encoderInput);
|
|
115
|
+
if (route === undefined) return undefined;
|
|
116
|
+
entries.push([name, route]);
|
|
117
|
+
}
|
|
118
|
+
return Object.fromEntries(entries);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readBind(
|
|
122
|
+
data: Record<string, unknown>,
|
|
123
|
+
encoderInput = false,
|
|
124
|
+
): SessionProfileBindPayload | undefined {
|
|
125
|
+
if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key)))
|
|
126
|
+
return undefined;
|
|
127
|
+
if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined;
|
|
128
|
+
const modelProfiles = readSnapshot(data.modelProfiles, encoderInput);
|
|
129
|
+
if (modelProfiles === undefined) return undefined;
|
|
130
|
+
return { kind: "bind", origin: data.origin, name: data.name, modelProfiles };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Create a detached user selection payload; never appends or claims durability. */
|
|
134
|
+
export function createSessionProfileBind(
|
|
135
|
+
name: string,
|
|
136
|
+
modelProfiles: AgentModelConfig,
|
|
137
|
+
): SessionProfileBindPayload {
|
|
138
|
+
const binding = readBind({ origin: "user", name, modelProfiles }, true);
|
|
139
|
+
if (!binding) throw new TypeError("Invalid session profile binding");
|
|
140
|
+
return binding;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createSessionProfileClear(): SessionProfileClearPayload {
|
|
144
|
+
return { kind: "clear" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Unknown family identifiers are terminal unsupported states, regardless of data. */
|
|
148
|
+
export function readSessionProfileEntry(
|
|
149
|
+
entry: SessionProfileEntry,
|
|
150
|
+
): SessionProfileReadResult {
|
|
151
|
+
if (
|
|
152
|
+
entry.type !== "custom" ||
|
|
153
|
+
typeof entry.customType !== "string" ||
|
|
154
|
+
!entry.customType.startsWith(SESSION_PROFILE_FAMILY)
|
|
155
|
+
)
|
|
156
|
+
return { status: "absent" };
|
|
157
|
+
if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE)
|
|
158
|
+
return { status: "unsupported" };
|
|
159
|
+
const data = entry.data;
|
|
160
|
+
if (!isRecord(data) || !hasOwn(data, "kind")) return { status: "invalid" };
|
|
161
|
+
if (data.kind === "clear") return { status: "cleared" };
|
|
162
|
+
if (data.kind !== "bind") return { status: "invalid" };
|
|
163
|
+
const binding = readBind(data);
|
|
164
|
+
return binding ? { status: "bound", binding } : { status: "invalid" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Caller MUST supply ALREADY disk-corroborated active-branch entries, in branch
|
|
169
|
+
* order (oldest to newest). This function cannot establish that precondition:
|
|
170
|
+
* it does not read disk, call Pi, enforce append-failure recovery, establish
|
|
171
|
+
* durability, or update the shared map. In-memory getBranch() alone is NOT
|
|
172
|
+
* corroboration. Sibling and future paths must be excluded by the caller.
|
|
173
|
+
*
|
|
174
|
+
* The newest family entry wins even when invalid or unsupported; never revive
|
|
175
|
+
* an older binding. Cleared permits current fallback at a later consumer,
|
|
176
|
+
* whereas invalid/unsupported must remain distinct from absent. entryIndex is
|
|
177
|
+
* the actual supplied branch offset for a later adapter's location warning.
|
|
178
|
+
*/
|
|
179
|
+
export function replaySessionProfileBranch(
|
|
180
|
+
entries: readonly SessionProfileEntry[],
|
|
181
|
+
): SessionProfileReplayResult {
|
|
182
|
+
for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) {
|
|
183
|
+
const result = readSessionProfileEntry(entries[entryIndex]);
|
|
184
|
+
if (result.status !== "absent") return { ...result, entryIndex };
|
|
185
|
+
}
|
|
186
|
+
return { status: "absent" };
|
|
187
|
+
}
|
package/lib/shell-changes.ts
CHANGED
|
@@ -110,6 +110,8 @@ export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
|
|
|
110
110
|
const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
|
|
111
111
|
const files: ChangedFile[] = [];
|
|
112
112
|
for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
|
|
113
|
+
// Dependency backups are local artifacts; tracked files remain visible.
|
|
114
|
+
if (status === CHANGE_STATUS.UNTRACKED && /(?:^|\/)node_modules\.backup-[^/]+\//.test(path)) continue;
|
|
113
115
|
const entry = counts.get(path);
|
|
114
116
|
files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
|
|
115
117
|
}
|
package/lib/shell-prompt.ts
CHANGED
|
@@ -74,7 +74,9 @@ export const DOUBLE_ESC_CANCEL_HINT = "esc again to cancel";
|
|
|
74
74
|
export const IDLE_ESC_CLEAR_HINT = "esc again to clear";
|
|
75
75
|
const LABEL_ROLE = "muted";
|
|
76
76
|
const HINT_ROLE = "dim";
|
|
77
|
-
|
|
77
|
+
// Audited legacy SGR and Pi 1.2 APC cursors. Do not resolve the latter
|
|
78
|
+
// here: the host TUI owns hardware-cursor suppression after composition.
|
|
79
|
+
const EMPTY_FAKE_CURSORS = ["\x1b[7m \x1b[0m", "\x1b_pi:fc\x07 \x1b_pi:/fc\x07"];
|
|
78
80
|
const SCROLL_INDICATOR = /[↑↓] \d+ more/;
|
|
79
81
|
const STATE_LABEL: Record<PromptState, string | undefined> = {
|
|
80
82
|
[PROMPT_STATE.IDLE]: undefined,
|
|
@@ -228,9 +230,9 @@ export function framePromptLines(lines: string[], width: number, options: Prompt
|
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
233
|
-
const afterCursor =
|
|
233
|
+
const cursor = EMPTY_FAKE_CURSORS.find((marker) => line.includes(marker));
|
|
234
|
+
if (!cursor) return line;
|
|
235
|
+
const afterCursor = line.indexOf(cursor) + cursor.length;
|
|
234
236
|
const trailing = line.slice(afterCursor);
|
|
235
237
|
if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
|
|
236
238
|
return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
|
package/lib/shell-todo.ts
CHANGED
|
@@ -258,16 +258,28 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
|
|
|
258
258
|
return state;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
// gentle-shell#1367, #1662: the system prompt is a cached prefix for every
|
|
262
|
+
// provider, so the block it carries is byte-stable for the whole session:
|
|
263
|
+
// rules only, no task content and no counters. The current list travels as a
|
|
264
|
+
// request-local message at the end of the conversation (todoStateMessage),
|
|
265
|
+
// where a change costs only the tail of the request, never the prefix.
|
|
266
|
+
const TODO_PROMPT_BLOCK = [
|
|
267
|
+
"## Todo list",
|
|
268
|
+
"Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
|
|
269
|
+
"A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
|
|
270
|
+
"While tasks stay open, the current list arrives as the last message of each request; it is not repeated here.",
|
|
271
|
+
].join("\n");
|
|
272
|
+
|
|
273
|
+
/** The byte-stable rules block appended to every system prompt. */
|
|
274
|
+
export function todoPromptBlock(): string {
|
|
275
|
+
return TODO_PROMPT_BLOCK;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The open-task snapshot delivered as a request-local message; undefined when nothing is open. */
|
|
279
|
+
export function todoStateMessage(state: TodoState): string | undefined {
|
|
262
280
|
if (todoSummary(state).open === 0) return undefined;
|
|
263
281
|
const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
|
|
264
|
-
|
|
265
|
-
return [
|
|
266
|
-
"## Todo list",
|
|
267
|
-
"Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
|
|
268
|
-
"A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
|
|
269
|
-
...lines,
|
|
270
|
-
].join("\n") + staleLine;
|
|
282
|
+
return ["Current todo list:", ...lines].join("\n");
|
|
271
283
|
}
|
|
272
284
|
|
|
273
285
|
function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
|
|
4
|
-
// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0 and 1.
|
|
4
|
+
// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0 and 1.2.0:
|
|
5
5
|
// editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry,
|
|
6
6
|
// autocomplete cancellation and { state, pastes, pasteCounter } undo restoration;
|
|
7
7
|
// undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical
|
|
@@ -13,6 +13,10 @@ import { createRequire } from "node:module";
|
|
|
13
13
|
// Actual installed bundled/unbundled tests exercise identity, edit/undo, paste,
|
|
14
14
|
// selection, wrapping/scroll and autocomplete; fabricated metadata tests only
|
|
15
15
|
// prove exact-version admission for the older audited releases.
|
|
16
|
+
// 1.2.0 changes only editor cursor rendering to paired pi:fc/pi:/fc APC
|
|
17
|
+
// markers resolved by the host TUI (including hardware-cursor suppression).
|
|
18
|
+
// Keep these markers intact and rearm selection after their reverse-off;
|
|
19
|
+
// state, undo, paste, layout and autocomplete contracts remain unchanged.
|
|
16
20
|
// Never silently adapt another build: undo also owns the paste registry.
|
|
17
21
|
interface Position { line: number; col: number }
|
|
18
22
|
interface State { lines: string[]; cursorLine: number; cursorCol: number }
|
|
@@ -51,7 +55,7 @@ interface PrivateEditor {
|
|
|
51
55
|
|
|
52
56
|
// The single audited-release source for the adapter and Gentle Shell's runtime
|
|
53
57
|
// identity gates. Add a release only after re-auditing the files named above.
|
|
54
|
-
export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0"] as const);
|
|
58
|
+
export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"] as const);
|
|
55
59
|
export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number];
|
|
56
60
|
|
|
57
61
|
export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion {
|
|
@@ -83,7 +87,7 @@ function hasEditorIdentity(value: unknown, version: string, editorClass: typeof
|
|
|
83
87
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
84
88
|
const marker = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g;
|
|
85
89
|
|
|
86
|
-
// Only CSI SGR and Pi's own zero-width cursor
|
|
90
|
+
// Only CSI SGR and Pi's own zero-width cursor markers are expected in editor
|
|
87
91
|
// text. Unknown escapes leave the unmodified frame intact (fail closed).
|
|
88
92
|
function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, right: number, padding: number): string | undefined {
|
|
89
93
|
const cursor = chunk.hasCursor ? chunk.cursorPos : undefined;
|
|
@@ -114,8 +118,22 @@ function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, righ
|
|
|
114
118
|
result += token;
|
|
115
119
|
if (selected && !inverse) { result += "\x1b[7m"; inverse = true; }
|
|
116
120
|
} else if (token.startsWith("\x1b")) {
|
|
117
|
-
if (token !== CURSOR_MARKER) return undefined;
|
|
121
|
+
if (token !== CURSOR_MARKER && token !== "\x1b_pi:fc\x07" && token !== "\x1b_pi:/fc\x07") return undefined;
|
|
122
|
+
// Close selection *before* the cursor pair, never inside its cell:
|
|
123
|
+
// otherwise a later host SGR 7 would immediately meet our SGR 27.
|
|
124
|
+
// Closing before CURSOR_MARKER also keeps it adjacent to pi:fc so
|
|
125
|
+
// hardware-cursor suppression can still recognize the focused pair.
|
|
126
|
+
const local = col - padding;
|
|
127
|
+
if ((token === CURSOR_MARKER || token === "\x1b_pi:fc\x07") && selected &&
|
|
128
|
+
!(local >= left && local < right && local < chunk.text.length)) {
|
|
129
|
+
if (!nativeInverse && inverse) result += "\x1b[27m";
|
|
130
|
+
selected = false;
|
|
131
|
+
inverse = nativeInverse;
|
|
132
|
+
}
|
|
118
133
|
result += token;
|
|
134
|
+
// The host resolves pi:/fc to SGR 27, or drops the pair when the
|
|
135
|
+
// hardware cursor owns this cell. Selection must survive either path.
|
|
136
|
+
if (token === "\x1b_pi:/fc\x07" && selected) result += "\x1b[7m";
|
|
119
137
|
} else {
|
|
120
138
|
const local = col - padding;
|
|
121
139
|
const shouldSelect = local >= left && local < right && local < chunk.text.length;
|