@oh-my-pi/pi-coding-agent 15.2.2 → 15.2.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.
- package/CHANGELOG.md +49 -1
- package/dist/types/cli/worktree-cli.d.ts +26 -0
- package/dist/types/commands/worktree.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +23 -0
- package/dist/types/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/hash.d.ts +13 -39
- package/dist/types/hashline/parser.d.ts +2 -6
- package/dist/types/modes/shared.d.ts +9 -0
- package/dist/types/modes/theme/shimmer.d.ts +21 -10
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/yield-queue.d.ts +24 -0
- package/dist/types/slash-commands/helpers/format.d.ts +1 -1
- package/dist/types/task/worktree.d.ts +0 -1
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +7 -7
- package/src/autoresearch/storage.ts +14 -2
- package/src/cli/worktree-cli.ts +291 -0
- package/src/cli.ts +1 -0
- package/src/commands/worktree.ts +56 -0
- package/src/config/prompt-templates.ts +1 -8
- package/src/config/settings-schema.ts +16 -0
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +5 -7
- package/src/edit/streaming.ts +24 -12
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/grammar.lark +7 -8
- package/src/hashline/hash.ts +21 -43
- package/src/hashline/input.ts +15 -13
- package/src/hashline/parser.ts +62 -161
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/components/mcp-add-wizard.ts +4 -3
- package/src/modes/components/settings-selector.ts +23 -10
- package/src/modes/components/welcome.ts +77 -35
- package/src/modes/controllers/event-controller.ts +2 -1
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/interactive-mode.ts +51 -10
- package/src/modes/shared.ts +16 -0
- package/src/modes/theme/shimmer.ts +173 -33
- package/src/modes/utils/ui-helpers.ts +31 -13
- package/src/prompts/tools/async-result.md +5 -2
- package/src/prompts/tools/hashline.md +62 -81
- package/src/sdk.ts +95 -21
- package/src/session/agent-session.ts +22 -0
- package/src/session/yield-queue.ts +155 -0
- package/src/slash-commands/helpers/format.ts +4 -1
- package/src/task/worktree.ts +2 -7
- package/src/tools/gh.ts +35 -32
- package/src/utils/commit-message-generator.ts +6 -1
- package/src/utils/git.ts +4 -0
- package/src/utils/title-generator.ts +45 -13
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI handler for `omp worktree` — list and clean up agent-managed worktrees.
|
|
3
|
+
*
|
|
4
|
+
* Layout under `~/.omp/wt/`:
|
|
5
|
+
*
|
|
6
|
+
* - **PR-checkout worktrees** (`tools/gh.ts`): a regular git worktree dir
|
|
7
|
+
* containing a `.git` *file* that points back at
|
|
8
|
+
* `<parent-repo>/.git/worktrees/<name>/`.
|
|
9
|
+
* - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
|
|
10
|
+
* `merged` subdir mounted/cloned by `natives.isoStart`. These are ephemeral
|
|
11
|
+
* — `ensureIsolation` always `rm -rf`s the base before re-creating it, so
|
|
12
|
+
* any leftover on disk is a leak from a crashed run.
|
|
13
|
+
*
|
|
14
|
+
* Legacy entries from before the encoding change keep working because git still
|
|
15
|
+
* tracks them by branch name. This command exists to GC them on demand.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs/promises";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-utils";
|
|
20
|
+
import chalk from "chalk";
|
|
21
|
+
import * as git from "../utils/git";
|
|
22
|
+
|
|
23
|
+
type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
|
|
24
|
+
|
|
25
|
+
export interface WorktreeEntry {
|
|
26
|
+
/** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */
|
|
27
|
+
path: string;
|
|
28
|
+
/** Classification of what we found on disk. */
|
|
29
|
+
kind: WorktreeKind;
|
|
30
|
+
/** Parent repo root, when this is a registered git worktree. */
|
|
31
|
+
parentRepo?: string;
|
|
32
|
+
/** Branch name extracted from the parent's tracking file, when available. */
|
|
33
|
+
branch?: string;
|
|
34
|
+
/** When set, the entry is unhealthy and `omp worktree clear` will remove it. */
|
|
35
|
+
orphanReason?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ListWorktreesOptions {
|
|
39
|
+
json: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ClearWorktreesOptions {
|
|
43
|
+
/** Remove every entry, including live PR-checkout worktrees. */
|
|
44
|
+
all: boolean;
|
|
45
|
+
/** Print what would be removed without touching the filesystem. */
|
|
46
|
+
dryRun: boolean;
|
|
47
|
+
json: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listWorktrees(options: ListWorktreesOptions): Promise<void> {
|
|
51
|
+
const entries = await scanWorktrees();
|
|
52
|
+
if (options.json) {
|
|
53
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (entries.length === 0) {
|
|
57
|
+
console.log(chalk.dim(`No agent-managed worktrees found under ${getWorktreesDir()}.`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let live = 0;
|
|
61
|
+
let orphaned = 0;
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const tag = entry.orphanReason ? chalk.yellow("orphaned") : chalk.green("live ");
|
|
64
|
+
const detail = formatEntryDetail(entry);
|
|
65
|
+
console.log(`${tag} ${entry.path}`);
|
|
66
|
+
if (detail) console.log(` ${chalk.dim(detail)}`);
|
|
67
|
+
if (entry.orphanReason) orphaned += 1;
|
|
68
|
+
else live += 1;
|
|
69
|
+
}
|
|
70
|
+
console.log(chalk.dim(`\n${live} live · ${orphaned} orphaned · ${entries.length} total`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function clearWorktrees(options: ClearWorktreesOptions): Promise<void> {
|
|
74
|
+
const entries = await scanWorktrees();
|
|
75
|
+
const targets = options.all ? entries : entries.filter(entry => entry.orphanReason !== undefined);
|
|
76
|
+
|
|
77
|
+
if (targets.length === 0) {
|
|
78
|
+
if (options.json) {
|
|
79
|
+
console.log(JSON.stringify({ removed: 0, kept: entries.length }));
|
|
80
|
+
} else {
|
|
81
|
+
console.log(chalk.dim(options.all ? "No worktrees to remove." : "No orphaned worktrees to remove."));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.dryRun) {
|
|
87
|
+
if (options.json) {
|
|
88
|
+
console.log(JSON.stringify({ wouldRemove: targets.map(t => t.path) }, null, 2));
|
|
89
|
+
} else {
|
|
90
|
+
for (const target of targets) {
|
|
91
|
+
console.log(`${chalk.yellow("would remove")} ${target.path}`);
|
|
92
|
+
}
|
|
93
|
+
console.log(chalk.dim(`\n${targets.length} dir${targets.length === 1 ? "" : "s"} would be removed.`));
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const results: { path: string; ok: boolean; error?: string }[] = [];
|
|
99
|
+
const parentsToPrune = new Set<string>();
|
|
100
|
+
for (const target of targets) {
|
|
101
|
+
try {
|
|
102
|
+
if (target.kind === "pr-checkout" && target.parentRepo && !target.orphanReason) {
|
|
103
|
+
// Live worktree: ask git to remove it cleanly. If git refuses (locked,
|
|
104
|
+
// dirty, etc.), fall back to fs.rm and rely on `worktree prune` to
|
|
105
|
+
// clean the bookkeeping on the parent side.
|
|
106
|
+
const removed = await git.worktree.tryRemove(target.parentRepo, target.path, { force: true });
|
|
107
|
+
if (!removed) {
|
|
108
|
+
await fs.rm(target.path, { recursive: true, force: true });
|
|
109
|
+
parentsToPrune.add(target.parentRepo);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
await fs.rm(target.path, { recursive: true, force: true });
|
|
113
|
+
if (target.parentRepo) parentsToPrune.add(target.parentRepo);
|
|
114
|
+
}
|
|
115
|
+
results.push({ path: target.path, ok: true });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
results.push({ path: target.path, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Best-effort: drop stale entries from each affected parent's `.git/worktrees/`.
|
|
122
|
+
for (const parent of parentsToPrune) {
|
|
123
|
+
try {
|
|
124
|
+
await git.worktree.prune(parent);
|
|
125
|
+
} catch {
|
|
126
|
+
/* parent repo may already be gone or pruned — ignore */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const succeeded = results.filter(r => r.ok).length;
|
|
131
|
+
const failed = results.length - succeeded;
|
|
132
|
+
|
|
133
|
+
if (options.json) {
|
|
134
|
+
console.log(JSON.stringify({ removed: succeeded, failed, results }, null, 2));
|
|
135
|
+
if (failed > 0) process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const result of results) {
|
|
140
|
+
if (result.ok) {
|
|
141
|
+
console.log(`${chalk.green("removed")} ${result.path}`);
|
|
142
|
+
} else {
|
|
143
|
+
console.log(`${chalk.red("failed ")} ${result.path}`);
|
|
144
|
+
if (result.error) console.log(` ${chalk.dim(result.error)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log(chalk.dim(`\n${succeeded} removed${failed > 0 ? ` · ${chalk.red(`${failed} failed`)}` : ""}`));
|
|
148
|
+
if (failed > 0) process.exitCode = 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
152
|
+
// Scanner
|
|
153
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async function scanWorktrees(): Promise<WorktreeEntry[]> {
|
|
156
|
+
const root = getWorktreesDir();
|
|
157
|
+
let topLevel: string[];
|
|
158
|
+
try {
|
|
159
|
+
topLevel = await fs.readdir(root);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (isEnoent(err)) return [];
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const entries: WorktreeEntry[] = [];
|
|
166
|
+
for (const name of topLevel) {
|
|
167
|
+
const dir = path.join(root, name);
|
|
168
|
+
const stat = await fs.stat(dir).catch(() => null);
|
|
169
|
+
if (!stat?.isDirectory()) continue;
|
|
170
|
+
|
|
171
|
+
const direct = await classifyDir(dir);
|
|
172
|
+
if (direct) {
|
|
173
|
+
entries.push(direct);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Legacy nesting: ~/.omp/wt/<encoded-project>/<branch-or-id>
|
|
178
|
+
let children: string[];
|
|
179
|
+
try {
|
|
180
|
+
children = await fs.readdir(dir);
|
|
181
|
+
} catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let nested = 0;
|
|
185
|
+
for (const child of children) {
|
|
186
|
+
const childDir = path.join(dir, child);
|
|
187
|
+
const childStat = await fs.stat(childDir).catch(() => null);
|
|
188
|
+
if (!childStat?.isDirectory()) continue;
|
|
189
|
+
const childClassified = await classifyDir(childDir);
|
|
190
|
+
if (childClassified) {
|
|
191
|
+
entries.push(childClassified);
|
|
192
|
+
nested += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (nested === 0) {
|
|
196
|
+
entries.push({
|
|
197
|
+
path: dir,
|
|
198
|
+
kind: children.length === 0 ? "empty" : "stray",
|
|
199
|
+
orphanReason: children.length === 0 ? "empty directory" : "no recognizable worktree contents",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
|
|
207
|
+
const gitEntry = path.join(dir, ".git");
|
|
208
|
+
const gitStat = await fs.stat(gitEntry).catch(() => null);
|
|
209
|
+
if (gitStat?.isFile()) {
|
|
210
|
+
return classifyPrCheckout(dir, gitEntry);
|
|
211
|
+
}
|
|
212
|
+
const mergedStat = await fs.stat(path.join(dir, "merged")).catch(() => null);
|
|
213
|
+
if (mergedStat?.isDirectory()) {
|
|
214
|
+
return {
|
|
215
|
+
path: dir,
|
|
216
|
+
kind: "task-isolation",
|
|
217
|
+
orphanReason: "task-isolation leftover (no live task owns it)",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function classifyPrCheckout(dir: string, gitEntry: string): Promise<WorktreeEntry> {
|
|
224
|
+
let contents: string;
|
|
225
|
+
try {
|
|
226
|
+
contents = await fs.readFile(gitEntry, "utf8");
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return {
|
|
229
|
+
path: dir,
|
|
230
|
+
kind: "pr-checkout",
|
|
231
|
+
orphanReason: `cannot read .git file: ${err instanceof Error ? err.message : String(err)}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(contents);
|
|
235
|
+
const parentGitDir = match?.[1];
|
|
236
|
+
if (!parentGitDir) {
|
|
237
|
+
return { path: dir, kind: "pr-checkout", orphanReason: "malformed .git file (no gitdir line)" };
|
|
238
|
+
}
|
|
239
|
+
// parentGitDir is `<parent-repo>/.git/worktrees/<name>`; back out the repo root.
|
|
240
|
+
const parentRepo = path.dirname(path.dirname(path.dirname(parentGitDir)));
|
|
241
|
+
const branch = await readWorktreeBranch(path.join(parentGitDir, "HEAD"));
|
|
242
|
+
|
|
243
|
+
const parentDirStat = await fs.stat(parentGitDir).catch(() => null);
|
|
244
|
+
if (!parentDirStat?.isDirectory()) {
|
|
245
|
+
return {
|
|
246
|
+
path: dir,
|
|
247
|
+
kind: "pr-checkout",
|
|
248
|
+
parentRepo,
|
|
249
|
+
branch,
|
|
250
|
+
orphanReason: "parent repo no longer tracks this worktree",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const parentRepoStat = await fs.stat(parentRepo).catch(() => null);
|
|
254
|
+
if (!parentRepoStat?.isDirectory()) {
|
|
255
|
+
return {
|
|
256
|
+
path: dir,
|
|
257
|
+
kind: "pr-checkout",
|
|
258
|
+
parentRepo,
|
|
259
|
+
branch,
|
|
260
|
+
orphanReason: "parent repo missing",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return { path: dir, kind: "pr-checkout", parentRepo, branch };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function readWorktreeBranch(headFile: string): Promise<string | undefined> {
|
|
267
|
+
try {
|
|
268
|
+
const head = (await fs.readFile(headFile, "utf8")).trim();
|
|
269
|
+
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
270
|
+
return refMatch?.[1];
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatEntryDetail(entry: WorktreeEntry): string {
|
|
277
|
+
const parts: string[] = [];
|
|
278
|
+
if (entry.kind === "pr-checkout") {
|
|
279
|
+
const repo = entry.parentRepo ? path.basename(entry.parentRepo) : "unknown repo";
|
|
280
|
+
const branch = entry.branch ?? "unknown branch";
|
|
281
|
+
parts.push(`${repo} · ${branch}`);
|
|
282
|
+
} else if (entry.kind === "task-isolation") {
|
|
283
|
+
parts.push("task-isolation sandbox");
|
|
284
|
+
} else if (entry.kind === "empty") {
|
|
285
|
+
parts.push("legacy project shell");
|
|
286
|
+
} else {
|
|
287
|
+
parts.push("unrecognized contents");
|
|
288
|
+
}
|
|
289
|
+
if (entry.orphanReason) parts.push(entry.orphanReason);
|
|
290
|
+
return parts.join(" — ");
|
|
291
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,7 @@ const commands: CommandEntry[] = [
|
|
|
46
46
|
{ name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
|
|
47
47
|
{ name: "stats", load: () => import("./commands/stats").then(m => m.default) },
|
|
48
48
|
{ name: "update", load: () => import("./commands/update").then(m => m.default) },
|
|
49
|
+
{ name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
|
|
49
50
|
{ name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
|
|
50
51
|
];
|
|
51
52
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List and clean up agent-managed git worktrees under `~/.omp/wt`.
|
|
3
|
+
*/
|
|
4
|
+
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
|
5
|
+
import { clearWorktrees, listWorktrees } from "../cli/worktree-cli";
|
|
6
|
+
|
|
7
|
+
export default class Worktree extends Command {
|
|
8
|
+
static description = "List or clear agent-managed git worktrees (~/.omp/wt)";
|
|
9
|
+
|
|
10
|
+
static aliases = ["wt"];
|
|
11
|
+
|
|
12
|
+
static args = {
|
|
13
|
+
// `list` (default) inspects the worktree dir; `clear` removes entries.
|
|
14
|
+
// A positional action keeps `omp worktree` (the no-arg form) useful.
|
|
15
|
+
action: Args.string({
|
|
16
|
+
description: "list (default) or clear",
|
|
17
|
+
required: false,
|
|
18
|
+
options: ["list", "clear"],
|
|
19
|
+
default: "list",
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
static flags = {
|
|
24
|
+
all: Flags.boolean({
|
|
25
|
+
description: "Clear every entry, including live PR-checkout worktrees (clear)",
|
|
26
|
+
default: false,
|
|
27
|
+
}),
|
|
28
|
+
"dry-run": Flags.boolean({
|
|
29
|
+
char: "n",
|
|
30
|
+
description: "Print what would be removed without touching the filesystem (clear)",
|
|
31
|
+
default: false,
|
|
32
|
+
}),
|
|
33
|
+
json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: false }),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
static examples = [
|
|
37
|
+
"omp worktree",
|
|
38
|
+
"omp worktree list --json",
|
|
39
|
+
"omp worktree clear",
|
|
40
|
+
"omp worktree clear --dry-run",
|
|
41
|
+
"omp worktree clear --all",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
async run(): Promise<void> {
|
|
45
|
+
const { args, flags } = await this.parse(Worktree);
|
|
46
|
+
if (args.action === "clear") {
|
|
47
|
+
await clearWorktrees({
|
|
48
|
+
all: flags.all ?? false,
|
|
49
|
+
dryRun: flags["dry-run"] ?? false,
|
|
50
|
+
json: flags.json ?? false,
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await listWorktrees({ json: flags.json ?? false });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
parseFrontmatter,
|
|
9
9
|
prompt,
|
|
10
10
|
} from "@oh-my-pi/pi-utils";
|
|
11
|
-
import { computeLineHash, HL_BODY_SEP
|
|
11
|
+
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
|
|
12
12
|
import { jtdToTypeScript } from "../tools/jtd-to-typescript";
|
|
13
13
|
import { parseCommandArgs, substituteArgs } from "../utils/command-args";
|
|
14
14
|
|
|
@@ -154,13 +154,6 @@ prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): str
|
|
|
154
154
|
return `${ref}${HL_BODY_SEP}${text}`;
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
/**
|
|
158
|
-
* {{hsep}} — emit the configured hashline payload separator character.
|
|
159
|
-
* Stays in sync with {@link HL_EDIT_SEP} so edit prompt templates
|
|
160
|
-
* never have to hardcode the payload separator.
|
|
161
|
-
*/
|
|
162
|
-
prompt.registerHelper("hsep", (): string => HL_EDIT_SEP);
|
|
163
|
-
|
|
164
157
|
const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
|
|
165
158
|
const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
|
|
166
159
|
|
|
@@ -601,6 +601,22 @@ export const SETTINGS_SCHEMA = {
|
|
|
601
601
|
default: 3,
|
|
602
602
|
},
|
|
603
603
|
|
|
604
|
+
"display.shimmer": {
|
|
605
|
+
type: "enum",
|
|
606
|
+
values: ["classic", "kitt", "disabled"] as const,
|
|
607
|
+
default: "classic",
|
|
608
|
+
ui: {
|
|
609
|
+
tab: "appearance",
|
|
610
|
+
label: "Shimmer",
|
|
611
|
+
description: "Animation style for working/loading messages",
|
|
612
|
+
options: [
|
|
613
|
+
{ value: "classic", label: "Classic", description: "Soft cosine wave sweeping across the text" },
|
|
614
|
+
{ value: "kitt", label: "KITT Scanner", description: "Knight Rider 1982 red light bouncing left-right" },
|
|
615
|
+
{ value: "disabled", label: "Disabled", description: "No animation; static muted text" },
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
|
|
604
620
|
"display.showTokenUsage": {
|
|
605
621
|
type: "boolean",
|
|
606
622
|
default: false,
|
package/src/edit/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ export * from "./apply-patch";
|
|
|
35
35
|
export * from "./diff";
|
|
36
36
|
export * from "./file-read-cache";
|
|
37
37
|
|
|
38
|
-
// Resolve the `$HFMT
|
|
38
|
+
// Resolve the `$HFMT$`, `$HOP_*$`, `$HOP_CHARS$`, and `$HFILE$` placeholders in the hashline Lark grammar.
|
|
39
39
|
const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
|
|
40
40
|
|
|
41
41
|
export * from "../hashline";
|
package/src/edit/renderer.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
6
6
|
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
9
|
+
import { HL_FILE_PREFIX } from "../hashline/hash";
|
|
9
10
|
import type { FileDiagnosticsResult } from "../lsp";
|
|
10
11
|
import { renderDiff as renderDiffColored } from "../modes/components/diff";
|
|
11
12
|
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
|
@@ -328,7 +329,6 @@ function getCallPreview(
|
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
|
|
331
|
-
const HL_INPUT_HEADER_PREFIX = "@";
|
|
332
332
|
|
|
333
333
|
function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
334
334
|
const trimmed = rawPath.trim();
|
|
@@ -342,13 +342,11 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
function parseHashlineInputPreviewHeader(line: string): string | null {
|
|
345
|
-
if (!line.startsWith(
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// (and stray "@ PATH" / "@@@ PATH" runs) all route to the same file. Mirror
|
|
349
|
-
// that here so the renderer doesn't surface a literal "@ " in the title.
|
|
345
|
+
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
|
346
|
+
// Mirror hashline/input.ts: strip every leading file marker so canonical
|
|
347
|
+
// `§ PATH` headers and stray `§§ PATH` / `§§§PATH` runs render clean paths.
|
|
350
348
|
let prefixEnd = 0;
|
|
351
|
-
while (prefixEnd < line.length && line[prefixEnd] ===
|
|
349
|
+
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
|
352
350
|
const body = line.slice(prefixEnd).trim();
|
|
353
351
|
const previewPath = normalizeHashlineInputPreviewPath(body);
|
|
354
352
|
return previewPath.length > 0 ? previewPath : null;
|
package/src/edit/streaming.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
containsRecognizableHashlineOperations,
|
|
23
23
|
END_PATCH_MARKER,
|
|
24
24
|
type HashlineInputSection,
|
|
25
|
+
HL_FILE_PREFIX,
|
|
26
|
+
HL_OP_CHARS,
|
|
25
27
|
splitHashlineInputs,
|
|
26
28
|
} from "../hashline";
|
|
27
29
|
import type { Theme } from "../modes/theme/theme";
|
|
@@ -77,8 +79,19 @@ const STREAMING_FALLBACK_LINES = 12;
|
|
|
77
79
|
const STREAMING_FALLBACK_WIDTH = 80;
|
|
78
80
|
|
|
79
81
|
function isHashlineHeaderLine(line: string): boolean {
|
|
82
|
+
return line.trimEnd().startsWith(HL_FILE_PREFIX);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseHashlineHeaderPath(line: string): string {
|
|
80
86
|
const trimmed = line.trimEnd();
|
|
81
|
-
|
|
87
|
+
let prefixEnd = 0;
|
|
88
|
+
while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
|
89
|
+
return trimmed.slice(prefixEnd).trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isHashlineOpLine(line: string): boolean {
|
|
93
|
+
const first = line[0];
|
|
94
|
+
return first !== undefined && HL_OP_CHARS.includes(first);
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
function isHashlineEnvelopeMarkerLine(line: string): boolean {
|
|
@@ -358,11 +371,11 @@ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[
|
|
|
358
371
|
}
|
|
359
372
|
|
|
360
373
|
/**
|
|
361
|
-
* Hashline equivalent: emit each
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
374
|
+
* Hashline equivalent: emit each payload line as a `+added` line in the
|
|
375
|
+
* order the model typed it. We deliberately omit op headers and removal
|
|
376
|
+
* targets from the streaming preview because their content lives in the file
|
|
377
|
+
* and would require a costly re-apply per tick; the complete unified diff is
|
|
378
|
+
* shown once streaming finishes.
|
|
366
379
|
*/
|
|
367
380
|
function buildHashlineNaturalOrderPreviews(
|
|
368
381
|
input: string,
|
|
@@ -382,13 +395,12 @@ function buildHashlineNaturalOrderPreviews(
|
|
|
382
395
|
for (const raw of lines) {
|
|
383
396
|
if (isHashlineEnvelopeMarkerLine(raw)) continue;
|
|
384
397
|
if (isHashlineHeaderLine(raw)) {
|
|
385
|
-
currentPath = raw
|
|
398
|
+
currentPath = parseHashlineHeaderPath(raw);
|
|
386
399
|
if (currentPath) ensure(currentPath);
|
|
387
400
|
continue;
|
|
388
401
|
}
|
|
389
|
-
if (raw
|
|
390
|
-
|
|
391
|
-
}
|
|
402
|
+
if (isHashlineOpLine(raw) || !currentPath) continue;
|
|
403
|
+
ensure(currentPath).push(`+${raw}`);
|
|
392
404
|
}
|
|
393
405
|
if (groups.size === 0) return null;
|
|
394
406
|
const previews: PerFileDiffPreview[] = [];
|
|
@@ -409,7 +421,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
409
421
|
if (input.length === 0) return null;
|
|
410
422
|
if (ctx.isStreaming) {
|
|
411
423
|
// Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
|
|
412
|
-
// reordering by showing
|
|
424
|
+
// reordering by showing payload lines in input order.
|
|
413
425
|
return buildHashlineNaturalOrderPreviews(input, args.path);
|
|
414
426
|
}
|
|
415
427
|
ctx.signal.throwIfAborted();
|
|
@@ -419,7 +431,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
419
431
|
sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
|
|
420
432
|
} catch {
|
|
421
433
|
// Single-section fallback keeps the original error rendering for the
|
|
422
|
-
// "haven't typed
|
|
434
|
+
// "haven't typed `§ PATH` yet" case.
|
|
423
435
|
const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
|
|
424
436
|
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
425
437
|
});
|
|
@@ -4,9 +4,6 @@ export const MISMATCH_CONTEXT = 2;
|
|
|
4
4
|
/** Filler hash used for the interior of a multi-line range; not validated. */
|
|
5
5
|
export const RANGE_INTERIOR_HASH = "**";
|
|
6
6
|
|
|
7
|
-
/** Header marker introducing a new file section in multi-section input. */
|
|
8
|
-
export const FILE_HEADER_PREFIX = "@";
|
|
9
|
-
|
|
10
7
|
/** Optional patch envelope start marker; silently consumed when present. */
|
|
11
8
|
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
12
9
|
|
package/src/hashline/diff.ts
CHANGED
|
@@ -30,7 +30,7 @@ export async function computeHashlineSectionDiff(
|
|
|
30
30
|
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
|
31
31
|
const { text: content } = stripBom(rawContent);
|
|
32
32
|
const normalized = normalizeToLF(content);
|
|
33
|
-
const result = applyHashlineEdits(normalized, parseHashline(section.diff
|
|
33
|
+
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
|
|
34
34
|
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
35
35
|
return generateDiffString(normalized, result.lines);
|
|
36
36
|
} catch (err) {
|
package/src/hashline/execute.ts
CHANGED
|
@@ -106,7 +106,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
|
|
|
106
106
|
const { session, path: sectionPath, diff } = options;
|
|
107
107
|
|
|
108
108
|
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
109
|
-
const { edits } = parseHashlineWithWarnings(diff
|
|
109
|
+
const { edits } = parseHashlineWithWarnings(diff);
|
|
110
110
|
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
111
111
|
|
|
112
112
|
const source = await readHashlineFile(absolutePath, sectionPath);
|
|
@@ -139,7 +139,7 @@ async function executeHashlineSection(
|
|
|
139
139
|
} = options;
|
|
140
140
|
|
|
141
141
|
const absolutePath = resolvePlanPath(session, sourcePath);
|
|
142
|
-
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff
|
|
142
|
+
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
|
|
143
143
|
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
144
144
|
|
|
145
145
|
const source = await readHashlineFile(absolutePath, sourcePath);
|
|
@@ -3,20 +3,19 @@ begin_patch: "*** Begin Patch" LF
|
|
|
3
3
|
end_patch: "*** End Patch" LF?
|
|
4
4
|
|
|
5
5
|
hunk: update_hunk
|
|
6
|
-
update_hunk: "
|
|
6
|
+
update_hunk: "$HFILE$" filename LF line_op*
|
|
7
7
|
|
|
8
8
|
filename: /(.+)/
|
|
9
9
|
|
|
10
|
-
line_op: insert_before | insert_after | replace |
|
|
11
|
-
insert_before:
|
|
12
|
-
insert_after:
|
|
13
|
-
replace:
|
|
14
|
-
|
|
15
|
-
payload: $HSEP$ /(.*)/ LF
|
|
10
|
+
line_op: insert_before | insert_after | replace | blank
|
|
11
|
+
insert_before: "$HOP_INSERT_BEFORE$" anchor LF payload+
|
|
12
|
+
insert_after: "$HOP_INSERT_AFTER$" anchor LF payload+
|
|
13
|
+
replace: "$HOP_REPLACE$" range LF payload*
|
|
14
|
+
payload: /[^$HOP_CHARS$$HFILE$\n][^\n]*/ LF | LF
|
|
16
15
|
blank: LF
|
|
17
16
|
|
|
18
17
|
anchor: LID | "EOF" | "BOF"
|
|
19
|
-
range: LID ".." LID
|
|
18
|
+
range: LID (".." LID)?
|
|
20
19
|
LID: /[1-9]\d*$HFMT$/
|
|
21
20
|
|
|
22
21
|
%import common.LF
|