From d80c6e471999d1536bec6b11a2555bf50e4ff48e Mon Sep 17 00:00:00 2001 From: Julian Coy Date: Thu, 23 Jul 2026 20:59:12 -0400 Subject: [PATCH 1/2] Add independent tagged README actions, panel view, and editor round-trip integration for both conventional paths --- .../changes/support-non-asset-files/tasks.md | 2 +- packages/cli/src/commands/edit/wizard.tsx | 85 ++++--- .../src/tui/views/edit/readme-panel-view.tsx | 220 ++++++++++++++++++ .../src/tui/views/edit/use-edit-session.ts | 54 ++++- packages/cli/src/tui/views/edit/wizard.tsx | 79 ++++++- .../src/__tests__/edit-supplementary.test.ts | 95 ++++++++ packages/engine/src/edit/readme-actions.ts | 177 ++++++++++++++ packages/engine/src/index.ts | 10 + 8 files changed, 672 insertions(+), 50 deletions(-) create mode 100644 packages/cli/src/tui/views/edit/readme-panel-view.tsx create mode 100644 packages/engine/src/edit/readme-actions.ts diff --git a/openspec/changes/support-non-asset-files/tasks.md b/openspec/changes/support-non-asset-files/tasks.md index b432734d..ef87b2cc 100644 --- a/openspec/changes/support-non-asset-files/tasks.md +++ b/openspec/changes/support-non-asset-files/tasks.md @@ -126,7 +126,7 @@ - [x] 13.1 Implement: Add an editable default `README.md` scaffold option and template that writes the file and top-level declaration atomically without regenerating authored content after identity edits - [x] 13.2 Implement: Add the dedicated create README card/editor flow, optional disable behavior, state snapshotting, and explicit confirmation preview, and align headless create with the documented policy - [x] 13.3 Implement: Extend edit scanning and reconciliation for undeclared skill companions, common root files, and missing declared supplementary files while routing only exact `README.md` and `README` paths to a dedicated panel -- [ ] 13.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes +- [x] 13.4 Implement: Add independent tagged states and actions for both conventional README paths, preserving bytes on adoption and retaining exact paths for scaffold, edit, removal, and declaration changes - [x] 13.5 Implement: Replace string-parsed reconciliation keys with stable structured identities and represent file/declaration operations as tagged variants with no invalid combinations - [x] 13.6 Implement: Apply README, companion, and generic supplementary changes transactionally with manifest edits and show every queued exact-path operation before Apply - [ ] 13.7 Implement: Add engine, CLI, TUI, integration, and create-build end-to-end tests covering README defaults/disable/edit preservation in interactive and headless creates, both README paths, adoption, missing-file choices, companion discovery, skill deletion preserving undeclared files, confirmation, cancellation, and buildability diff --git a/packages/cli/src/commands/edit/wizard.tsx b/packages/cli/src/commands/edit/wizard.tsx index 6687f6a0..7b65faa9 100644 --- a/packages/cli/src/commands/edit/wizard.tsx +++ b/packages/cli/src/commands/edit/wizard.tsx @@ -1,16 +1,10 @@ import type { EditContext, EditResult } from '@agent-facets/engine' +import { readmeActionFor } from '@agent-facets/engine' import { render } from 'ink' -import type { AssetSectionKey } from '../../tui/context/form-state-context.ts' import { openInEditorSync } from '../../tui/editor.ts' -import type { EditWizardSnapshot } from '../../tui/views/edit/wizard.tsx' +import type { EditEditorRequest, EditWizardSnapshot } from '../../tui/views/edit/wizard.tsx' import { EditWizard } from '../../tui/views/edit/wizard.tsx' -interface EditorRequest { - section: AssetSectionKey - name: string - description: string -} - export interface RunEditWizardOptions { /** Apply edit operations to disk. */ onApply: (result: EditResult & { outcome: 'applied' }) => Promise @@ -25,7 +19,7 @@ export interface RunEditWizardOptions { export async function runEditWizardInk(context: EditContext, options: RunEditWizardOptions): Promise { let completed = false let snapshot: EditWizardSnapshot | undefined - let pendingEditor: EditorRequest | null = null + let pendingEditor: EditEditorRequest | null = null let done = false while (!done) { @@ -44,8 +38,8 @@ export async function runEditWizardInk(context: EditContext, options: RunEditWiz onSnapshot={(s) => { snapshot = s }} - onRequestEditor={(section, name, description) => { - pendingEditor = { section, name, description } + onRequestEditor={(request) => { + pendingEditor = request instance.clear() instance.unmount() }} @@ -56,29 +50,7 @@ export async function runEditWizardInk(context: EditContext, options: RunEditWiz }) if (pendingEditor) { - const req = pendingEditor as EditorRequest - const edited = openInEditorSync(req.description, `${req.name}.md`) - if (snapshot) { - snapshot = { - ...snapshot, - selectedItem: undefined, - formState: snapshot.formState - ? { - ...snapshot.formState, - assets: { - ...snapshot.formState.assets, - [req.section]: { - ...snapshot.formState.assets[req.section], - descriptions: { - ...snapshot.formState.assets[req.section].descriptions, - ...(edited !== null ? { [req.name]: edited.trim() } : {}), - }, - }, - }, - } - : undefined, - } - } + snapshot = applyEditorRoundTrip(pendingEditor, snapshot) } else { done = true } @@ -86,3 +58,48 @@ export async function runEditWizardInk(context: EditContext, options: RunEditWiz return completed } + +/** + * Open the external editor for a pending request and fold the result back into + * the wizard snapshot so the re-rendered wizard sees the edit. Asset-description + * edits update the form; README edits become the chosen tagged action on the + * exact path (bytes discarded → no action, so a cancelled editor leaves the + * path untouched rather than queuing empty content). + */ +function applyEditorRoundTrip( + request: EditEditorRequest, + snapshot: EditWizardSnapshot | undefined, +): EditWizardSnapshot | undefined { + if (!snapshot) return snapshot + + if (request.kind === 'asset-description') { + const edited = openInEditorSync(request.content, `${request.name}.md`) + return { + ...snapshot, + selectedItem: undefined, + formState: snapshot.formState + ? { + ...snapshot.formState, + assets: { + ...snapshot.formState.assets, + [request.section]: { + ...snapshot.formState.assets[request.section], + descriptions: { + ...snapshot.formState.assets[request.section].descriptions, + ...(edited !== null ? { [request.name]: edited.trim() } : {}), + }, + }, + }, + } + : undefined, + } + } + + // README content edit: seed the editor with the request content, then queue + // the tagged action for this path. A cancelled editor (null) queues nothing. + const edited = openInEditorSync(request.content, request.path) + if (edited === null) return { ...snapshot, selectedItem: undefined } + const nextReadme = new Map(snapshot.readmeActions) + nextReadme.set(request.path, readmeActionFor(request.option.kind, edited)) + return { ...snapshot, selectedItem: undefined, readmeActions: nextReadme } +} diff --git a/packages/cli/src/tui/views/edit/readme-panel-view.tsx b/packages/cli/src/tui/views/edit/readme-panel-view.tsx new file mode 100644 index 00000000..734a33e3 --- /dev/null +++ b/packages/cli/src/tui/views/edit/readme-panel-view.tsx @@ -0,0 +1,220 @@ +import { + type ReadmeAction, + type ReadmeActionOption, + type ReadmeFileState, + type ReadmePath, + readmeActionOptions, + readmeOptionKindFor, +} from '@agent-facets/engine' +import { Box, Text, useInput } from 'ink' +import Gradient from 'ink-gradient' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button } from '../../components/button.tsx' +import { useFocusOrder } from '../../context/focus-order-context.ts' +import { GRADIENT_STOPS, getAnimatedGradient } from '../../gradient.ts' +import { THEME } from '../../theme.ts' + +const ANIMATION_INTERVAL_MS = 75 + +/** Human-readable summary of a README path's current on-disk/declaration state. */ +function stateLabel(state: ReadmeFileState): string { + switch (state.state) { + case 'present-declared': + return 'present, declared' + case 'present-undeclared': + return 'present, not declared' + case 'declared-missing': + return 'declared, missing on disk' + case 'absent-undeclared': + return 'absent' + } +} + +/** + * One README path row. Renders the path, its current state, and its legal + * action options (1 or 2 per state). Left/right move the highlight; Enter + * selects. Content-bearing options are marked so the parent opens the editor. + * `none` (leave as-is) is always available as an implicit first choice so an + * author can decline to touch a path. + */ +function ReadmeRow({ + id, + state, + action, + onSelect, +}: { + id: string + state: ReadmeFileState + action: ReadmeAction | undefined + onSelect: (option: ReadmeActionOption) => void +}) { + const { focusedId } = useFocusOrder() + const isFocused = focusedId === id + const options = useMemo(() => readmeActionOptions(state), [state]) + + const selectedKind = action ? readmeOptionKindFor(action) : null + const selectedIndex = selectedKind ? options.findIndex((o) => o.kind === selectedKind) : -1 + + const [highlightedIndex, setHighlightedIndex] = useState(selectedIndex >= 0 ? selectedIndex : 0) + const [offset, setOffset] = useState(0) + + useEffect(() => { + if (!isFocused) return + const interval = setInterval(() => { + setOffset((prev) => (prev + 1) % GRADIENT_STOPS.length) + }, ANIMATION_INTERVAL_MS) + return () => clearInterval(interval) + }, [isFocused]) + + useInput( + (_input, key) => { + if (key.leftArrow) setHighlightedIndex((i) => Math.max(0, i - 1)) + if (key.rightArrow) setHighlightedIndex((i) => Math.min(options.length - 1, i + 1)) + if (key.return) { + const option = options[highlightedIndex] + if (option) onSelect(option) + } + }, + { isActive: isFocused }, + ) + + const animatedColors = getAnimatedGradient(offset) + const leaveSelected = action === undefined || action.kind === 'none' + + return ( + + + + {isFocused ? ( + + ▸ + + ) : ( + + )} + {state.path} + + + + {options.map((opt, i) => { + const isSelected = selectedIndex === i + const isHighlighted = isFocused && highlightedIndex === i + if (isHighlighted) { + return ( + + {isSelected && } + + {opt.label} + + + ) + } + if (isSelected) { + return ( + + + + {opt.label} + + + ) + } + return ( + + {opt.label} + + ) + })} + + + + + + {stateLabel(state)} + {leaveSelected ? ' · leaving as-is' : ''} + + + + ) +} + +/** + * The dedicated facet-level README panel (design D11). Shows both conventional + * README paths independently, each with its legal actions. Content-bearing + * choices request the external editor via `onEditReadme`; all others are queued + * immediately via `onResolve`. No disk or manifest change happens here — every + * choice is queued until the confirmation Apply. + */ +export function ReadmePanelView({ + states, + actions, + onResolve, + onEditReadme, + onContinue, +}: { + states: ReadmeFileState[] + actions: Map + onResolve: (path: ReadmePath, option: ReadmeActionOption) => void + onEditReadme: (path: ReadmePath, option: ReadmeActionOption) => void + onContinue: () => void +}) { + const { setFocusIds, focusedId, focus } = useFocusOrder() + + const focusIds = useMemo(() => { + const ids = states.map((s) => `readme-${s.path}`) + ids.push('readme-continue') + return ids + }, [states]) + + useEffect(() => { + setFocusIds(focusIds) + if (!focusedId) focus(focusIds[0] ?? '') + }, [focusIds, setFocusIds, focusedId, focus]) + + const handleSelect = useCallback( + (path: ReadmePath, option: ReadmeActionOption) => { + if (option.requiresEditor) { + onEditReadme(path, option) + } else { + onResolve(path, option) + } + focus('readme-continue') + }, + [onResolve, onEditReadme, focus], + ) + + return ( + + + README + + + Manage the conventional README files. Changes apply on the final confirmation. + + + {states.map((state) => ( + handleSelect(state.path, option)} + /> + ))} + + +