@sealant/mend 0.15.0 → 0.15.2

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.
@@ -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
- /** The new-session flow asks the worktree's name FIRST, then the harness. */
159
- const [naming, setNaming] = useState(null);
160
- const [pendingName, setPendingName] = useState(null);
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" — open a fresh
231
- // shell in the same workspace and attach to that.
232
- const shell = await ctx.api("POST", `/sessions/${session.id}/shell`);
233
- process.stdout.write(`no live terminal — opened a shell in the workspace\n\n`);
234
- outcome = await ctx.attachTty(session.id, "shell", shell.id);
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: null,
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: null,
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
- patchWorkbench((current) => mapWorkbenchSessions(current, (row) => row.id === session.id ? { ...row, status: "stopped" } : row));
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
- const startSession = (projectId, harness) => {
496
- setFocus("sessions");
497
- launchMutation.mutate({ projectId, harness, name: pendingName, pendingKey: pendingId() });
498
- setPendingName(null);
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
- /** Name first, then the harness: the worktree is the identity being created. */
501
- const submitWorktreeName = (value) => {
502
- setNaming(null);
503
- const slug = normalizeProjectName(value.trim());
504
- setPendingName(value.trim() === "" ? null : slug);
505
- openPicker(null);
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 === null) {
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 (naming !== null) {
613
- if (key.name === "escape")
614
- setNaming(null);
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 worktree's name comes first; the harness picker follows.
913
+ // The whole creation is one modal: name, base, harness.
676
914
  if (selectedProject !== null)
677
- setNaming({ projectId: selectedProject.project.id });
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
- const footerText = editing !== null
746
- ? " enter save · esc cancel"
747
- : naming !== null
748
- ? " enter continue — the harness picker follows · esc cancel"
749
- : picker !== null
750
- ? " ↑↓ move · enter start · esc cancel"
751
- : focus === "projects"
752
- ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
753
- : " ↑↓ move · enter attach/resume · n new worktree · s session here · x stop · ⇧D remove · v review · e rename · o web · q quit";
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)))) })) : naming !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: " new worktree \u2014 name ", titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: "", placeholder: "name the worktree, e.g. fix-auth (empty = auto)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
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(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
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,7 @@ 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
15
  import { sshCommand } from "./ssh-setup.js";
16
16
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
17
17
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
@@ -2426,6 +2426,7 @@ const dashboard = async (config) => {
2426
2426
  await runDashboard({
2427
2427
  config,
2428
2428
  cwd: process.cwd(),
2429
+ cwdBranch: gitCurrentBranch(process.cwd()),
2429
2430
  api: (method, route, body) => request(config, method, route, body),
2430
2431
  attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
2431
2432
  });
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/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.15.0",
3
+ "version": "0.15.2",
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",