Compare commits
2
Commits
harpsichord
...
etude
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed5a088299 | ||
|
|
65d7186e1c |
+53
-24
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
|||||||
import { parentDir } from "./paths.js";
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||||
import { abbreviateCwd } from "./types.js";
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
@@ -140,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "spawn-form";
|
form.className = "spawn-form";
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Harness</span>
|
||||||
|
<div class="harness-grid cmd-tiles"></div>
|
||||||
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||||
|
placeholder="bash -l" autocomplete="off" />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Project</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
@@ -151,12 +157,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">Command</span>
|
|
||||||
<div class="tile-row cmd-tiles"></div>
|
|
||||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
||||||
placeholder="bash -l" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="advanced">
|
<div class="advanced">
|
||||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
<span class="chevron">▾</span>
|
<span class="chevron">▾</span>
|
||||||
@@ -348,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
updateStructuredAvailability();
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
|
||||||
* without touching `command`, since that was already settled. */
|
* without touching `command`, since that was already settled. */
|
||||||
function reapplyActiveTile(): void {
|
function reapplyActiveTile(): void {
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a preset id to a tool icon id. The preset's command is the
|
||||||
|
* source of truth — `splitCommand` extracts the tool the same way the
|
||||||
|
* sidebar does. For the "Custom…" preset (empty command) there is no
|
||||||
|
* tool to show, and a terminal prompt stands in. */
|
||||||
|
function presetToolId(p: SpawnPreset): string | null {
|
||||||
|
if (!p.command) return null;
|
||||||
|
return splitCommand(p.command).tool;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCommandTiles(): void {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
const tile = document.createElement("button");
|
const isCustom = p.command === "";
|
||||||
tile.type = "button";
|
const card = document.createElement("button");
|
||||||
tile.className = "tile";
|
card.type = "button";
|
||||||
tile.dataset.id = p.id;
|
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
card.dataset.id = p.id;
|
||||||
tile.addEventListener("click", () => {
|
const toolId = presetToolId(p);
|
||||||
|
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||||
|
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||||
|
card.innerHTML =
|
||||||
|
`<span class="harness-icon">${iconHtml}</span>` +
|
||||||
|
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
|
||||||
|
card.addEventListener("click", () => {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
selectPreset(p.id);
|
selectPreset(p.id);
|
||||||
});
|
});
|
||||||
cmdTiles.appendChild(tile);
|
cmdTiles.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
|
||||||
|
* from toolIcon.ts but at a larger viewport so the marks breathe. */
|
||||||
|
function harnessIcon(toolId: string): string {
|
||||||
|
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
|
||||||
|
// same shape at 24x24 for the card context.
|
||||||
|
const small = toolIconSvg(toolId);
|
||||||
|
if (!small) return "";
|
||||||
|
return small
|
||||||
|
.replace(/width="13"/g, 'width="24"')
|
||||||
|
.replace(/height="13"/g, 'height="24"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ellipsis mark for the "Custom…" escape hatch. */
|
||||||
|
function customIcon(): string {
|
||||||
|
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Set once the operator picks a tile or types a custom command — guards
|
// Set once the operator picks a tile or types a custom command — guards
|
||||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||||
@@ -648,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
box.type = "button";
|
box.type = "button";
|
||||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
box.dataset.cwd = project.path;
|
box.dataset.cwd = project.path;
|
||||||
const trees = worktreesOf(group).length;
|
|
||||||
// The parent directory only — the last segment is the name line above it.
|
// The parent directory only — the last segment is the name line above it.
|
||||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||||
// sharing a basename apart (#95).
|
// sharing a basename apart (#95).
|
||||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
<span class="project-path">${escapeHtml(parent)}</span>
|
<span class="project-path">${escapeHtml(parent)}</span>`;
|
||||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
|
||||||
box.addEventListener("click", () => selectProject(group));
|
box.addEventListener("click", () => selectProject(group));
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
|||||||
const form = await mount([MAESTRO]);
|
const form = await mount([MAESTRO]);
|
||||||
box(form, MAESTRO.path).click();
|
box(form, MAESTRO.path).click();
|
||||||
await drain();
|
await drain();
|
||||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
raw.value = "bash -l";
|
raw.value = "bash -l";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "zsh",
|
(t) => t.dataset.id === "zsh",
|
||||||
)!;
|
)!;
|
||||||
zshTile.click();
|
zshTile.click();
|
||||||
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "custom",
|
(t) => t.dataset.id === "custom",
|
||||||
)!;
|
)!;
|
||||||
customTile.click();
|
customTile.click();
|
||||||
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
|
|
||||||
// Untouched: the refined preset list is still free to settle the command.
|
// Untouched: the refined preset list is still free to settle the command.
|
||||||
expect(values?.command).toBe("claude");
|
expect(values?.command).toBe("claude");
|
||||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "fish",
|
(t) => t.dataset.id === "fish",
|
||||||
);
|
);
|
||||||
expect(fishTile).toBeDefined();
|
expect(fishTile).toBeDefined();
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
|
|||||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
it("opens with the project filter focused, harness before project", async () => {
|
||||||
openSpawnModal({ onSuccess: () => {} });
|
openSpawnModal({ onSuccess: () => {} });
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const form = modal()!.querySelector("form.spawn-form")!;
|
const form = modal()!.querySelector("form.spawn-form")!;
|
||||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("titles a seeded open as a clone", async () => {
|
it("titles a seeded open as a clone", async () => {
|
||||||
|
|||||||
+46
-42
@@ -3651,61 +3651,78 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One-click tiles: host row + command row. */
|
/* Harness cards: the first decision in the spawn form — which tool runs. */
|
||||||
.tile-row {
|
.harness-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.harness-card {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 96px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) var(--sp-3);
|
padding: var(--sp-3) var(--sp-2);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile:hover {
|
.harness-card:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile.is-active {
|
.harness-card.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-label {
|
.harness-icon {
|
||||||
font-size: var(--fs-14);
|
display: flex;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-sub {
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-more {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harness-card.is-active .harness-icon {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-icon .tool-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-label {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
|
||||||
|
and subdued color separate it from the named harnesses. */
|
||||||
|
.harness-custom {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-custom .harness-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
selectable. */
|
selectable. */
|
||||||
@@ -3732,7 +3749,7 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.picker.projects {
|
.picker.projects {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
@@ -3788,19 +3805,6 @@ input.leaf-rename:focus {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-meta {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-other {
|
.project-other {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user