@sealant/mend 0.15.0 → 0.16.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 +4 -0
- package/dist/dashboard-model.js +95 -0
- package/dist/dashboard.js +322 -49
- package/dist/main.js +80 -2
- package/dist/shared.js +6 -0
- package/dist/skills.js +134 -0
- package/dist/tui-theme.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,10 @@ mend sessions [--all] [--project p] [--json]
|
|
|
83
83
|
mend status active sessions (alias of mend sessions)
|
|
84
84
|
mend pair [--url <base url>] pair a phone or a second machine: QR + code + URL
|
|
85
85
|
mend doctor read-only checklist of this machine's setup
|
|
86
|
+
mend skills [--project [p]] your skill library on the server (or a project's)
|
|
87
|
+
mend skills push [--project [p]] [--prune] [--dir <path>]
|
|
88
|
+
upload ~/.agents/skills bundles; sessions receive
|
|
89
|
+
them at launch (--prune removes what's gone)
|
|
86
90
|
```
|
|
87
91
|
|
|
88
92
|
## Signing in
|
package/dist/dashboard-model.js
CHANGED
|
@@ -200,3 +200,98 @@ export const foldGroupStatus = (group) => {
|
|
|
200
200
|
return "idle";
|
|
201
201
|
return statuses[0] ?? "idle";
|
|
202
202
|
};
|
|
203
|
+
// ─── attach + verb helpers ──────────────────────────────────────────────────
|
|
204
|
+
/**
|
|
205
|
+
* Where "get me in" should land when the session has no live agent terminal:
|
|
206
|
+
* the newest LIVE shell, so repeated attaches rejoin the same one instead of
|
|
207
|
+
* stacking a fresh bash per attempt (the five-orphan-shells failure mode).
|
|
208
|
+
* Null = nothing attachable; opening a new shell is then honest.
|
|
209
|
+
*/
|
|
210
|
+
export const liveShellOf = (processes) => processes.findLast((process) => process.kind === "shell" && process.exitedAt === null) ?? null;
|
|
211
|
+
/**
|
|
212
|
+
* The optimistic stop: the row settles AND its live process/service fact
|
|
213
|
+
* lines drop in the same paint — the server's refetch only confirms.
|
|
214
|
+
*/
|
|
215
|
+
export const markSessionStopped = (data, sessionId) => {
|
|
216
|
+
const processesBySession = new Map(data.processesBySession);
|
|
217
|
+
processesBySession.delete(sessionId);
|
|
218
|
+
const servicesBySession = new Map(data.servicesBySession);
|
|
219
|
+
servicesBySession.delete(sessionId);
|
|
220
|
+
return {
|
|
221
|
+
...mapWorkbenchSessions(data, (session) => session.id === sessionId ? { ...session, status: "stopped" } : session),
|
|
222
|
+
processesBySession,
|
|
223
|
+
servicesBySession,
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
/** The optimistic removal: the whole group leaves the list before the server answers. */
|
|
227
|
+
export const removeWorktreeGroup = (data, projectId, group) => {
|
|
228
|
+
const memberIds = new Set(group.sessions.map((item) => item.session.id));
|
|
229
|
+
const detail = data.details.get(projectId);
|
|
230
|
+
if (detail === undefined)
|
|
231
|
+
return data;
|
|
232
|
+
const details = new Map(data.details);
|
|
233
|
+
details.set(projectId, {
|
|
234
|
+
...detail,
|
|
235
|
+
sessions: detail.sessions.filter((session) => !memberIds.has(session.id)),
|
|
236
|
+
...(detail.worktrees === undefined
|
|
237
|
+
? {}
|
|
238
|
+
: { worktrees: detail.worktrees.filter((worktree) => worktree.id !== group.id) }),
|
|
239
|
+
});
|
|
240
|
+
return { ...data, details };
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Subsequence fuzzy score, fzf-flavored: every query character must appear in
|
|
244
|
+
* order; consecutive hits and segment starts (after `/`, `-`, `_`, `.`) score
|
|
245
|
+
* higher; earlier matches beat later ones. Null = no match. Case-insensitive.
|
|
246
|
+
*/
|
|
247
|
+
export const fuzzyScore = (query, candidate) => {
|
|
248
|
+
if (query === "")
|
|
249
|
+
return 0;
|
|
250
|
+
const q = query.toLowerCase();
|
|
251
|
+
const c = candidate.toLowerCase();
|
|
252
|
+
let score = 0;
|
|
253
|
+
let last = -1;
|
|
254
|
+
for (const char of q) {
|
|
255
|
+
const at = c.indexOf(char, last + 1);
|
|
256
|
+
if (at === -1)
|
|
257
|
+
return null;
|
|
258
|
+
if (at === last + 1)
|
|
259
|
+
score += 3;
|
|
260
|
+
else if (at === 0 || "/-_.".includes(c[at - 1] ?? ""))
|
|
261
|
+
score += 2;
|
|
262
|
+
else
|
|
263
|
+
score += 1;
|
|
264
|
+
score -= (at - last - 1) * 0.01;
|
|
265
|
+
last = at;
|
|
266
|
+
}
|
|
267
|
+
return score;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* The picker's rows for one query: fuzzy-filtered, best score first, ties by
|
|
271
|
+
* most recent commit; an empty query lists everything, default branch on top.
|
|
272
|
+
*/
|
|
273
|
+
export const filterBranches = (branches, query) => {
|
|
274
|
+
const scored = branches.flatMap((branch) => {
|
|
275
|
+
const score = fuzzyScore(query, branch.name);
|
|
276
|
+
return score === null ? [] : [{ branch, score }];
|
|
277
|
+
});
|
|
278
|
+
return scored
|
|
279
|
+
.toSorted((a, b) => {
|
|
280
|
+
if (query === "" && a.branch.isDefault !== b.branch.isDefault) {
|
|
281
|
+
return a.branch.isDefault ? -1 : 1;
|
|
282
|
+
}
|
|
283
|
+
if (a.score !== b.score)
|
|
284
|
+
return b.score - a.score;
|
|
285
|
+
return b.branch.committedAt.localeCompare(a.branch.committedAt);
|
|
286
|
+
})
|
|
287
|
+
.map((entry) => entry.branch);
|
|
288
|
+
};
|
|
289
|
+
/** Commit the base step: the highlighted branch (default branch = null base). */
|
|
290
|
+
export const advanceFromBase = (current) => {
|
|
291
|
+
const chosen = filterBranches(current.branches ?? [], current.query)[current.baseIndex];
|
|
292
|
+
return {
|
|
293
|
+
...current,
|
|
294
|
+
base: chosen === undefined || chosen.isDefault ? null : chosen.name,
|
|
295
|
+
step: "harness",
|
|
296
|
+
};
|
|
297
|
+
};
|
package/dist/dashboard.js
CHANGED
|
@@ -3,12 +3,12 @@ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
|
|
|
3
3
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
4
4
|
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
|
|
5
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
|
-
import { deriveHarnesses, deriveProjects, deriveRows, deriveWorktrees, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, mapWorkbenchSessions, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
|
|
6
|
+
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
|
|
7
7
|
import { reviewTargetForSession } from "./review-workflow.js";
|
|
8
8
|
import { ReviewScreen } from "./review.js";
|
|
9
9
|
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
|
|
10
10
|
import { openUrl } from "./terminal.js";
|
|
11
|
-
import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
|
|
11
|
+
import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, SURFACE, WASH } from "./tui-theme.js";
|
|
12
12
|
// Near-mono on purpose: a status is a word, and only an observed failure
|
|
13
13
|
// earns color. Live states read at full ink; settled ones recede.
|
|
14
14
|
const STATUS_COLOR = {
|
|
@@ -155,15 +155,24 @@ const App = ({ ctx, onQuit }) => {
|
|
|
155
155
|
const [editing, setEditing] = useState(null);
|
|
156
156
|
/** Session id a stop is armed against; the second press fires it. */
|
|
157
157
|
const [stopArmed, setStopArmed] = useState(null);
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
/**
|
|
159
|
+
* The whole worktree creation is ONE modal: name, base (fuzzy over the
|
|
160
|
+
* project's branches), harness — every step visible, the active one
|
|
161
|
+
* expanded. Enter advances, esc steps back (cancels from the name).
|
|
162
|
+
*/
|
|
163
|
+
const [creating, setCreating] = useState(null);
|
|
164
|
+
/**
|
|
165
|
+
* The cwd is a git repo the store doesn't know: offer to adopt it, once
|
|
166
|
+
* per dashboard run. Enter adopts, esc declines and stays quiet.
|
|
167
|
+
*/
|
|
168
|
+
const [adoptOffer, setAdoptOffer] = useState(null);
|
|
169
|
+
const adoptOfferDecided = useRef(false);
|
|
161
170
|
const [reviewing, setReviewing] = useState(null);
|
|
162
171
|
/** Set synchronously around attach/launch so keystrokes can't double-fire. */
|
|
163
172
|
const lockRef = useRef(false);
|
|
164
173
|
/** Latest modal state for mutation callbacks — closures there go stale. */
|
|
165
174
|
const modalRef = useRef(false);
|
|
166
|
-
modalRef.current = editing !== null || reviewing !== null || picker !== null;
|
|
175
|
+
modalRef.current = editing !== null || reviewing !== null || picker !== null || creating !== null;
|
|
167
176
|
const { width: terminalCols, height: terminalRows } = useTerminalDimensions();
|
|
168
177
|
const showProjectsPane = terminalCols >= 72;
|
|
169
178
|
const showDetail = terminalRows >= 18;
|
|
@@ -197,6 +206,26 @@ const App = ({ ctx, onQuit }) => {
|
|
|
197
206
|
const selectSession = (id) => setSessionKey(`s:${id}`);
|
|
198
207
|
const say = (text) => setStatus({ text, at: Date.now() });
|
|
199
208
|
const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
|
|
209
|
+
// A repo underfoot that the store has never met: raise the adopt offer the
|
|
210
|
+
// first time the workbench answers and no project matches the cwd.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (data === undefined || adoptOfferDecided.current)
|
|
213
|
+
return;
|
|
214
|
+
if (homeProject !== undefined)
|
|
215
|
+
return;
|
|
216
|
+
const facts = cwdFacts(ctx.cwd);
|
|
217
|
+
if (facts.repoRoot === null)
|
|
218
|
+
return;
|
|
219
|
+
adoptOfferDecided.current = true;
|
|
220
|
+
setAdoptOffer({
|
|
221
|
+
root: facts.repoRoot,
|
|
222
|
+
source: facts.originUrl ?? facts.repoRoot,
|
|
223
|
+
name: normalizeProjectName(facts.repoRoot.split("/").at(-1) ?? "project"),
|
|
224
|
+
modeIndex: 0,
|
|
225
|
+
});
|
|
226
|
+
// homeProject/data identity is enough; ctx.cwd never changes in a run.
|
|
227
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
+
}, [data, homeProject]);
|
|
200
229
|
// Keep each pane's selection on screen — the one imperative escape hatch.
|
|
201
230
|
const projectScrollRef = useRef(null);
|
|
202
231
|
const sessionScrollRef = useRef(null);
|
|
@@ -227,11 +256,21 @@ const App = ({ ctx, onQuit }) => {
|
|
|
227
256
|
outcome = await ctx.attachTty(session.id, session.harness);
|
|
228
257
|
if (outcome === "unavailable") {
|
|
229
258
|
// A live session whose terminal ended (idle: a workspace held open,
|
|
230
|
-
// no PTY behind it). Enter still means "get me in" —
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
259
|
+
// no PTY behind it). Enter still means "get me in" — REJOIN the shell
|
|
260
|
+
// already holding the workspace when one is live; only open a fresh
|
|
261
|
+
// one when nothing is attachable (stacking a new bash per attempt is
|
|
262
|
+
// how a session ends up held open by orphan shells).
|
|
263
|
+
const detail = await ctx.api("GET", `/sessions/${session.id}`);
|
|
264
|
+
const existing = liveShellOf(detail.processes);
|
|
265
|
+
if (existing !== null) {
|
|
266
|
+
process.stdout.write(`no live terminal — rejoining the open shell\n\n`);
|
|
267
|
+
outcome = await ctx.attachTty(session.id, "shell", existing.id);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const shell = await ctx.api("POST", `/sessions/${session.id}/shell`);
|
|
271
|
+
process.stdout.write(`no live terminal — opened a shell in the workspace\n\n`);
|
|
272
|
+
outcome = await ctx.attachTty(session.id, "shell", shell.id);
|
|
273
|
+
}
|
|
235
274
|
}
|
|
236
275
|
}
|
|
237
276
|
catch (error) {
|
|
@@ -283,7 +322,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
283
322
|
harness: vars.harness,
|
|
284
323
|
label: null,
|
|
285
324
|
name: vars.name,
|
|
286
|
-
base:
|
|
325
|
+
base: vars.base,
|
|
287
326
|
});
|
|
288
327
|
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
289
328
|
return session;
|
|
@@ -296,7 +335,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
296
335
|
label: null,
|
|
297
336
|
branch: vars.name === null ? "provisioning…" : `mend/${vars.name}`,
|
|
298
337
|
baseSha: "",
|
|
299
|
-
baseRef:
|
|
338
|
+
baseRef: vars.base,
|
|
300
339
|
status: "starting",
|
|
301
340
|
summary: null,
|
|
302
341
|
createdAt: new Date().toISOString(),
|
|
@@ -342,6 +381,46 @@ const App = ({ ctx, onQuit }) => {
|
|
|
342
381
|
},
|
|
343
382
|
onSettled: settleRefetch,
|
|
344
383
|
});
|
|
384
|
+
const ADOPT_AUTH_MODES = [
|
|
385
|
+
{
|
|
386
|
+
mode: "ambient",
|
|
387
|
+
label: "ambient",
|
|
388
|
+
hint: "the server machine's own git/ssh setup",
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
mode: "mend-key",
|
|
392
|
+
label: "mend-key",
|
|
393
|
+
hint: "the machine's Mend deploy key — add its public half on the git host",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
mode: "bridge",
|
|
397
|
+
label: "bridge",
|
|
398
|
+
hint: "your OWN ssh-agent, relayed while `mend keys share` runs here",
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
const adoptMutation = useMutation({
|
|
402
|
+
mutationFn: (offer) => ctx.api("POST", "/projects", {
|
|
403
|
+
name: offer.name,
|
|
404
|
+
source: offer.source,
|
|
405
|
+
gitAuthMode: ADOPT_AUTH_MODES[offer.modeIndex]?.mode ?? "ambient",
|
|
406
|
+
}),
|
|
407
|
+
onMutate: (offer) => {
|
|
408
|
+
setAdoptOffer(null);
|
|
409
|
+
setBusy(`adopting ${offer.name} — cloning into the store ·`);
|
|
410
|
+
setBusyStarted(Date.now());
|
|
411
|
+
},
|
|
412
|
+
onError: (error) => {
|
|
413
|
+
setBusy(null);
|
|
414
|
+
say(errorText(error));
|
|
415
|
+
},
|
|
416
|
+
onSuccess: (project) => {
|
|
417
|
+
setBusy(null);
|
|
418
|
+
say(`adopted · ${project.name} — n starts a worktree`);
|
|
419
|
+
setProjectKey(project.id);
|
|
420
|
+
setSessionKey(null);
|
|
421
|
+
},
|
|
422
|
+
onSettled: settleRefetch,
|
|
423
|
+
});
|
|
345
424
|
// The label lands in the row before the server answers; an error puts the
|
|
346
425
|
// truth back on the next refetch.
|
|
347
426
|
const renameMutation = useMutation({
|
|
@@ -365,7 +444,8 @@ const App = ({ ctx, onQuit }) => {
|
|
|
365
444
|
mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
|
|
366
445
|
onMutate: async (session) => {
|
|
367
446
|
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
368
|
-
|
|
447
|
+
// The row settles and its live process/service facts drop in one paint.
|
|
448
|
+
patchWorkbench((current) => markSessionStopped(current, session.id));
|
|
369
449
|
say(`stopped · ${worktreeDisplayName(session)} — the record and review remain`);
|
|
370
450
|
},
|
|
371
451
|
onError: (error) => {
|
|
@@ -424,7 +504,13 @@ const App = ({ ctx, onQuit }) => {
|
|
|
424
504
|
mutationFn: (group) => group.id !== null
|
|
425
505
|
? ctx.api("DELETE", `/worktrees/${group.id}`)
|
|
426
506
|
: ctx.api("DELETE", `/sessions/${group.sessions[0]?.session.id ?? ""}`),
|
|
427
|
-
onMutate: (group) => {
|
|
507
|
+
onMutate: async (group) => {
|
|
508
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
509
|
+
const projectId = selectedProject?.project.id;
|
|
510
|
+
// The group leaves the list before the server answers; an error refetches truth.
|
|
511
|
+
if (projectId !== undefined) {
|
|
512
|
+
patchWorkbench((current) => removeWorktreeGroup(current, projectId, group));
|
|
513
|
+
}
|
|
428
514
|
say(`removing worktree · ${group.name}`);
|
|
429
515
|
},
|
|
430
516
|
onError: (error) => {
|
|
@@ -492,17 +578,79 @@ const App = ({ ctx, onQuit }) => {
|
|
|
492
578
|
setStopArmed(item.session.id);
|
|
493
579
|
say(`press again to stop · ${worktreeDisplayName(item.session)}`);
|
|
494
580
|
};
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
581
|
+
/** Open the creation modal; the branch list starts loading immediately. */
|
|
582
|
+
const openCreateModal = (projectId) => {
|
|
583
|
+
setCreating({
|
|
584
|
+
projectId,
|
|
585
|
+
step: "name",
|
|
586
|
+
name: "",
|
|
587
|
+
branches: null,
|
|
588
|
+
query: "",
|
|
589
|
+
baseIndex: 0,
|
|
590
|
+
base: null,
|
|
591
|
+
joins: false,
|
|
592
|
+
harnessIndex: 0,
|
|
593
|
+
});
|
|
594
|
+
// The checkout mend ran in names its branch; creating in THAT project
|
|
595
|
+
// prefills the base with it — the list highlights it once loaded.
|
|
596
|
+
const prefill = projectId === homeProject?.id ? ctx.cwdBranch : null;
|
|
597
|
+
void ctx.api("GET", `/projects/${projectId}/branches`).then((branches) => setCreating((current) => {
|
|
598
|
+
if (current === null)
|
|
599
|
+
return current;
|
|
600
|
+
const match = prefill === null ? undefined : branches.find((candidate) => candidate.name === prefill);
|
|
601
|
+
const ordered = filterBranches(branches, "");
|
|
602
|
+
return {
|
|
603
|
+
...current,
|
|
604
|
+
branches,
|
|
605
|
+
base: match === undefined || match.isDefault ? null : match.name,
|
|
606
|
+
baseIndex: match === undefined
|
|
607
|
+
? 0
|
|
608
|
+
: Math.max(0, ordered.findIndex((candidate) => candidate.name === match.name)),
|
|
609
|
+
};
|
|
610
|
+
}), () =>
|
|
611
|
+
// An unreadable list falls back to the default base; the base step
|
|
612
|
+
// then shows only the default and enter moves on.
|
|
613
|
+
setCreating((current) => (current === null ? current : { ...current, branches: [] })));
|
|
614
|
+
};
|
|
615
|
+
/** Commit the name: an existing name JOINS its worktree — base is fixed. */
|
|
616
|
+
const advanceFromName = (current, raw) => {
|
|
617
|
+
const slug = normalizeProjectName(raw.trim());
|
|
618
|
+
const name = raw.trim() === "" ? "" : slug;
|
|
619
|
+
const joins = name !== "" &&
|
|
620
|
+
(workbench()?.details.get(current.projectId)?.worktrees ?? []).some((candidate) => candidate.name === name);
|
|
621
|
+
// A join fixes the base; otherwise whatever is chosen (the checkout
|
|
622
|
+
// prefill included) rides along untouched.
|
|
623
|
+
return {
|
|
624
|
+
...current,
|
|
625
|
+
name,
|
|
626
|
+
joins,
|
|
627
|
+
...(joins ? { base: null } : {}),
|
|
628
|
+
step: joins ? "harness" : "base",
|
|
629
|
+
};
|
|
630
|
+
};
|
|
631
|
+
const submitCreateName = (value) => {
|
|
632
|
+
setCreating((current) => (current === null ? current : advanceFromName(current, value)));
|
|
499
633
|
};
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
634
|
+
const submitCreateBase = () => {
|
|
635
|
+
setCreating((current) => (current === null ? current : advanceFromBase(current)));
|
|
636
|
+
};
|
|
637
|
+
/** Enter on the harness: everything is chosen — launch. */
|
|
638
|
+
const submitCreateHarness = () => {
|
|
639
|
+
const current = creating;
|
|
640
|
+
if (current === null)
|
|
641
|
+
return;
|
|
642
|
+
const choice = deriveHarnesses(null)[current.harnessIndex];
|
|
643
|
+
if (choice?.harness == null)
|
|
644
|
+
return;
|
|
645
|
+
setCreating(null);
|
|
646
|
+
setFocus("sessions");
|
|
647
|
+
launchMutation.mutate({
|
|
648
|
+
projectId: current.projectId,
|
|
649
|
+
harness: choice.harness,
|
|
650
|
+
name: current.name === "" ? null : current.name,
|
|
651
|
+
base: current.base,
|
|
652
|
+
pendingKey: pendingId(),
|
|
653
|
+
});
|
|
506
654
|
};
|
|
507
655
|
const resumeSession = (projectId, session, harness) => {
|
|
508
656
|
setFocus("sessions");
|
|
@@ -558,11 +706,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
558
706
|
});
|
|
559
707
|
}
|
|
560
708
|
}
|
|
561
|
-
else if (picker.session
|
|
562
|
-
if (choice.harness !== null)
|
|
563
|
-
startSession(projectId, choice.harness);
|
|
564
|
-
}
|
|
565
|
-
else {
|
|
709
|
+
else if (picker.session !== null) {
|
|
566
710
|
resumeSession(projectId, picker.session, choice.harness);
|
|
567
711
|
}
|
|
568
712
|
return;
|
|
@@ -603,15 +747,109 @@ const App = ({ ctx, onQuit }) => {
|
|
|
603
747
|
return;
|
|
604
748
|
if (key.ctrl && key.name === "c")
|
|
605
749
|
return onQuit();
|
|
750
|
+
if (adoptOffer !== null) {
|
|
751
|
+
if (key.name === "return" || key.name === "linefeed" || key.name === "y") {
|
|
752
|
+
adoptMutation.mutate(adoptOffer);
|
|
753
|
+
}
|
|
754
|
+
else if (key.name === "escape" || key.name === "q") {
|
|
755
|
+
setAdoptOffer(null);
|
|
756
|
+
say("not adopted — mend adopt does it any time");
|
|
757
|
+
}
|
|
758
|
+
else if (key.name === "right" ||
|
|
759
|
+
key.name === "l" ||
|
|
760
|
+
key.name === "tab" ||
|
|
761
|
+
key.name === "left" ||
|
|
762
|
+
key.name === "h") {
|
|
763
|
+
const delta = key.name === "left" || key.name === "h" ? -1 : 1;
|
|
764
|
+
setAdoptOffer((current) => current === null
|
|
765
|
+
? current
|
|
766
|
+
: {
|
|
767
|
+
...current,
|
|
768
|
+
modeIndex: (current.modeIndex + delta + ADOPT_AUTH_MODES.length) % ADOPT_AUTH_MODES.length,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
606
773
|
if (editing !== null) {
|
|
607
774
|
// The input owns the keyboard; only escape leaves without saving.
|
|
608
775
|
if (key.name === "escape")
|
|
609
776
|
setEditing(null);
|
|
610
777
|
return;
|
|
611
778
|
}
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
779
|
+
if (creating !== null) {
|
|
780
|
+
// Inputs own the characters (name text, base filter); this handler owns
|
|
781
|
+
// the arrows, esc (a step back; cancel from the name), and the harness
|
|
782
|
+
// step's enter. The name and base enters land through their input's
|
|
783
|
+
// submit so typed-but-unflushed text never races.
|
|
784
|
+
if (key.name === "escape") {
|
|
785
|
+
setCreating((current) => {
|
|
786
|
+
if (current === null)
|
|
787
|
+
return current;
|
|
788
|
+
if (current.step === "harness") {
|
|
789
|
+
return { ...current, step: current.joins ? "name" : "base" };
|
|
790
|
+
}
|
|
791
|
+
if (current.step === "base")
|
|
792
|
+
return { ...current, step: "name" };
|
|
793
|
+
say("new worktree cancelled");
|
|
794
|
+
return null;
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
// Tab walks the steps without the enter ceremony: forward commits the
|
|
799
|
+
// active field (the mirrored name, the highlighted branch), shift+tab
|
|
800
|
+
// steps back, and from the harness it wraps around to the name.
|
|
801
|
+
if (key.name === "tab" || key.name === "backtab") {
|
|
802
|
+
const backward = key.name === "backtab" || key.shift === true;
|
|
803
|
+
setCreating((current) => {
|
|
804
|
+
if (current === null)
|
|
805
|
+
return current;
|
|
806
|
+
if (backward) {
|
|
807
|
+
if (current.step === "harness") {
|
|
808
|
+
return { ...current, step: current.joins ? "name" : "base" };
|
|
809
|
+
}
|
|
810
|
+
if (current.step === "base")
|
|
811
|
+
return { ...current, step: "name" };
|
|
812
|
+
return current;
|
|
813
|
+
}
|
|
814
|
+
if (current.step === "name")
|
|
815
|
+
return advanceFromName(current, current.name);
|
|
816
|
+
if (current.step === "base")
|
|
817
|
+
return advanceFromBase(current);
|
|
818
|
+
return { ...current, step: "name" };
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const delta = key.name === "down" || (key.ctrl === true && key.name === "n")
|
|
823
|
+
? 1
|
|
824
|
+
: key.name === "up" || (key.ctrl === true && key.name === "p")
|
|
825
|
+
? -1
|
|
826
|
+
: 0;
|
|
827
|
+
if (delta !== 0) {
|
|
828
|
+
setCreating((current) => {
|
|
829
|
+
if (current === null)
|
|
830
|
+
return current;
|
|
831
|
+
if (current.step === "base") {
|
|
832
|
+
const matches = filterBranches(current.branches ?? [], current.query).length;
|
|
833
|
+
return {
|
|
834
|
+
...current,
|
|
835
|
+
baseIndex: Math.max(0, Math.min(matches - 1, current.baseIndex + delta)),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (current.step === "harness") {
|
|
839
|
+
const count = deriveHarnesses(null).length;
|
|
840
|
+
return {
|
|
841
|
+
...current,
|
|
842
|
+
harnessIndex: Math.max(0, Math.min(count - 1, current.harnessIndex + delta)),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
return current;
|
|
846
|
+
});
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (creating.step === "harness" &&
|
|
850
|
+
(key.name === "return" || key.name === "linefeed" || key.name === "l")) {
|
|
851
|
+
submitCreateHarness();
|
|
852
|
+
}
|
|
615
853
|
return;
|
|
616
854
|
}
|
|
617
855
|
if (picker !== null) {
|
|
@@ -672,9 +910,9 @@ const App = ({ ctx, onQuit }) => {
|
|
|
672
910
|
setFocus(focus === "projects" ? "sessions" : "projects");
|
|
673
911
|
return;
|
|
674
912
|
case "n":
|
|
675
|
-
// The
|
|
913
|
+
// The whole creation is one modal: name, base, harness.
|
|
676
914
|
if (selectedProject !== null)
|
|
677
|
-
|
|
915
|
+
openCreateModal(selectedProject.project.id);
|
|
678
916
|
return;
|
|
679
917
|
case "s": {
|
|
680
918
|
// A new conversation inside the selected worktree.
|
|
@@ -742,15 +980,24 @@ const App = ({ ctx, onQuit }) => {
|
|
|
742
980
|
: picker.session === null
|
|
743
981
|
? "new session — pick a harness"
|
|
744
982
|
: `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
983
|
+
// One big fixed-size modal: every step visible at once, nothing shifts as
|
|
984
|
+
// focus moves through name → base → harness.
|
|
985
|
+
const creatingHeight = 2 + 1 + 1 + 6 + 1 + deriveHarnesses(null).length;
|
|
986
|
+
const footerText = adoptOffer !== null
|
|
987
|
+
? " enter adopt · ←→ auth mode · esc not now"
|
|
988
|
+
: editing !== null
|
|
989
|
+
? " enter save · esc cancel"
|
|
990
|
+
: creating !== null
|
|
991
|
+
? creating.step === "name"
|
|
992
|
+
? " enter continue · esc cancel"
|
|
993
|
+
: creating.step === "base"
|
|
994
|
+
? " type to filter · ↑↓ move · enter choose base · esc back"
|
|
995
|
+
: " ↑↓ move · enter launch · esc back"
|
|
996
|
+
: picker !== null
|
|
997
|
+
? " ↑↓ move · enter start · esc cancel"
|
|
998
|
+
: focus === "projects"
|
|
999
|
+
? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
|
|
1000
|
+
: " ↑↓ move · enter attach/resume · n new worktree · s session here · x stop · ⇧D remove · v review · e rename · o web · q quit";
|
|
754
1001
|
const loadFailure = data === undefined && failureReason !== null
|
|
755
1002
|
? failureReason instanceof Error
|
|
756
1003
|
? failureReason.message
|
|
@@ -762,13 +1009,39 @@ const App = ({ ctx, onQuit }) => {
|
|
|
762
1009
|
refetch();
|
|
763
1010
|
}, onQuit: onQuit }));
|
|
764
1011
|
}
|
|
765
|
-
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : row.group.sessions.length === 1 ? (_jsx(CombinedRow, { group: row.group, item: row.item, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) :
|
|
766
|
-
if (typeof value === "string")
|
|
767
|
-
submitWorktreeName(value);
|
|
768
|
-
} }) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
1012
|
+
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : row.group.sessions.length === 1 ? (_jsx(CombinedRow, { group: row.group, item: row.item, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
769
1013
|
if (typeof value === "string")
|
|
770
1014
|
submitRename(editing, value);
|
|
771
|
-
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, _jsx(
|
|
1015
|
+
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, adoptOffer !== null ? (_jsxs("box", { position: "absolute", zIndex: 11, left: Math.max(1, Math.floor((terminalCols - Math.min(70, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - 7) / 2)), width: Math.min(70, terminalCols - 4), height: 7, border: true, borderStyle: "rounded", borderColor: COBALT, title: " adopt this repository? ", titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { children: " " }), _jsx("span", { fg: INK, children: adoptOffer.name }), _jsx("span", { fg: FAINT, children: " · not in the store yet" })] }), _jsx("text", { height: 1, bg: SURFACE, fg: MUTED, children: ` ${adoptOffer.source}` }), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { fg: FAINT, children: " auth " }), ADOPT_AUTH_MODES.map((candidate, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " · " }) : null, _jsx("span", { fg: index === adoptOffer.modeIndex ? COBALT : FAINT, children: index === adoptOffer.modeIndex ? `▸ ${candidate.label}` : candidate.label })] }, candidate.mode)))] }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: ` ${ADOPT_AUTH_MODES[adoptOffer.modeIndex]?.hint ?? ""}` }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: adoptOffer.source === adoptOffer.root
|
|
1016
|
+
? " enter adopt from this path — needs the server on THIS machine · esc not now"
|
|
1017
|
+
: " enter adopt · ←→ auth mode · esc not now" })] })) : null, creating !== null ? (_jsxs("box", { position: "absolute", zIndex: 10, left: Math.max(1, Math.floor((terminalCols - Math.min(74, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - creatingHeight) / 2)), width: Math.min(74, terminalCols - 4), height: creatingHeight, border: true, borderStyle: "rounded", borderColor: COBALT, title: ` new worktree — ${selectedProject?.project.name ?? "project"} `, titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "name" }), _jsx("span", { fg: FAINT, children: "name " }), creating.step !== "name" ? (_jsx("span", { fg: INK, children: creating.name === "" ? "auto" : creating.name })) : null, creating.joins ? _jsx("span", { fg: FAINT, children: " · joins the existing worktree" }) : null] }), creating.step === "name" ? (_jsx("input", { focused: true, value: creating.name, placeholder: "e.g. fix-auth (empty = auto \u00B7 an existing name joins it)", backgroundColor: SURFACE, focusedBackgroundColor: SURFACE, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onInput: (value) => {
|
|
1018
|
+
const clean = value.replace(/\t/g, "");
|
|
1019
|
+
setCreating((current) => current === null ? current : { ...current, name: clean });
|
|
1020
|
+
}, onSubmit: (value) => {
|
|
1021
|
+
if (typeof value === "string")
|
|
1022
|
+
submitCreateName(value);
|
|
1023
|
+
} })) : null] }), _jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "base" }), _jsx("span", { fg: FAINT, children: "base " }), creating.step !== "base" ? (_jsx("span", { fg: creating.step === "name" && !creating.joins ? FAINT : INK, children: creating.joins
|
|
1024
|
+
? "fixed by the existing worktree"
|
|
1025
|
+
: (creating.base ?? "default branch") })) : null] }), creating.step === "base" ? (creating.branches === null ? (_jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: "reading branches\u2026" })) : (_jsx("input", { focused: true, value: "", placeholder: "type to filter (enter = highlighted; empty = default)", backgroundColor: SURFACE, focusedBackgroundColor: SURFACE, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onInput: (value) => {
|
|
1026
|
+
const clean = value.replace(/\t/g, "");
|
|
1027
|
+
setCreating((current) => current === null ? current : { ...current, query: clean, baseIndex: 0 });
|
|
1028
|
+
}, onSubmit: () => submitCreateBase() }))) : null] }), Array.from({ length: 6 }, (_, index) => {
|
|
1029
|
+
const branch = creating.joins || creating.branches === null
|
|
1030
|
+
? undefined
|
|
1031
|
+
: filterBranches(creating.branches, creating.query)[index];
|
|
1032
|
+
const active = creating.step === "base";
|
|
1033
|
+
if (branch === undefined) {
|
|
1034
|
+
return (_jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: index === 0 && active && creating.branches !== null
|
|
1035
|
+
? " no branch matches — enter uses the default"
|
|
1036
|
+
: " " }, `slot-${index}`));
|
|
1037
|
+
}
|
|
1038
|
+
const highlighted = active && index === creating.baseIndex;
|
|
1039
|
+
return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: highlighted ? WASH : SURFACE, children: _jsxs("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [_jsx("span", { fg: FAINT, children: " " }), _jsx(Gutter, { selected: highlighted }), _jsx("span", { fg: active ? INK : FAINT, children: branch.name.slice(0, 40).padEnd(41) }), _jsx("span", { fg: FAINT, children: branch.sha.slice(0, 8).padEnd(10) }), _jsx("span", { fg: active ? MUTED : FAINT, children: timeAgo(branch.committedAt).replace(" ago", "").padEnd(6) }), branch.isDefault ? _jsx("span", { fg: FAINT, children: "default" }) : null] }) }, branch.name));
|
|
1040
|
+
}), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "harness" }), _jsx("span", { fg: FAINT, children: "harness" })] }), deriveHarnesses(null).map((item, index) => {
|
|
1041
|
+
const active = creating.step === "harness";
|
|
1042
|
+
const highlighted = active && index === creating.harnessIndex;
|
|
1043
|
+
return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: highlighted ? WASH : SURFACE, children: _jsxs("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [_jsx("span", { fg: FAINT, children: " " }), _jsx(Gutter, { selected: highlighted }), _jsx("span", { fg: active ? INK : FAINT, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: active ? item.hint : "" })] }) }, String(item.harness)));
|
|
1044
|
+
})] })) : null, _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
|
|
772
1045
|
};
|
|
773
1046
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
774
1047
|
export const runDashboard = async (ctx) => {
|
package/dist/main.js
CHANGED
|
@@ -11,7 +11,8 @@ import { formatLoadReport } from "./env.js";
|
|
|
11
11
|
import { loginCommand } from "./login.js";
|
|
12
12
|
import { pairCommand, qrCommand } from "./pair.js";
|
|
13
13
|
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
14
|
-
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
|
|
14
|
+
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
|
|
15
|
+
import { DEFAULT_SKILLS_DIR, scanSkillLibrary } from "./skills.js";
|
|
15
16
|
import { sshCommand } from "./ssh-setup.js";
|
|
16
17
|
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
17
18
|
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
@@ -1789,6 +1790,75 @@ const dotfilesCommand = async (config, args) => {
|
|
|
1789
1790
|
return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
|
|
1790
1791
|
}
|
|
1791
1792
|
};
|
|
1793
|
+
/** With `--project` the project's library; bare, your own. */
|
|
1794
|
+
const skillsList = async (config, args) => {
|
|
1795
|
+
const forProject = args.includes("--project");
|
|
1796
|
+
const project = forProject ? await findProject(config, takeFlagValue(args, "--project")) : null;
|
|
1797
|
+
const skills = await api(config, "GET", project === null ? "/skills" : `/projects/${project.id}/skills`);
|
|
1798
|
+
const library = project === null ? "your library" : project.name;
|
|
1799
|
+
if (skills.length === 0) {
|
|
1800
|
+
say(dim(`no skills in ${library} — push a local one: mend skills push`));
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
for (const skill of skills) {
|
|
1804
|
+
say(` ${skill.name.padEnd(28)} ${dim(`${skill.fileCount} file${skill.fileCount === 1 ? "" : "s"} · ${formatDotfileBytes(skill.bytes)}`)}`);
|
|
1805
|
+
if (skill.description !== "")
|
|
1806
|
+
say(` ${dim(skill.description)}`);
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
/**
|
|
1810
|
+
* Scan the shared agent-skills directory (`~/.agents/skills` by convention)
|
|
1811
|
+
* and upload every bundle. The upload is the intent: same-named skills are
|
|
1812
|
+
* replaced, and `--prune` removes what the directory no longer carries.
|
|
1813
|
+
*/
|
|
1814
|
+
const skillsPush = async (config, args) => {
|
|
1815
|
+
const dirFlag = takeFlagValue(args, "--dir");
|
|
1816
|
+
const root = dirFlag === null ? path.join(os.homedir(), DEFAULT_SKILLS_DIR) : path.resolve(dirFlag);
|
|
1817
|
+
const scanned = scanSkillLibrary(root);
|
|
1818
|
+
if ("error" in scanned)
|
|
1819
|
+
return fail(scanned.error);
|
|
1820
|
+
for (const note of scanned.notes)
|
|
1821
|
+
say(dim(` ${note.skill}: ${note.message}`));
|
|
1822
|
+
if (scanned.skills.length === 0)
|
|
1823
|
+
return fail(`no skills found under ${root}`);
|
|
1824
|
+
const forProject = args.includes("--project");
|
|
1825
|
+
const project = forProject ? await findProject(config, takeFlagValue(args, "--project")) : null;
|
|
1826
|
+
const report = await api(config, "POST", "/skills/sync", {
|
|
1827
|
+
scope: project === null ? "user" : "project",
|
|
1828
|
+
projectId: project?.id ?? null,
|
|
1829
|
+
skills: scanned.skills.map((skill) => ({
|
|
1830
|
+
name: skill.name,
|
|
1831
|
+
description: skill.description,
|
|
1832
|
+
files: skill.files,
|
|
1833
|
+
})),
|
|
1834
|
+
prune: args.includes("--prune"),
|
|
1835
|
+
});
|
|
1836
|
+
const library = project === null ? "your library" : project.name;
|
|
1837
|
+
const counts = [
|
|
1838
|
+
`${report.created.length} new`,
|
|
1839
|
+
`${report.updated.length} updated`,
|
|
1840
|
+
`${report.unchanged.length} unchanged`,
|
|
1841
|
+
...(report.removed.length > 0 ? [`${report.removed.length} removed`] : []),
|
|
1842
|
+
].join(" · ");
|
|
1843
|
+
say(`${green("pushed")} ${scanned.skills.length} skill${scanned.skills.length === 1 ? "" : "s"} from ${root} to ${library} · ${dim(counts)}`);
|
|
1844
|
+
say(dim("sessions receive them from the next launch"));
|
|
1845
|
+
};
|
|
1846
|
+
const skillsCommand = async (config, args) => {
|
|
1847
|
+
const [verb, ...rest] = args;
|
|
1848
|
+
switch (verb) {
|
|
1849
|
+
case "push":
|
|
1850
|
+
return skillsPush(config, rest);
|
|
1851
|
+
case "list":
|
|
1852
|
+
return skillsList(config, rest);
|
|
1853
|
+
case undefined:
|
|
1854
|
+
return skillsList(config, args);
|
|
1855
|
+
default:
|
|
1856
|
+
// Bare flags (`mend skills --project web`) read as the list.
|
|
1857
|
+
if (verb.startsWith("--"))
|
|
1858
|
+
return skillsList(config, args);
|
|
1859
|
+
return fail(`unknown skills command "${verb}" — try: mend skills push | list`);
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1792
1862
|
// ─── completions: live sessions under TAB ───────────────────────────────────
|
|
1793
1863
|
/**
|
|
1794
1864
|
* The data half of shell completion: one live session per line as
|
|
@@ -1824,6 +1894,7 @@ _mend() {
|
|
|
1824
1894
|
'shell:open a shell in a live session workspace'
|
|
1825
1895
|
'service:reachable ports — add, list, stop'
|
|
1826
1896
|
'keys:the machine Mend deploy key — init, show, share'
|
|
1897
|
+
'skills:skill libraries — list, push'
|
|
1827
1898
|
'accounts:your connected accounts on the platform'
|
|
1828
1899
|
'pair:pair a phone or a second machine' 'doctor:read-only checklist of this setup'
|
|
1829
1900
|
'connect:send this machine'"'"'s claude/codex/github credential to the platform'
|
|
@@ -1849,7 +1920,7 @@ _mend "$@"
|
|
|
1849
1920
|
const BASH_COMPLETIONS = `_mend() {
|
|
1850
1921
|
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1851
1922
|
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1852
|
-
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service keys pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
|
|
1923
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service keys skills pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
|
|
1853
1924
|
return
|
|
1854
1925
|
fi
|
|
1855
1926
|
case \${COMP_WORDS[1]} in
|
|
@@ -2426,6 +2497,7 @@ const dashboard = async (config) => {
|
|
|
2426
2497
|
await runDashboard({
|
|
2427
2498
|
config,
|
|
2428
2499
|
cwd: process.cwd(),
|
|
2500
|
+
cwdBranch: gitCurrentBranch(process.cwd()),
|
|
2429
2501
|
api: (method, route, body) => request(config, method, route, body),
|
|
2430
2502
|
attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
|
|
2431
2503
|
});
|
|
@@ -2474,6 +2546,10 @@ everything else
|
|
|
2474
2546
|
mend accounts your connected accounts on the platform (claude, codex, github)
|
|
2475
2547
|
mend dotfiles your dotfiles on the server: repo + synced home files
|
|
2476
2548
|
mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
|
|
2549
|
+
mend skills [--project [p]] your skill library on the server (or a project's)
|
|
2550
|
+
mend skills push [--project [p]] [--prune] [--dir <path>]
|
|
2551
|
+
upload ~/.agents/skills bundles into the library; sessions
|
|
2552
|
+
receive them at launch (--prune removes what's gone)
|
|
2477
2553
|
mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
|
|
2478
2554
|
hardware keys sign here; Ctrl-C stops sharing)
|
|
2479
2555
|
mend run -- <command...> same, with an arbitrary command
|
|
@@ -2544,6 +2620,8 @@ const main = async () => {
|
|
|
2544
2620
|
return keysCommand(config, rest);
|
|
2545
2621
|
case "dotfiles":
|
|
2546
2622
|
return dotfilesCommand(config, rest);
|
|
2623
|
+
case "skills":
|
|
2624
|
+
return skillsCommand(config, rest);
|
|
2547
2625
|
case "accounts":
|
|
2548
2626
|
return accountsCommand(config);
|
|
2549
2627
|
case "connect":
|
package/dist/shared.js
CHANGED
|
@@ -263,6 +263,12 @@ export const cwdFacts = (cwd) => ({
|
|
|
263
263
|
repoRoot: gitTopLevel(cwd),
|
|
264
264
|
originUrl: gitOriginUrl(cwd),
|
|
265
265
|
});
|
|
266
|
+
/** The branch checked out at `cwd`, or null outside a repo or on a detached HEAD. */
|
|
267
|
+
export const gitCurrentBranch = (cwd) => {
|
|
268
|
+
const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
|
|
269
|
+
const out = result.status === 0 ? result.stdout.trim() : "";
|
|
270
|
+
return out === "" || out === "HEAD" ? null : out;
|
|
271
|
+
};
|
|
266
272
|
/** The cwd repo's origin remote, or null when there is no repo or no origin. */
|
|
267
273
|
export const gitOriginUrl = (cwd) => {
|
|
268
274
|
const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Local skill-library scanning for `mend skills push`. Runs on the machine
|
|
5
|
+
* that HAS the skills: the shared agent-skills convention keeps them under
|
|
6
|
+
* `~/.agents/skills/<name>/SKILL.md` (plus support files), and the CLI
|
|
7
|
+
* captures the bundles here and streams them to the server's library. The
|
|
8
|
+
* CLI stays dependency-light by design, so the frontmatter peek below is a
|
|
9
|
+
* few lines of string handling, not a YAML parser — the server re-validates
|
|
10
|
+
* everything.
|
|
11
|
+
*/
|
|
12
|
+
/** Where the shared convention keeps user skills, relative to `$HOME`. */
|
|
13
|
+
export const DEFAULT_SKILLS_DIR = ".agents/skills";
|
|
14
|
+
/** The servers' caps, checked here so a mistake fails before the upload. */
|
|
15
|
+
export const MAX_SKILL_FILES = 64;
|
|
16
|
+
export const MAX_SKILL_FILE_BYTES = 512 * 1024;
|
|
17
|
+
const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__"]);
|
|
18
|
+
const isTextBuffer = (buffer) => !buffer.subarray(0, 8192).includes(0);
|
|
19
|
+
/**
|
|
20
|
+
* Pull `description:` out of a SKILL.md frontmatter block. Single-line plain
|
|
21
|
+
* or quoted scalars only — the common case by far; anything else reads as
|
|
22
|
+
* absent and the server keeps an empty description.
|
|
23
|
+
*/
|
|
24
|
+
export const readFrontmatterDescription = (contents) => {
|
|
25
|
+
if (!contents.startsWith("---\n") && !contents.startsWith("---\r\n"))
|
|
26
|
+
return "";
|
|
27
|
+
const end = contents.indexOf("\n---", 3);
|
|
28
|
+
if (end === -1)
|
|
29
|
+
return "";
|
|
30
|
+
for (const line of contents.slice(0, end).split("\n")) {
|
|
31
|
+
const match = /^description:\s*(.*)$/.exec(line);
|
|
32
|
+
if (match === null)
|
|
33
|
+
continue;
|
|
34
|
+
const raw = (match[1] ?? "").trim();
|
|
35
|
+
if (raw === "" || raw === "|" || raw === ">" || raw === "|-" || raw === ">-")
|
|
36
|
+
return "";
|
|
37
|
+
const quoted = /^(['"])(.*)\1$/.exec(raw);
|
|
38
|
+
return quoted?.[2] ?? raw;
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
};
|
|
42
|
+
const walkFiles = (root) => {
|
|
43
|
+
const found = [];
|
|
44
|
+
const visit = (dir) => {
|
|
45
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
47
|
+
continue;
|
|
48
|
+
const absolute = path.join(dir, entry.name);
|
|
49
|
+
// stat (not the dirent) so symlinked files and directories both count.
|
|
50
|
+
let stat;
|
|
51
|
+
try {
|
|
52
|
+
stat = fs.statSync(absolute);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue; // dangling symlink
|
|
56
|
+
}
|
|
57
|
+
if (stat.isDirectory())
|
|
58
|
+
visit(absolute);
|
|
59
|
+
else if (stat.isFile())
|
|
60
|
+
found.push(absolute);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
visit(root);
|
|
64
|
+
return found.toSorted();
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Scan a skill library directory: every immediate subdirectory carrying a
|
|
68
|
+
* `SKILL.md` becomes one bundle. Oversized and binary files are skipped with
|
|
69
|
+
* a note; a directory without a SKILL.md is noted and left out entirely.
|
|
70
|
+
*/
|
|
71
|
+
export const scanSkillLibrary = (root) => {
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { error: `${root} is not a readable directory` };
|
|
78
|
+
}
|
|
79
|
+
const skills = [];
|
|
80
|
+
const notes = [];
|
|
81
|
+
for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
82
|
+
if (entry.name.startsWith("."))
|
|
83
|
+
continue;
|
|
84
|
+
const skillDir = path.join(root, entry.name);
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs.statSync(skillDir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!stat.isDirectory())
|
|
93
|
+
continue;
|
|
94
|
+
if (!fs.existsSync(path.join(skillDir, "SKILL.md"))) {
|
|
95
|
+
notes.push({ skill: entry.name, message: "no SKILL.md — skipped" });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const files = [];
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
for (const absolute of walkFiles(skillDir)) {
|
|
101
|
+
const relative = path.relative(skillDir, absolute).split(path.sep).join("/");
|
|
102
|
+
const buffer = fs.readFileSync(absolute);
|
|
103
|
+
if (buffer.byteLength > MAX_SKILL_FILE_BYTES) {
|
|
104
|
+
notes.push({ skill: entry.name, message: `${relative} is over 512KB — skipped` });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!isTextBuffer(buffer)) {
|
|
108
|
+
notes.push({ skill: entry.name, message: `${relative} is binary — skipped` });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
files.push({ path: relative, contents: buffer.toString("utf8") });
|
|
112
|
+
bytes += buffer.byteLength;
|
|
113
|
+
}
|
|
114
|
+
if (files.length > MAX_SKILL_FILES) {
|
|
115
|
+
notes.push({
|
|
116
|
+
skill: entry.name,
|
|
117
|
+
message: `${files.length} files is over the ${MAX_SKILL_FILES}-file cap — skipped`,
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const entryFile = files.find((file) => file.path === "SKILL.md");
|
|
122
|
+
if (entryFile === undefined) {
|
|
123
|
+
notes.push({ skill: entry.name, message: "SKILL.md was unreadable — skipped" });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
skills.push({
|
|
127
|
+
name: entry.name.toLowerCase(),
|
|
128
|
+
description: readFrontmatterDescription(entryFile.contents),
|
|
129
|
+
files,
|
|
130
|
+
bytes,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { skills, notes };
|
|
134
|
+
};
|
package/dist/tui-theme.js
CHANGED
|
@@ -23,6 +23,11 @@ export const RULE = "#37374a";
|
|
|
23
23
|
export const COBALT = "#6f96f5";
|
|
24
24
|
/** The selection wash: a neutral lifted row — visible, not colorful. */
|
|
25
25
|
export const WASH = "#2c2c35";
|
|
26
|
+
/**
|
|
27
|
+
* The one exception to the no-painted-background rule: a floating modal
|
|
28
|
+
* must occlude the content beneath it, so it owns its ground.
|
|
29
|
+
*/
|
|
30
|
+
export const SURFACE = "#17171d";
|
|
26
31
|
export const AMBER = "#e2ab42";
|
|
27
32
|
export const GREEN = "#69c189";
|
|
28
33
|
export const RED = "#e87f76";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|