From 0eab55e08aad1cf9c36f925701173f71a2808d8a Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Mon, 17 Aug 2026 22:30:14 +0200 Subject: [PATCH 1/4] fix: allowed long input in TUI Create Goal Wizard. --- src/presentation/tui/wizard/Wizard.tsx | 2 +- .../tui/wizard/WizardTextInput.tsx | 17 +++++----- .../tui/goals/GoalAuthoringFlow.test.tsx | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/presentation/tui/wizard/Wizard.tsx b/src/presentation/tui/wizard/Wizard.tsx index 64bbfe7a..7f5ce39f 100644 --- a/src/presentation/tui/wizard/Wizard.tsx +++ b/src/presentation/tui/wizard/Wizard.tsx @@ -459,7 +459,7 @@ export function Wizard({ backgroundColor={BaseColors.black} paddingX={4} paddingY={2} - minWidth={OVERLAY_MIN_WIDTH} + width={OVERLAY_MIN_WIDTH} ref={panelRef} > diff --git a/src/presentation/tui/wizard/WizardTextInput.tsx b/src/presentation/tui/wizard/WizardTextInput.tsx index b00ee0e2..6932bd20 100644 --- a/src/presentation/tui/wizard/WizardTextInput.tsx +++ b/src/presentation/tui/wizard/WizardTextInput.tsx @@ -69,6 +69,14 @@ export function WizardTextInput({ backgroundColor={INPUT_BACKGROUND} > {placeholder} + {focused && ( + + ▎ + + )} ) : ( {value} - - )} - {focused && ( - - ▎ + {focused && "▎"} )} diff --git a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx index e912acbd..4b0151bc 100644 --- a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx +++ b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { describe, expect, it, jest } from "@jest/globals"; import { render } from "ink-testing-library"; +import stripAnsi from "strip-ansi"; import { GoalAuthoringFlow } from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; import { WizardValidationCopy } from "../../../../src/presentation/tui/wizard/WizardConstants.js"; @@ -35,6 +36,36 @@ describe("GoalAuthoringFlow", () => { unmount(); }); + it("wraps a long objective without expanding the wizard backdrop", async () => { + const objective = + "Allow full fidelity view of a Decision by extending the commands with 'jumbo decision show --id'. Today only summaries are visible via 'jumbo decisions list'"; + const { lastFrame, stdin, unmount } = render( + {}} onCancel={() => {}} />, + ); + + stdin.write("Demonstration"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write(objective); + const frame = stripAnsi( + await waitForFrame(lastFrame, (renderedFrame) => + renderedFrame.includes("list'") && renderedFrame.includes("▎"), + ), + ); + + expect( + frame.split("\n").every((line) => line.trimStart().length <= 88), + ).toBe(true); + expect(frame.replace(/\s+/g, " ")).toContain(objective); + expect(frame.replace(/\s+/g, " ")).toContain(`list'▎`); + expect(frame).toContain("▎"); + expect(frame).toContain("Title"); + expect(frame).toContain("Objective"); + expect(frame).toContain("1/5"); + unmount(); + }); + it("includes all goal authoring steps", async () => { const { lastFrame, stdin, unmount } = render( {}} onCancel={() => {}} />, From 4ff3ef2d130143092ff6f588209f4435c316cf65 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Mon, 17 Aug 2026 22:39:14 +0200 Subject: [PATCH 2/4] chore: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ad58dd..e8db75c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **TUI goal authoring layout**: Long objective values now wrap within the bounded goal wizard without expanding its background into horizontal bands, and the input cursor remains after the final wrapped character. + ## [3.20.0] - 2026-07-25 ### Added From 823038ec5c3a672251ebb61c0c332141330e3b13 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Mon, 17 Aug 2026 23:45:25 +0200 Subject: [PATCH 3/4] fix(tui): collect goal scope as repeated items --- CHANGELOG.md | 1 + .../tui/goals/AddGoalRequestFactory.ts | 16 +- .../tui/goals/GoalAuthoringFlow.tsx | 157 ++++++++++++++---- .../tui/goals/GoalAuthoringFlowConstants.ts | 14 +- .../tui/application-shell/App.test.tsx | 34 +++- .../tui/goals/AddGoalRequestFactory.test.ts | 57 +++++++ .../tui/goals/GoalAuthoringFlow.test.tsx | 97 +++++++++-- .../tui/goals/GoalsScreen.test.tsx | 52 +++++- 8 files changed, 358 insertions(+), 70 deletions(-) create mode 100644 tests/presentation/tui/goals/AddGoalRequestFactory.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8db75c6..c1374ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **TUI goal scope authoring**: Scope-in and scope-out values are now collected as independent repeatable items, preserving spaces, item order, and entered values when navigating backward or forward through the goal wizard. - **TUI goal authoring layout**: Long objective values now wrap within the bounded goal wizard without expanding its background into horizontal bands, and the input cursor remains after the final wrapped character. ## [3.20.0] - 2026-07-25 diff --git a/src/presentation/tui/goals/AddGoalRequestFactory.ts b/src/presentation/tui/goals/AddGoalRequestFactory.ts index 9ee5f825..a92c48a9 100644 --- a/src/presentation/tui/goals/AddGoalRequestFactory.ts +++ b/src/presentation/tui/goals/AddGoalRequestFactory.ts @@ -1,10 +1,10 @@ /** * AddGoalRequestFactory - Assembles an AddGoalRequest from GoalAuthoringValues. * - * Normalizes free-text authoring fields at the presentation-application - * boundary: blank optional text becomes undefined and whitespace/comma - * separated lists become arrays. Shared by the App overlay and GoalsScreen - * submission paths so the two cannot drift. + * Normalizes optional authoring fields at the presentation-application + * boundary. Scope arrays are copied without parsing so item content and order + * stay intact. Shared by the App overlay and GoalsScreen submission paths so + * the two cannot drift. */ import type { AddGoalRequest } from "../../../application/context/goals/add/AddGoalRequest.js"; @@ -16,8 +16,8 @@ export const AddGoalRequestFactory = { title: values.title, objective: values.objective, successCriteria: [...values.successCriteria], - scopeIn: optionalList(values.scopeIn), - scopeOut: optionalList(values.scopeOut), + scopeIn: optionalArray(values.scopeIn), + scopeOut: optionalArray(values.scopeOut), nextGoalId: optionalText(values.nextGoal), previousGoalId: optionalText(values.previousGoal), prerequisiteGoals: optionalList(values.prerequisiteGoals), @@ -40,3 +40,7 @@ function optionalList(value: string): string[] | undefined { return values.length > 0 ? values : undefined; } + +function optionalArray(values: readonly string[]): string[] | undefined { + return values.length > 0 ? [...values] : undefined; +} diff --git a/src/presentation/tui/goals/GoalAuthoringFlow.tsx b/src/presentation/tui/goals/GoalAuthoringFlow.tsx index 99297deb..969c1566 100644 --- a/src/presentation/tui/goals/GoalAuthoringFlow.tsx +++ b/src/presentation/tui/goals/GoalAuthoringFlow.tsx @@ -15,8 +15,8 @@ export interface GoalAuthoringValues { readonly title: string; readonly objective: string; readonly successCriteria: readonly string[]; - readonly scopeIn: string; - readonly scopeOut: string; + readonly scopeIn: readonly string[]; + readonly scopeOut: readonly string[]; readonly nextGoal: string; readonly previousGoal: string; readonly prerequisiteGoals: string; @@ -43,27 +43,6 @@ const DETAILS_STEPS: readonly WizardStepDefinition[] = [ }, ] as const; -const SCOPE_STEPS: readonly WizardStepDefinition[] = [ - { - title: GoalAuthoringCopy.scope.title, - description: GoalAuthoringCopy.scope.description, - fields: [ - { - key: GoalAuthoringFieldKey.SCOPE_IN, - label: GoalAuthoringCopy.scope.fields.scopeIn, - placeholder: GoalAuthoringCopy.scope.fields.scopeInPlaceholder, - required: false, - }, - { - key: GoalAuthoringFieldKey.SCOPE_OUT, - label: GoalAuthoringCopy.scope.fields.scopeOut, - placeholder: GoalAuthoringCopy.scope.fields.scopeOutPlaceholder, - required: false, - }, - ], - }, -] as const; - const SEQUENCING_STEPS: readonly WizardStepDefinition[] = [ { title: GoalAuthoringCopy.sequencing.title, @@ -121,6 +100,10 @@ interface GoalAuthoringFlowProps { readonly disabled?: boolean; } +type ScopeFieldKey = + | typeof GoalAuthoringFieldKey.SCOPE_IN + | typeof GoalAuthoringFieldKey.SCOPE_OUT; + export function GoalAuthoringFlow({ onComplete, onCancel, @@ -134,9 +117,13 @@ export function GoalAuthoringFlow({ const [objective, setObjective] = useState(""); const [successCriteria, setSuccessCriteria] = useState([]); const [scopeValues, setScopeValues] = useState({ - scopeIn: "", - scopeOut: "", + scopeIn: [] as readonly string[], + scopeOut: [] as readonly string[], }); + const [scopeFieldKey, setScopeFieldKey] = useState( + GoalAuthoringFieldKey.SCOPE_IN, + ); + const [scopeEditIndex, setScopeEditIndex] = useState(0); const [sequencingValues, setSequencingValues] = useState({ previousGoal: "", nextGoal: "", @@ -154,6 +141,10 @@ export function GoalAuthoringFlow({ () => buildCriteriaSteps(criterionNumber), [criterionNumber], ); + const scopeSteps = useMemo( + () => buildScopeSteps(scopeFieldKey, scopeEditIndex + 1), + [scopeEditIndex, scopeFieldKey], + ); const handleDetailsConfirm = (values: Record) => { setTitle(values[GoalAuthoringFieldKey.TITLE] ?? ""); @@ -181,10 +172,33 @@ export function GoalAuthoringFlow({ }; const handleScopeConfirm = (values: Record) => { - setScopeValues({ - scopeIn: values[GoalAuthoringFieldKey.SCOPE_IN] ?? "", - scopeOut: values[GoalAuthoringFieldKey.SCOPE_OUT] ?? "", - }); + const item = values[scopeFieldKey] ?? ""; + const nextScopeValues = { + ...scopeValues, + [scopeFieldKey]: replaceScopeItem( + scopeValues[scopeFieldKey], + scopeEditIndex, + item, + ), + }; + setScopeValues(nextScopeValues); + + if ( + values[scopeAddAnotherFieldKey(scopeFieldKey)] === + GoalAuthoringCriterionValue.YES + ) { + setScopeEditIndex(scopeEditIndex + 1); + setWizardKey((current) => current + 1); + return; + } + + if (scopeFieldKey === GoalAuthoringFieldKey.SCOPE_IN) { + setScopeFieldKey(GoalAuthoringFieldKey.SCOPE_OUT); + setScopeEditIndex(0); + setWizardKey((current) => current + 1); + return; + } + setStage(GoalAuthoringStage.SEQUENCING); }; @@ -229,11 +243,27 @@ export function GoalAuthoringFlow({ }; const handleScopeBack = () => { + if (scopeEditIndex > 0) { + setScopeEditIndex(scopeEditIndex - 1); + setWizardKey((current) => current + 1); + return; + } + + if (scopeFieldKey === GoalAuthoringFieldKey.SCOPE_OUT) { + setScopeFieldKey(GoalAuthoringFieldKey.SCOPE_IN); + setScopeEditIndex(Math.max(scopeValues.scopeIn.length - 1, 0)); + setWizardKey((current) => current + 1); + return; + } + setCriteriaEditIndex(Math.max(successCriteria.length - 1, 0)); setStage(GoalAuthoringStage.CRITERIA); }; const handleSequencingBack = () => { + setScopeFieldKey(GoalAuthoringFieldKey.SCOPE_OUT); + setScopeEditIndex(Math.max(scopeValues.scopeOut.length - 1, 0)); + setWizardKey((current) => current + 1); setStage(GoalAuthoringStage.SCOPE); }; @@ -280,13 +310,19 @@ export function GoalAuthoringFlow({ if (stage === GoalAuthoringStage.SCOPE) { return ( { await tick(); stdin.write("\r"); await tick(); - stdin.write("src/presentation/tui"); + stdin.write("src/presentation tui"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); - stdin.write("src/application"); + stdin.write("y"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("tests/presentation/tui"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("src/application layer"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("y"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("src/domain"); + await tick(); + stdin.write("\r"); await tick(); stdin.write("\r"); await tick(); @@ -920,8 +940,8 @@ describe("App", () => { title: "Prototype Cockpit goal authoring", objective: "Open goal authoring from Cockpit", successCriteria: ["Wizard opens and closes"], - scopeIn: ["src/presentation/tui"], - scopeOut: ["src/application"], + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], nextGoalId: undefined, previousGoalId: undefined, prerequisiteGoals: undefined, @@ -975,6 +995,10 @@ describe("App", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out")); stdin.write("\r"); await tick(); stdin.write("\r"); diff --git a/tests/presentation/tui/goals/AddGoalRequestFactory.test.ts b/tests/presentation/tui/goals/AddGoalRequestFactory.test.ts new file mode 100644 index 00000000..792be7e0 --- /dev/null +++ b/tests/presentation/tui/goals/AddGoalRequestFactory.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@jest/globals"; +import { AddGoalRequestFactory } from "../../../../src/presentation/tui/goals/AddGoalRequestFactory.js"; +import type { GoalAuthoringValues } from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; + +describe("AddGoalRequestFactory", () => { + it("maps multiple scope items without changing their order or content", () => { + const values = authoringValues({ + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], + }); + + const request = AddGoalRequestFactory.create(values); + + expect(request.scopeIn).toEqual([ + "src/presentation tui", + "tests/presentation/tui", + ]); + expect(request.scopeOut).toEqual(["src/application layer", "src/domain"]); + }); + + it("maps a single scope item as one array element", () => { + const request = AddGoalRequestFactory.create( + authoringValues({ + scopeIn: ["one scope in item"], + scopeOut: ["one scope out item"], + }), + ); + + expect(request.scopeIn).toEqual(["one scope in item"]); + expect(request.scopeOut).toEqual(["one scope out item"]); + }); + + it("maps empty scope arrays to undefined", () => { + const request = AddGoalRequestFactory.create(authoringValues()); + + expect(request.scopeIn).toBeUndefined(); + expect(request.scopeOut).toBeUndefined(); + }); +}); + +function authoringValues( + overrides: Partial = {}, +): GoalAuthoringValues { + return { + title: "Goal title", + objective: "Goal objective", + successCriteria: ["Goal criterion"], + scopeIn: [], + scopeOut: [], + nextGoal: "", + previousGoal: "", + prerequisiteGoals: "", + branch: "", + worktree: "", + ...overrides, + }; +} diff --git a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx index 4b0151bc..29835d08 100644 --- a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx +++ b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx @@ -88,17 +88,21 @@ describe("GoalAuthoringFlow", () => { await tick(); stdin.write("\r"); await tick(); - expect(lastFrame()).toContain("Scope in (optional)"); + expect(lastFrame()).toContain("Scope in item (optional)"); expect(lastFrame()).toContain("3/5"); stdin.write("src/presentation/tui"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); stdin.write("src/application"); await tick(); stdin.write("\r"); await tick(); + stdin.write("\r"); + await tick(); expect(lastFrame()).toContain("Previous goal (optional)"); expect(lastFrame()).toContain("4/5"); stdin.write("\r"); @@ -112,7 +116,7 @@ describe("GoalAuthoringFlow", () => { unmount(); }); - it("collects success criteria as an array", async () => { + it("collects criteria and multiple scope items as arrays", async () => { const handleComplete = jest.fn(); const { stdin, lastFrame, unmount } = render( {}} />, @@ -143,11 +147,44 @@ describe("GoalAuthoringFlow", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); - stdin.write("src/presentation/tui"); + stdin.write("src/presentation/tui goals"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); - stdin.write("src/application"); + stdin.write("y"); + await tick(); + stdin.write("\r"); + await waitForFrame( + lastFrame, + (frame) => + frame.includes("Add another scope-in item?") && + !frame.includes("src/presentation/tui goals"), + ); + stdin.write("tests/presentation/tui"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => + frame.includes("Add another scope-out item?"), + ); + + stdin.write("src/application layer"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("y"); + await tick(); + stdin.write("\r"); + await waitForFrame( + lastFrame, + (frame) => + frame.includes("Add another scope-out item?") && + !frame.includes("src/application layer"), + ); + stdin.write("src/domain"); + await tick(); + stdin.write("\r"); await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); @@ -178,8 +215,8 @@ describe("GoalAuthoringFlow", () => { title: "Prototype S2", objective: "Prototype the Goals screen", successCriteria: ["Renders goals", "Shows goal detail"], - scopeIn: "src/presentation/tui", - scopeOut: "src/application", + scopeIn: ["src/presentation/tui goals", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], previousGoal: "goal_previous", nextGoal: "goal_next", prerequisiteGoals: "goal_prerequisite", @@ -210,6 +247,10 @@ describe("GoalAuthoringFlow", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out")); stdin.write("\r"); await tick(); stdin.write("\r"); @@ -232,8 +273,8 @@ describe("GoalAuthoringFlow", () => { title: "Prototype S2", objective: "Prototype the Goals screen", successCriteria: ["Renders goals"], - scopeIn: "", - scopeOut: "", + scopeIn: [], + scopeOut: [], previousGoal: "", nextGoal: "", prerequisiteGoals: "", @@ -243,7 +284,7 @@ describe("GoalAuthoringFlow", () => { unmount(); }); - it("preserves earlier answers when navigating back", async () => { + it("preserves scope items when navigating backward and forward", async () => { const { stdin, lastFrame, unmount } = render( {}} onCancel={() => {}} />, ); @@ -262,20 +303,42 @@ describe("GoalAuthoringFlow", () => { await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); - stdin.write("src/presentation/tui"); + stdin.write("src/presentation tui"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); - stdin.write("src/application"); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + stdin.write("src/application layer"); + await tick(); + stdin.write("\r"); await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); stdin.write(LEFT_ARROW); - await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); - expect(lastFrame()).toContain("src/presentation/tui"); - expect(lastFrame()).toContain("src/application"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + expect(lastFrame()).toContain("src/application layer"); + stdin.write(LEFT_ARROW); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope in item")); + expect(lastFrame()).toContain("src/presentation tui"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + expect(lastFrame()).toContain("src/application layer"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); + + stdin.write(LEFT_ARROW); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + stdin.write(LEFT_ARROW); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope in item")); stdin.write(LEFT_ARROW); await waitForFrame(lastFrame, (frame) => frame.includes("Success criterion"), diff --git a/tests/presentation/tui/goals/GoalsScreen.test.tsx b/tests/presentation/tui/goals/GoalsScreen.test.tsx index 6d6d8c86..35420d01 100644 --- a/tests/presentation/tui/goals/GoalsScreen.test.tsx +++ b/tests/presentation/tui/goals/GoalsScreen.test.tsx @@ -45,7 +45,13 @@ async function waitUntil(condition: () => boolean): Promise { async function submitAuthoringFlow( stdin: ReturnType["stdin"], readFrame: () => string | undefined, - values: { readonly title: string; readonly objective: string; readonly criterion: string }, + values: { + readonly title: string; + readonly objective: string; + readonly criterion: string; + readonly scopeIn?: readonly string[]; + readonly scopeOut?: readonly string[]; + }, ): Promise { stdin.write("n"); await waitForFrame(readFrame, "Author Goal"); @@ -66,10 +72,18 @@ async function submitAuthoringFlow( stdin.write("\r"); await waitForFrame(readFrame, "Scope in"); - stdin.write("\r"); - await settleInput(); - stdin.write("\r"); - await waitForFrame(readFrame, "Previous goal"); + await submitScopeCollection( + stdin, + readFrame, + values.scopeIn ?? [], + "Scope out", + ); + await submitScopeCollection( + stdin, + readFrame, + values.scopeOut ?? [], + "Previous goal", + ); stdin.write("\r"); await settleInput(); @@ -84,6 +98,30 @@ async function submitAuthoringFlow( await settleInput(); } +async function submitScopeCollection( + stdin: ReturnType["stdin"], + readFrame: () => string | undefined, + items: readonly string[], + nextStageText: string, +): Promise { + const submittedItems = items.length > 0 ? items : [""]; + + for (const [index, item] of submittedItems.entries()) { + stdin.write(item); + await settleInput(); + stdin.write("\r"); + await settleInput(); + if (index < submittedItems.length - 1) { + stdin.write("y"); + await settleInput(); + } + stdin.write("\r"); + await settleInput(); + } + + await waitForFrame(readFrame, nextStageText); +} + async function navigateRightUntil( stdin: ReturnType["stdin"], readFrame: () => string | undefined, @@ -626,6 +664,8 @@ describe("GoalsScreen", () => { title: "Created goal", objective: "Dispatch the authored goal", criterion: "Goal is persisted", + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], }); await waitUntil(() => dispatchedRequests.length > 0); @@ -634,6 +674,8 @@ describe("GoalsScreen", () => { title: "Created goal", objective: "Dispatch the authored goal", successCriteria: ["Goal is persisted"], + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], }); unmount(); }); From e62f47b9d08eb1f12f74ce22ca2d5acc5d0ab428 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Tue, 18 Aug 2026 00:33:42 +0200 Subject: [PATCH 4/4] Add TUI goal creation result screen --- CHANGELOG.md | 5 +- docs/getting-started/first-run.md | 4 +- .../tui/application-shell/App.tsx | 96 ++++---- .../tui/goals/GoalAuthoringFlow.tsx | 232 ++++++++++++++++-- .../tui/goals/GoalAuthoringFlowConstants.ts | 30 +++ src/presentation/tui/goals/GoalsScreen.tsx | 112 +++++---- .../tui/application-shell/App.test.tsx | 160 ++++++++---- .../tui/goals/GoalAuthoringFlow.test.tsx | 221 ++++++++++++++++- .../goals/GoalAuthoringFlowConstants.test.ts | 26 ++ .../tui/goals/GoalsScreen.test.tsx | 53 +++- 10 files changed, 771 insertions(+), 168 deletions(-) create mode 100644 tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1374ffd..18cd528d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **TUI goal creation result screen**: Cockpit and Goals-screen authoring now show a bounded pending, success, or failure result after submission. Successful results include the created goal ID and remain visible until Enter is pressed; failures retain every entered value for retry or allow cancellation with Escape. + ### Fixed - **TUI goal scope authoring**: Scope-in and scope-out values are now collected as independent repeatable items, preserving spaces, item order, and entered values when navigating backward or forward through the goal wizard. @@ -817,7 +821,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Goal update extended**: 'goal update' extended with '--nextGoalId' for latent goal chaining. - ## [1.0.0-beta.0] - 2026-01-01 ### Changed diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index ac344eaa..4750095e 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -111,7 +111,7 @@ Select option 2 to auto-approve subsequent registrations of the same type, or re End your agent session when finished, or clear context and switch to a fresh terminal for Step 3. :::tip[Best practice] -Archive all of your context related `.md` files to a location outside of your repository, and strip your `AGENTS.md` of any special instructions that can be registered with Jumbo. They will cause context bloat and potential conflicting instructions (as your project evolves) if left for agents to read. *You can always ask an agent to port them from Jumbo to an `.md` later if you want to remove Jumbo.* +Archive all of your context related `.md` files to a location outside of your repository, and strip your `AGENTS.md` of any special instructions that can be registered with Jumbo. They will cause context bloat and potential conflicting instructions (as your project evolves) if left for agents to read. _You can always ask an agent to port them from Jumbo to an `.md` later if you want to remove Jumbo._ ::: --- @@ -133,7 +133,7 @@ jumbo goal add \ "Includes unit tests for limit exceeded scenarios" ``` -If you are using the TUI, run `jumbo` and press `g` from the primed-empty Cockpit screen to open the same goal authoring flow without leaving the interface. +If you are using the TUI, run `jumbo` and press `g` from the primed-empty Cockpit screen to open the same goal authoring flow without leaving the interface. After submission, the final screen shows the request status and created goal ID; press Enter to acknowledge success, or retry or cancel if creation fails. You can also press `m` anywhere the main TUI frame owns input to open the Mega Menu. Use the arrow keys and Enter to navigate between Cockpit, Goals, Memory, and Settings. Goal menu entries such as Backlog, Active, and Archive open the Goals screen with the matching status filter already applied. diff --git a/src/presentation/tui/application-shell/App.tsx b/src/presentation/tui/application-shell/App.tsx index 3b52b0db..6b767196 100644 --- a/src/presentation/tui/application-shell/App.tsx +++ b/src/presentation/tui/application-shell/App.tsx @@ -9,7 +9,11 @@ import { SearchOverlay } from "../search/SearchOverlay.js"; import { InitFlow } from "../project-initialization/InitFlow.js"; import type { InitFlowActionControllers } from "../project-initialization/InitFlow.js"; import { GoalAuthoringFlow } from "../goals/GoalAuthoringFlow.js"; -import type { GoalAuthoringValues } from "../goals/GoalAuthoringFlow.js"; +import type { + GoalAuthoringSubmissionResult, + GoalAuthoringValues, +} from "../goals/GoalAuthoringFlow.js"; +import { GoalAuthoringRequestStatus } from "../goals/GoalAuthoringFlowConstants.js"; import { AddGoalRequestFactory } from "../goals/AddGoalRequestFactory.js"; import { DEFAULT_SCREEN_INDEX } from "../navigation/ScreenDefinitions.js"; import { ActionDispatcher } from "../action-dispatch/ActionDispatcher.js"; @@ -20,7 +24,10 @@ import type { StateReaderOptions } from "../state-reading/StateReaderOptions.js" import { useProjectContext } from "../state-reading/useProjectContext.js"; import { SubprocessManagerProvider } from "../daemon-subprocesses/SubprocessManagerProvider.js"; import { useSubprocessManager } from "../daemon-subprocesses/useSubprocessManager.js"; -import type { ISubprocessManager, SubprocessSnapshot } from "../daemon-subprocesses/ISubprocessManager.js"; +import type { + ISubprocessManager, + SubprocessSnapshot, +} from "../daemon-subprocesses/ISubprocessManager.js"; import type { NotificationDrawerNotification } from "./NotificationDrawer.js"; import type { CliUpdateController } from "../../../application/cli-metadata/update/CliUpdateController.js"; import type { CliUpdateCheckResult } from "../../../application/cli-metadata/update/CliUpdateCheckResult.js"; @@ -187,19 +194,17 @@ function AppFrame({ const subprocessManager = useSubprocessManager(); const { columns, rows } = useTerminalDimensions(); const projectContext = useProjectContext(); - const [activeScreenIndex, setActiveScreenIndex] = useState( - DEFAULT_SCREEN_INDEX, - ); + const [activeScreenIndex, setActiveScreenIndex] = + useState(DEFAULT_SCREEN_INDEX); const [initFlowOpen, setInitFlowOpen] = useState(false); const [goalAuthoringOpen, setGoalAuthoringOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [megaMenuOpen, setMegaMenuOpen] = useState(false); const [notificationDrawerOpen, setNotificationDrawerOpen] = useState(false); const [screenModalOpen, setScreenModalOpen] = useState(false); - const [goalStatusFilter, setGoalStatusFilter] = - useState(undefined); - const [goalAuthoringError, setGoalAuthoringError] = useState(null); - const [goalAuthoringWorking, setGoalAuthoringWorking] = useState(false); + const [goalStatusFilter, setGoalStatusFilter] = useState< + readonly GoalStatusType[] | undefined + >(undefined); const [lifecycleRouteOverride, setLifecycleRouteOverride] = useState(null); const [unprimedSkipped, setUnprimedSkipped] = useState(false); @@ -209,9 +214,9 @@ function AppFrame({ const [billboardAnimationComplete, setBillboardAnimationComplete] = useState( !launchAnimationEnabled, ); - const [daemonStatuses, setDaemonStatuses] = useState( - subprocessManager.getAllStatuses(), - ); + const [daemonStatuses, setDaemonStatuses] = useState< + readonly SubprocessSnapshot[] + >(subprocessManager.getAllStatuses()); const [cliUpdateCheck, setCliUpdateCheck] = useState(null); const [cliUpgradeResult, setCliUpgradeResult] = @@ -338,7 +343,6 @@ function AppFrame({ setInitFlowOpen(true); } if (goalAuthoringShortcutEnabled && (input === "g" || input === "G")) { - setGoalAuthoringError(null); setGoalAuthoringOpen(true); } if ( @@ -379,10 +383,9 @@ function AppFrame({ const handleInitComplete = useCallback( async (_values: Record) => { - await completeStateChangingOverlay( - () => setInitFlowOpen(false), - { reinstallStateReaders: true }, - ); + await completeStateChangingOverlay(() => setInitFlowOpen(false), { + reinstallStateReaders: true, + }); }, [completeStateChangingOverlay], ); @@ -392,36 +395,47 @@ function AppFrame({ }, []); const handleGoalAuthoringComplete = useCallback( - async (values: GoalAuthoringValues) => { + async ( + values: GoalAuthoringValues, + ): Promise => { const addGoalController = actionControllers?.addGoalController; if (addGoalController === undefined) { - setGoalAuthoringError(AppCopy.goalAuthoringUnavailable); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: AppCopy.goalAuthoringUnavailable, + }; } - setGoalAuthoringWorking(true); - setGoalAuthoringError(null); const result = await ActionDispatcher.dispatch( addGoalController, AddGoalRequestFactory.create(values), ); - setGoalAuthoringWorking(false); if (!result.ok) { - setGoalAuthoringError(result.error.message); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: result.error.message, + }; } - await completeStateChangingOverlay( - () => setGoalAuthoringOpen(false), - { lifecycleRouteOverride: ProjectLifecycle.PRIMED }, - ); + return { + status: GoalAuthoringRequestStatus.SUCCESS, + goalId: result.response.goalId, + }; }, - [actionControllers, completeStateChangingOverlay], + [actionControllers], + ); + + const handleGoalAuthoringAcknowledged = useCallback( + async (_goalId: string) => { + await completeStateChangingOverlay(() => setGoalAuthoringOpen(false), { + lifecycleRouteOverride: ProjectLifecycle.PRIMED, + }); + }, + [completeStateChangingOverlay], ); const handleGoalAuthoringCancel = useCallback(() => { - setGoalAuthoringError(null); setGoalAuthoringOpen(false); }, []); @@ -444,11 +458,7 @@ function AppFrame({ setCliUpgradeResult(result); setCliUpgradeWorking(false); }, - [ - cliUpdateCheck, - cliUpdateController, - cliUpgradeWorking, - ], + [cliUpdateCheck, cliUpdateController, cliUpgradeWorking], ); const handleBannerAnimationComplete = useCallback(() => { @@ -472,7 +482,9 @@ function AppFrame({
)} @@ -564,8 +575,8 @@ function AppFrame({ searchOpen ? [] : cockpitLaunchpadVisible - ? COCKPIT_FOOTER_SHORTCUTS - : DEFAULT_FOOTER_SHORTCUTS + ? COCKPIT_FOOTER_SHORTCUTS + : DEFAULT_FOOTER_SHORTCUTS } notifications={buildNotifications( daemonStatuses, @@ -590,7 +601,8 @@ function buildDaemonFailureNotifications( .map((status) => ({ id: `daemon-${status.name}-failed`, title: `${status.name.toUpperCase()} daemon failed`, - body: status.stderr[status.stderr.length - 1] ?? AppCopy.daemonFailureBody, + body: + status.stderr[status.stderr.length - 1] ?? AppCopy.daemonFailureBody, unread: true, })); } diff --git a/src/presentation/tui/goals/GoalAuthoringFlow.tsx b/src/presentation/tui/goals/GoalAuthoringFlow.tsx index 969c1566..74d38e91 100644 --- a/src/presentation/tui/goals/GoalAuthoringFlow.tsx +++ b/src/presentation/tui/goals/GoalAuthoringFlow.tsx @@ -1,12 +1,24 @@ import React, { useMemo, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { + BaseColors, + SemanticColors, + TuiGlyphs, +} from "../../shared/DesignTokens.js"; +import { KeyBadge } from "../ui-primitives/KeyBadge.js"; import { Wizard } from "../wizard/Wizard.js"; import type { WizardStepDefinition } from "../wizard/Wizard.js"; import { WizardFieldKind } from "../wizard/WizardConstants.js"; import { AUTHORING_PROGRESS_LABELS, + GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH, + GOAL_AUTHORING_RESULT_PANEL_WIDTH, GoalAuthoringCopy, GoalAuthoringCriterionValue, GoalAuthoringFieldKey, + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, + GoalAuthoringResultInteractionKey, GoalAuthoringStage, type GoalAuthoringStageValue, } from "./GoalAuthoringFlowConstants.js"; @@ -24,6 +36,22 @@ export interface GoalAuthoringValues { readonly worktree: string; } +export type GoalAuthoringSubmissionResult = + | { + readonly status: typeof GoalAuthoringRequestStatus.SUCCESS; + readonly goalId: string; + } + | { + readonly status: typeof GoalAuthoringRequestStatus.FAILURE; + readonly error: string; + }; + +type GoalAuthoringRequestResult = + | { + readonly status: typeof GoalAuthoringRequestStatus.PENDING; + } + | GoalAuthoringSubmissionResult; + const DETAILS_STEPS: readonly WizardStepDefinition[] = [ { title: GoalAuthoringCopy.details.title, @@ -94,10 +122,11 @@ const WORKSPACE_STEPS: readonly WizardStepDefinition[] = [ ] as const; interface GoalAuthoringFlowProps { - readonly onComplete: (values: GoalAuthoringValues) => void | Promise; + readonly onComplete: ( + values: GoalAuthoringValues, + ) => Promise; + readonly onSuccessAcknowledged?: (goalId: string) => void | Promise; readonly onCancel: () => void; - readonly dispatchError?: string | null; - readonly disabled?: boolean; } type ScopeFieldKey = @@ -106,9 +135,8 @@ type ScopeFieldKey = export function GoalAuthoringFlow({ onComplete, + onSuccessAcknowledged = () => {}, onCancel, - dispatchError = null, - disabled = false, }: GoalAuthoringFlowProps): React.ReactElement { const [stage, setStage] = useState( GoalAuthoringStage.DETAILS, @@ -135,6 +163,9 @@ export function GoalAuthoringFlow({ branch: "", worktree: "", }); + const [requestResult, setRequestResult] = + useState(null); + const [acknowledgingSuccess, setAcknowledgingSuccess] = useState(false); const criterionNumber = criteriaEditIndex + 1; const criteriaSteps = useMemo( @@ -206,19 +237,18 @@ export function GoalAuthoringFlow({ setSequencingValues({ previousGoal: values[GoalAuthoringFieldKey.PREVIOUS_GOAL] ?? "", nextGoal: values[GoalAuthoringFieldKey.NEXT_GOAL] ?? "", - prerequisiteGoals: - values[GoalAuthoringFieldKey.PREREQUISITE_GOALS] ?? "", + prerequisiteGoals: values[GoalAuthoringFieldKey.PREREQUISITE_GOALS] ?? "", }); setStage(GoalAuthoringStage.WORKSPACE); }; - const handleWorkspaceConfirm = (values: Record) => { + const handleWorkspaceConfirm = async (values: Record) => { const nextWorkspaceValues = { branch: values[GoalAuthoringFieldKey.BRANCH] ?? "", worktree: values[GoalAuthoringFieldKey.WORKTREE] ?? "", }; setWorkspaceValues(nextWorkspaceValues); - onComplete({ + const authoringValues = { title, objective, successCriteria, @@ -229,7 +259,17 @@ export function GoalAuthoringFlow({ prerequisiteGoals: sequencingValues.prerequisiteGoals, branch: nextWorkspaceValues.branch, worktree: nextWorkspaceValues.worktree, - }); + }; + + setRequestResult({ status: GoalAuthoringRequestStatus.PENDING }); + try { + setRequestResult(await onComplete(authoringValues)); + } catch (caughtError) { + setRequestResult({ + status: GoalAuthoringRequestStatus.FAILURE, + error: normalizeSubmissionError(caughtError), + }); + } }; const handleCriteriaBack = () => { @@ -267,6 +307,49 @@ export function GoalAuthoringFlow({ setStage(GoalAuthoringStage.SCOPE); }; + useInput((_input, key) => { + if ( + requestResult === null || + requestResult.status === GoalAuthoringRequestStatus.PENDING || + acknowledgingSuccess + ) { + return; + } + + if ( + requestResult.status === GoalAuthoringRequestStatus.FAILURE && + key.escape + ) { + onCancel(); + return; + } + + if (!key.return) { + return; + } + + if (requestResult.status === GoalAuthoringRequestStatus.FAILURE) { + setRequestResult(null); + setWizardKey((current) => current + 1); + setStage(GoalAuthoringStage.WORKSPACE); + return; + } + + setAcknowledgingSuccess(true); + void Promise.resolve(onSuccessAcknowledged(requestResult.goalId)).catch( + () => setAcknowledgingSuccess(false), + ); + }); + + if (requestResult !== null) { + return ( + + ); + } + if (stage === GoalAuthoringStage.DETAILS) { return ( ); @@ -300,8 +381,6 @@ export function GoalAuthoringFlow({ ? GoalAuthoringCriterionValue.YES : GoalAuthoringCriterionValue.NO, }} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.CRITERIA]} /> ); @@ -323,8 +402,6 @@ export function GoalAuthoringFlow({ ? GoalAuthoringCriterionValue.YES : GoalAuthoringCriterionValue.NO, }} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.SCOPE]} /> ); @@ -340,8 +417,6 @@ export function GoalAuthoringFlow({ onCancel={onCancel} onBack={handleSequencingBack} initialValues={sequencingValues} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.SEQUENCING]} /> ); @@ -349,20 +424,135 @@ export function GoalAuthoringFlow({ return ( setStage(GoalAuthoringStage.SEQUENCING)} initialValues={workspaceValues} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.WORKSPACE]} /> ); } +function GoalAuthoringResultScreen({ + result, + acknowledgingSuccess, +}: { + readonly result: GoalAuthoringRequestResult; + readonly acknowledgingSuccess: boolean; +}): React.ReactElement { + const isPending = result.status === GoalAuthoringRequestStatus.PENDING; + const isSuccess = result.status === GoalAuthoringRequestStatus.SUCCESS; + const statusColor = isPending + ? SemanticColors.info + : isSuccess + ? SemanticColors.success + : SemanticColors.error; + const statusCopy = isPending + ? GoalAuthoringResultCopy.pending + : isSuccess + ? GoalAuthoringResultCopy.success + : GoalAuthoringResultCopy.failure; + + return ( + + + + {TuiGlyphs.accentBar} {GoalAuthoringResultCopy.title} + + + + + {GoalAuthoringResultCopy.statusLabel}:{" "} + + + {result.status} + + + + {statusCopy} + + + {result.status === GoalAuthoringRequestStatus.SUCCESS && ( + + + {GoalAuthoringResultCopy.goalIdLabel}:{" "} + + + {result.goalId} + + + )} + + {result.status === GoalAuthoringRequestStatus.FAILURE && ( + + + {GoalAuthoringResultCopy.errorLabel}: + + + {truncateResultMessage(result.error)} + + + )} + + {!isPending && ( + + + {!isSuccess && ( + + )} + + )} + + + ); +} + +function normalizeSubmissionError(caughtError: unknown): string { + if (caughtError instanceof Error) { + return caughtError.message; + } + + return String(caughtError); +} + +function truncateResultMessage(message: string): string { + if (message.length <= GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH) { + return message; + } + + return `${message.slice(0, GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH - 3)}...`; +} + function buildCriteriaSteps( criterionNumber: number, ): readonly WizardStepDefinition[] { diff --git a/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts b/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts index 26ef6d6c..c69d9b47 100644 --- a/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts +++ b/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts @@ -91,6 +91,36 @@ export const GoalAuthoringCriterionValue = { NO: "no", } as const; +export const GoalAuthoringRequestStatus = { + PENDING: "pending", + SUCCESS: "success", + FAILURE: "failure", +} as const; + +export type GoalAuthoringRequestStatusValue = + (typeof GoalAuthoringRequestStatus)[keyof typeof GoalAuthoringRequestStatus]; + +export const GoalAuthoringResultInteractionKey = { + ACKNOWLEDGE: "enter", + CANCEL: "esc", +} as const; + +export const GoalAuthoringResultCopy = { + title: "Goal Request", + statusLabel: "Status", + goalIdLabel: "Goal ID", + errorLabel: "Error", + pending: "Submitting goal request...", + success: "Goal created successfully.", + failure: "Goal creation failed.", + acknowledge: "continue", + retry: "retry", + cancel: "cancel", +} as const; + +export const GOAL_AUTHORING_RESULT_PANEL_WIDTH = 88; +export const GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH = 512; + export const AUTHORING_PROGRESS_LABELS: Readonly< Record > = { diff --git a/src/presentation/tui/goals/GoalsScreen.tsx b/src/presentation/tui/goals/GoalsScreen.tsx index 2a9a0de0..0975fe22 100644 --- a/src/presentation/tui/goals/GoalsScreen.tsx +++ b/src/presentation/tui/goals/GoalsScreen.tsx @@ -5,7 +5,10 @@ import { SemanticColors, TuiGlyphs, } from "../../shared/DesignTokens.js"; -import { GoalStatus, type GoalStatusType } from "../../../domain/goals/Constants.js"; +import { + GoalStatus, + type GoalStatusType, +} from "../../../domain/goals/Constants.js"; import type { GoalView } from "../../../application/context/goals/GoalView.js"; import type { AddGoalRequest } from "../../../application/context/goals/add/AddGoalRequest.js"; import type { AddGoalResponse } from "../../../application/context/goals/add/AddGoalResponse.js"; @@ -19,7 +22,11 @@ import type { InvariantView } from "../../../application/context/invariants/Inva import { KeyBadge } from "../ui-primitives/KeyBadge.js"; import { HorizontalRule } from "../ui-primitives/HorizontalRule.js"; import { GoalAuthoringFlow } from "./GoalAuthoringFlow.js"; -import type { GoalAuthoringValues } from "./GoalAuthoringFlow.js"; +import type { + GoalAuthoringSubmissionResult, + GoalAuthoringValues, +} from "./GoalAuthoringFlow.js"; +import { GoalAuthoringRequestStatus } from "./GoalAuthoringFlowConstants.js"; import { AddGoalRequestFactory } from "./AddGoalRequestFactory.js"; import { ActionDispatcher } from "../action-dispatch/ActionDispatcher.js"; import type { RequestController } from "../action-dispatch/RequestController.js"; @@ -135,8 +142,6 @@ export function GoalsScreen({ const [sectionPageFlowIndex, setSectionPageFlowIndex] = useState(0); const [filterIndex, setFilterIndex] = useState(0); const [authoringOpen, setAuthoringOpen] = useState(false); - const [authoringError, setAuthoringError] = useState(null); - const [authoringWorking, setAuthoringWorking] = useState(false); const activeFilter = GOAL_STATUS_FILTERS[filterIndex]; const requestedStatusFilter = useMemo( () => @@ -165,10 +170,13 @@ export function GoalsScreen({ : selectedGoal; const activeContext = contextualGoal !== undefined && contextualGoal.goalId === selectedGoal?.id - ? goalContext.data?.contextualGoalView.context ?? EMPTY_GOAL_CONTEXT + ? (goalContext.data?.contextualGoalView.context ?? EMPTY_GOAL_CONTEXT) : EMPTY_GOAL_CONTEXT; const sections = useMemo( - () => (activeGoal === undefined ? [] : buildGoalSections(activeGoal, activeContext)), + () => + activeGoal === undefined + ? [] + : buildGoalSections(activeGoal, activeContext), [activeContext, activeGoal], ); const sectionPages = useMemo( @@ -229,7 +237,6 @@ export function GoalsScreen({ } if (input === "n" || input === "N" || input === "a" || input === "A") { - setAuthoringError(null); updateAuthoringOpen(true); return; } @@ -269,31 +276,40 @@ export function GoalsScreen({ } }); - const handleAuthoringComplete = async (values: GoalAuthoringValues) => { + const handleAuthoringComplete = async ( + values: GoalAuthoringValues, + ): Promise => { if (addGoalController === undefined) { - setAuthoringError(GoalsScreenCopy.authoringUnavailable); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: GoalsScreenCopy.authoringUnavailable, + }; } - setAuthoringWorking(true); - setAuthoringError(null); const result = await ActionDispatcher.dispatch( addGoalController, AddGoalRequestFactory.create(values), ); - setAuthoringWorking(false); if (!result.ok) { - setAuthoringError(result.error.message); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: result.error.message, + }; } - updateAuthoringOpen(false); + return { + status: GoalAuthoringRequestStatus.SUCCESS, + goalId: result.response.goalId, + }; + }; + + const handleAuthoringAcknowledged = async (_goalId: string) => { await goalsList.refresh(); + updateAuthoringOpen(false); }; const handleAuthoringCancel = () => { - setAuthoringError(null); updateAuthoringOpen(false); }; @@ -301,9 +317,8 @@ export function GoalsScreen({ return ( ); } @@ -320,15 +335,23 @@ export function GoalsScreen({ - {GoalsScreenCopy.showingLabel} + + {GoalsScreenCopy.showingLabel}{" "} + - {selectedGoal ? `${selectedIndex + 1}/${visibleGoals.length}` : "0/0"} + {selectedGoal + ? `${selectedIndex + 1}/${visibleGoals.length}` + : "0/0"} - {GoalsScreenCopy.stateLineLabel} - {formatFilterLabel(filterLabel)} + + {GoalsScreenCopy.stateLineLabel}{" "} + + + {formatFilterLabel(filterLabel)} + @@ -338,7 +361,9 @@ export function GoalsScreen({ {goalsList.loading && goalsList.data === null ? ( - {GoalsScreenCopy.loadingGoals} + + {GoalsScreenCopy.loadingGoals} + ) : goalsList.error !== null ? ( {goalsList.error.message} ) : activeGoal === undefined || activeSectionPage === undefined ? ( @@ -352,12 +377,16 @@ export function GoalsScreen({ totalPages={activeSectionPage.totalPages} /> {goalContext.loading && goalContext.data === null && ( - {GoalsScreenCopy.loadingContext} - + + {GoalsScreenCopy.loadingContext} + )} )} @@ -376,7 +405,7 @@ function GoalHeading({ GOAL: - {truncateText(goal.title, GOAL_BROWSER_TITLE_MAX_LENGTH)} + {truncateText(goal.title, GOAL_BROWSER_TITLE_MAX_LENGTH)} @@ -400,8 +429,8 @@ function SectionHeading({ totalPages > 1 ? `${normalizedTitle} (${pageIndex + 1}/${totalPages}):` : section.title.endsWith(":") - ? section.title - : `${section.title}:`; + ? section.title + : `${section.title}:`; return ( @@ -439,7 +468,8 @@ function GoalSectionRows({ index > 0 ? 1 : 0 - }> + } + > {row.heading !== undefined ? ( {row.heading} @@ -473,7 +503,9 @@ function GoalSectionRows({ ) : ( {row.label} - {row.value} + + {row.value} + )} @@ -536,7 +568,10 @@ function buildGoalSections( listSection( "successCriteria", GoalsScreenCopy.sections.successCriteria, - goal.criteria.map((criterion) => ({ value: criterion, marker: "bullet" })), + goal.criteria.map((criterion) => ({ + value: criterion, + marker: "bullet", + })), ), listSection( "currentProgress", @@ -585,19 +620,12 @@ function buildGoalSections( ]; } -function textSection( - key: string, - title: string, - value?: string, -): GoalSection { +function textSection(key: string, title: string, value?: string): GoalSection { return { key, title, paginated: false, - rows: - value === undefined || value.trim().length === 0 - ? [] - : [{ value }], + rows: value === undefined || value.trim().length === 0 ? [] : [{ value }], }; } diff --git a/tests/presentation/tui/application-shell/App.test.tsx b/tests/presentation/tui/application-shell/App.test.tsx index f4d69584..56b6ab8c 100644 --- a/tests/presentation/tui/application-shell/App.test.tsx +++ b/tests/presentation/tui/application-shell/App.test.tsx @@ -9,6 +9,10 @@ import type { } from "../../../../src/presentation/tui/daemon-subprocesses/ISubprocessManager.js"; import type { AddGoalRequest } from "../../../../src/application/context/goals/add/AddGoalRequest.js"; import type { Settings } from "../../../../src/application/settings/Settings.js"; +import { + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; interface HeaderProps { readonly projectName: string; @@ -34,9 +38,8 @@ jest.unstable_mockModule( }), ); -const { App: ProductionApp } = await import( - "../../../../src/presentation/tui/application-shell/App.js" -); +const { App: ProductionApp } = + await import("../../../../src/presentation/tui/application-shell/App.js"); const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -426,16 +429,18 @@ describe("App", () => { config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ - daemon: "refiner", - status: "working", - category: "activity", - goalId: "goal_single_navigation", - phase: "agent", - elapsedMs: 1_000, - timestampMs: 1_000, - message: "single navigation activity", - }], + events: [ + { + daemon: "refiner", + status: "working", + category: "activity", + goalId: "goal_single_navigation", + phase: "agent", + elapsedMs: 1_000, + timestampMs: 1_000, + message: "single navigation activity", + }, + ], }; const terminate = jest.fn(async (name: DaemonName) => { refinerSnapshot = { ...refinerSnapshot, name, status: "stopped" }; @@ -480,8 +485,10 @@ describe("App", () => { }; await navigateFromGoalsToCockpit(stdin, lastFrame); - await waitForFrame(lastFrame, (frame) => - frame.includes("single navigation activity") && frame.includes("6s"), + await waitForFrame( + lastFrame, + (frame) => + frame.includes("single navigation activity") && frame.includes("6s"), ); expect(manager.spawn).not.toHaveBeenCalled(); @@ -503,21 +510,69 @@ describe("App", () => { it("preserves multiple independently updating daemons across navigation", async () => { const snapshots = new Map([ - ["refiner", { - name: "refiner", status: "running", pid: 5101, - config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ daemon: "refiner", status: "working", category: "activity", goalId: "goal_refiner", timestampMs: 7_000, message: "refiner resumed" }], - }], - ["reviewer", { - name: "reviewer", status: "running", pid: 5102, - config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ daemon: "reviewer", status: "working", category: "activity", goalId: "goal_reviewer", timestampMs: 8_000, message: "reviewer resumed" }], - }], - ["codifier", { - name: "codifier", status: "running", pid: 5103, - config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ daemon: "codifier", status: "working", category: "activity", goalId: "goal_codifier", timestampMs: 9_000, message: "codifier resumed" }], - }], + [ + "refiner", + { + name: "refiner", + status: "running", + pid: 5101, + config: stoppedDaemonSnapshot.config, + stdout: [], + stderr: [], + events: [ + { + daemon: "refiner", + status: "working", + category: "activity", + goalId: "goal_refiner", + timestampMs: 7_000, + message: "refiner resumed", + }, + ], + }, + ], + [ + "reviewer", + { + name: "reviewer", + status: "running", + pid: 5102, + config: stoppedDaemonSnapshot.config, + stdout: [], + stderr: [], + events: [ + { + daemon: "reviewer", + status: "working", + category: "activity", + goalId: "goal_reviewer", + timestampMs: 8_000, + message: "reviewer resumed", + }, + ], + }, + ], + [ + "codifier", + { + name: "codifier", + status: "running", + pid: 5103, + config: stoppedDaemonSnapshot.config, + stdout: [], + stderr: [], + events: [ + { + daemon: "codifier", + status: "working", + category: "activity", + goalId: "goal_codifier", + timestampMs: 9_000, + message: "codifier resumed", + }, + ], + }, + ], ]); const manager: ISubprocessManager = { spawn: jest.fn(async (name) => snapshots.get(name)!), @@ -545,16 +600,19 @@ describe("App", () => { ...snapshot, events: snapshot.events.map((event) => ({ ...event, - elapsedMs: name === "refiner" ? 7_000 : name === "reviewer" ? 8_000 : 9_000, + elapsedMs: + name === "refiner" ? 7_000 : name === "reviewer" ? 8_000 : 9_000, })), }); } await navigateFromGoalsToCockpit(stdin, lastFrame); - const frame = await waitForFrame(lastFrame, (currentFrame) => - currentFrame.includes("refiner resumed") && - currentFrame.includes("reviewer resumed") && - currentFrame.includes("codifier resumed"), + const frame = await waitForFrame( + lastFrame, + (currentFrame) => + currentFrame.includes("refiner resumed") && + currentFrame.includes("reviewer resumed") && + currentFrame.includes("codifier resumed"), ); expect(frame).toContain("7s"); @@ -836,9 +894,11 @@ describe("App", () => { await waitForFrame(lastFrame, (frame) => frame.includes("Author Goal")); stdin.write("\x1b"); - await waitForFrame(lastFrame, (frame) => - frame.includes("Ready to create your first goal.") && - !frame.includes("Author Goal"), + await waitForFrame( + lastFrame, + (frame) => + frame.includes("Ready to create your first goal.") && + !frame.includes("Author Goal"), ); expect(lastFrame()).not.toContain("Author Goal"); @@ -931,10 +991,17 @@ describe("App", () => { stdin.write("\r"); await tick(); stdin.write("\r"); - await waitForFrame(lastFrame, (frame) => - !frame.includes("Ready to create your first goal."), + const resultFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("goal_created"), ); + expect(resultFrame).toContain(GoalAuthoringRequestStatus.SUCCESS); + expect(resultFrame).toContain(GoalAuthoringResultCopy.goalIdLabel); + stdin.write("m"); + await tick(); + expect(lastFrame()).toContain("goal_created"); + expect(lastFrame()).not.toContain("Navigate"); + expect(addGoalRequests).toEqual([ { title: "Prototype Cockpit goal authoring", @@ -949,7 +1016,10 @@ describe("App", () => { worktree: undefined, }, ]); - expect(lastFrame()).not.toContain("Author Goal"); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("EVENTS//")); + + expect(lastFrame()).not.toContain(GoalAuthoringResultCopy.title); expect(lastFrame()).not.toContain("to add a goal"); unmount(); }, 10000); @@ -1019,6 +1089,13 @@ describe("App", () => { () => addGoalController.handle.mock.calls.length > 0, ); + const resultFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("goal_created"), + ); + expect(resultFrame).toContain(GoalAuthoringRequestStatus.SUCCESS); + expect(resultFrame).not.toContain("EVENTS//"); + + stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("EVENTS//")); expect(lastFrame()).not.toContain("to add a goal"); @@ -1109,5 +1186,4 @@ describe("App", () => { expect(lastFrame()).toContain("Navigate"); unmount(); }, 10000); - }); diff --git a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx index 29835d08..1ef472a3 100644 --- a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx +++ b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx @@ -3,10 +3,24 @@ import { describe, expect, it, jest } from "@jest/globals"; import { render } from "ink-testing-library"; import stripAnsi from "strip-ansi"; import { GoalAuthoringFlow } from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; +import type { + GoalAuthoringSubmissionResult, + GoalAuthoringValues, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; +import { + GOAL_AUTHORING_RESULT_PANEL_WIDTH, + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; import { WizardValidationCopy } from "../../../../src/presentation/tui/wizard/WizardConstants.js"; const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); const LEFT_ARROW = "\x1B[D"; +const SUCCESSFUL_SUBMISSION: GoalAuthoringSubmissionResult = { + status: GoalAuthoringRequestStatus.SUCCESS, + goalId: "goal_created", +}; +const completeSuccessfully = async () => SUCCESSFUL_SUBMISSION; const waitForFrame = async ( lastFrame: () => string | undefined, predicate: (frame: string) => boolean, @@ -24,7 +38,10 @@ const waitForFrame = async ( describe("GoalAuthoringFlow", () => { it("renders the objective step using the wizard primitive", () => { const { lastFrame, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); const frame = lastFrame() ?? ""; @@ -40,7 +57,10 @@ describe("GoalAuthoringFlow", () => { const objective = "Allow full fidelity view of a Decision by extending the commands with 'jumbo decision show --id'. Today only summaries are visible via 'jumbo decisions list'"; const { lastFrame, stdin, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); stdin.write("Demonstration"); @@ -49,8 +69,10 @@ describe("GoalAuthoringFlow", () => { await tick(); stdin.write(objective); const frame = stripAnsi( - await waitForFrame(lastFrame, (renderedFrame) => - renderedFrame.includes("list'") && renderedFrame.includes("▎"), + await waitForFrame( + lastFrame, + (renderedFrame) => + renderedFrame.includes("list'") && renderedFrame.includes("▎"), ), ); @@ -68,7 +90,10 @@ describe("GoalAuthoringFlow", () => { it("includes all goal authoring steps", async () => { const { lastFrame, stdin, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); stdin.write("Prototype S2"); @@ -117,7 +142,7 @@ describe("GoalAuthoringFlow", () => { }); it("collects criteria and multiple scope items as arrays", async () => { - const handleComplete = jest.fn(); + const handleComplete = jest.fn(async () => SUCCESSFUL_SUBMISSION); const { stdin, lastFrame, unmount } = render( {}} />, ); @@ -227,7 +252,7 @@ describe("GoalAuthoringFlow", () => { }); it("allows scope boundaries to be left blank", async () => { - const handleComplete = jest.fn(); + const handleComplete = jest.fn(async () => SUCCESSFUL_SUBMISSION); const { stdin, lastFrame, unmount } = render( {}} />, ); @@ -286,7 +311,10 @@ describe("GoalAuthoringFlow", () => { it("preserves scope items when navigating backward and forward", async () => { const { stdin, lastFrame, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); stdin.write("Prototype S2"); @@ -346,11 +374,184 @@ describe("GoalAuthoringFlow", () => { expect(lastFrame()).toContain("Renders goals"); stdin.write(LEFT_ARROW); - await waitForFrame(lastFrame, (frame) => - frame.includes("Title") && frame.includes("Objective"), + await waitForFrame( + lastFrame, + (frame) => frame.includes("Title") && frame.includes("Objective"), ); expect(lastFrame()).toContain("Prototype S2"); expect(lastFrame()).toContain("Prototype the Goals screen"); unmount(); }); + + it("replaces the wizard with pending and keeps success visible until acknowledged", async () => { + let resolveSubmission: + | ((result: GoalAuthoringSubmissionResult) => void) + | undefined; + const submission = new Promise((resolve) => { + resolveSubmission = resolve; + }); + const onComplete = jest.fn( + async (_values: GoalAuthoringValues) => submission, + ); + const onSuccessAcknowledged = jest.fn(); + const { stdin, lastFrame, unmount } = render( + {}} + />, + ); + + await submitMinimalAuthoringFlow(stdin, lastFrame); + + const pendingFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.PENDING), + ); + expect(pendingFrame).toContain(GoalAuthoringResultCopy.pending); + expect(pendingFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); + expect(pendingFrame).not.toContain("Branch (optional)"); + + resolveSubmission?.(SUCCESSFUL_SUBMISSION); + const successFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.SUCCESS), + ); + expect(successFrame).toContain(GoalAuthoringResultCopy.success); + expect(successFrame).toContain(SUCCESSFUL_SUBMISSION.goalId); + + await tick(); + expect(lastFrame()).toContain(SUCCESSFUL_SUBMISSION.goalId); + expect(onSuccessAcknowledged).not.toHaveBeenCalled(); + + stdin.write("\r"); + await waitForFrame( + lastFrame, + () => onSuccessAcknowledged.mock.calls.length === 1, + ); + expect(onSuccessAcknowledged).toHaveBeenCalledWith( + SUCCESSFUL_SUBMISSION.goalId, + ); + unmount(); + }); + + it("shows failure without a goal ID and retries with every value preserved", async () => { + const onComplete = jest + .fn< + (values: GoalAuthoringValues) => Promise + >() + .mockResolvedValueOnce({ + status: GoalAuthoringRequestStatus.FAILURE, + error: "normalized dispatch failure", + }) + .mockResolvedValueOnce(SUCCESSFUL_SUBMISSION); + const { stdin, lastFrame, unmount } = render( + {}} />, + ); + + await submitMinimalAuthoringFlow(stdin, lastFrame, { + branch: "feature/preserved", + worktree: "../preserved-worktree", + }); + const failureFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.FAILURE), + ); + expect(failureFrame).toContain("normalized dispatch failure"); + expect(failureFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); + + stdin.write("\r"); + const retryFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("Branch (optional)"), + ); + expect(retryFrame).toContain("feature/preserved"); + expect(retryFrame).toContain("../preserved-worktree"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, () => onComplete.mock.calls.length === 2); + + expect(onComplete.mock.calls[1]?.[0]).toEqual( + onComplete.mock.calls[0]?.[0], + ); + expect(lastFrame()).toContain(SUCCESSFUL_SUBMISSION.goalId); + unmount(); + }); + + it("cancels from failure and bounds long result content", async () => { + const onCancel = jest.fn(); + const longError = "dispatch failure ".repeat(100); + const { stdin, lastFrame, unmount } = render( + ({ + status: GoalAuthoringRequestStatus.FAILURE, + error: longError, + })} + onCancel={onCancel} + />, + ); + + await submitMinimalAuthoringFlow(stdin, lastFrame); + const failureFrame = stripAnsi( + await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.FAILURE), + ), + ); + + expect(failureFrame).not.toContain(longError); + const longestRenderedLine = Math.max( + ...failureFrame.split("\n").map((line) => line.trimStart().length), + ); + expect(longestRenderedLine).toBeLessThanOrEqual( + GOAL_AUTHORING_RESULT_PANEL_WIDTH, + ); + + stdin.write("\x1b"); + await waitForFrame(lastFrame, () => onCancel.mock.calls.length === 1); + expect(onCancel).toHaveBeenCalledTimes(1); + unmount(); + }); }); + +async function submitMinimalAuthoringFlow( + stdin: ReturnType["stdin"], + lastFrame: () => string | undefined, + workspace: { + readonly branch?: string; + readonly worktree?: string; + } = {}, +): Promise { + stdin.write("Goal title"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("Goal objective"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Success criterion")); + stdin.write("Goal criterion"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Branch")); + stdin.write(workspace.branch ?? ""); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write(workspace.worktree ?? ""); + await tick(); + stdin.write("\r"); +} diff --git a/tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts b/tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts new file mode 100644 index 00000000..1cf2be79 --- /dev/null +++ b/tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@jest/globals"; +import { + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, + GoalAuthoringResultInteractionKey, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; + +describe("GoalAuthoringFlowConstants", () => { + it("keeps request statuses stable", () => { + expect(GoalAuthoringRequestStatus).toEqual({ + PENDING: "pending", + SUCCESS: "success", + FAILURE: "failure", + }); + }); + + it("keeps result interaction keys and their copy owner-local", () => { + expect(GoalAuthoringResultInteractionKey).toEqual({ + ACKNOWLEDGE: "enter", + CANCEL: "esc", + }); + expect(GoalAuthoringResultCopy.acknowledge).toBeTruthy(); + expect(GoalAuthoringResultCopy.retry).toBeTruthy(); + expect(GoalAuthoringResultCopy.cancel).toBeTruthy(); + }); +}); diff --git a/tests/presentation/tui/goals/GoalsScreen.test.tsx b/tests/presentation/tui/goals/GoalsScreen.test.tsx index 35420d01..a0835535 100644 --- a/tests/presentation/tui/goals/GoalsScreen.test.tsx +++ b/tests/presentation/tui/goals/GoalsScreen.test.tsx @@ -3,6 +3,10 @@ import { describe, expect, it, jest } from "@jest/globals"; import { render } from "ink-testing-library"; import stripAnsi from "strip-ansi"; import { GoalsScreen } from "../../../../src/presentation/tui/goals/GoalsScreen.js"; +import { + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; import { StateReaderProvider } from "../../../../src/presentation/tui/state-reading/StateReader.js"; import { GoalStatus } from "../../../../src/domain/goals/Constants.js"; import { @@ -148,7 +152,10 @@ function renderGoalsScreen( readonly handledRequests?: GetGoalsRequest[]; readonly terminalWidth?: number; readonly shortcutsEnabled?: boolean; - readonly addGoalController?: RequestController; + readonly addGoalController?: RequestController< + AddGoalRequest, + AddGoalResponse + >; readonly onModalOpenChange?: (isOpen: boolean) => void; } = {}, ): ReturnType { @@ -336,7 +343,9 @@ describe("GoalsScreen", () => { it("renders every available goal-show content section", async () => { const { lastFrame, stdin, unmount } = renderGoalsScreen({ - contexts: new Map([["goal_real", createGoalContext({ componentCount: 1 })]]), + contexts: new Map([ + ["goal_real", createGoalContext({ componentCount: 1 })], + ]), }); const expectedSections = [ "OBJECTIVE:", @@ -376,7 +385,9 @@ describe("GoalsScreen", () => { expect(frame).toContain("Claimed by"); expect(frame).toContain("worker-1"); expect(frame).not.toContain("Objective."); - expect(frame.indexOf("OBJECTIVE:")).toBeLessThan(frame.indexOf("META-DATA:")); + expect(frame.indexOf("OBJECTIVE:")).toBeLessThan( + frame.indexOf("META-DATA:"), + ); expect(frame).not.toContain("WORKSPACE:"); expect(frame).not.toContain("CLAIM:"); unmount(); @@ -398,7 +409,9 @@ describe("GoalsScreen", () => { it("paginates related entity rows at six items before rendering overflow", async () => { const { lastFrame, stdin, unmount } = renderGoalsScreen({ - contexts: new Map([["goal_real", createGoalContext({ componentCount: 7 })]]), + contexts: new Map([ + ["goal_real", createGoalContext({ componentCount: 7 })], + ]), }); await waitForFrame(lastFrame, "META-DATA"); @@ -508,7 +521,11 @@ describe("GoalsScreen", () => { }); await waitForFrame(lastFrame, "META-DATA"); - const frame = await navigateRightUntil(stdin, lastFrame, "SUCCESS CRITERIA:"); + const frame = await navigateRightUntil( + stdin, + lastFrame, + "SUCCESS CRITERIA:", + ); expect(frame).not.toContain("SUCCESS CRITERIA (1/2):"); expect(frame).not.toContain("(1/2)"); unmount(); @@ -680,7 +697,7 @@ describe("GoalsScreen", () => { unmount(); }); - it("closes the flow and renders the refreshed list with the created goal on success", async () => { + it("keeps success visible until acknowledgement, then refreshes and closes", async () => { const onModalOpenChange = jest.fn(); const goals: GoalView[] = []; const { lastFrame, stdin, unmount } = renderGoalsScreen({ @@ -707,6 +724,17 @@ describe("GoalsScreen", () => { criterion: "Goal is persisted", }); + const resultFrame = await waitForFrame(lastFrame, "goal_created"); + expect(resultFrame).toContain(GoalAuthoringRequestStatus.SUCCESS); + expect(resultFrame).toContain(GoalAuthoringResultCopy.goalIdLabel); + expect(resultFrame).not.toContain("Created goal"); + expect(onModalOpenChange).not.toHaveBeenLastCalledWith(false); + + stdin.write(SPACE); + await settleInput(); + expect(lastFrame()).toContain("goal_created"); + + stdin.write("\r"); const frame = await waitForFrame(lastFrame, "Created goal"); expect(frame).toContain("Created goal"); expect(frame).toContain("1/1"); @@ -732,8 +760,13 @@ describe("GoalsScreen", () => { }); await waitUntil(() => dispatched.mock.calls.length > 0); onModalOpenChange.mockClear(); - await settleInput(); + const failureFrame = await waitForFrame( + lastFrame, + GoalAuthoringRequestStatus.FAILURE, + ); + expect(failureFrame).toContain("dispatch failed"); + expect(failureFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); expect(onModalOpenChange).not.toHaveBeenCalledWith(false); stdin.write("\x1b"); @@ -755,8 +788,12 @@ describe("GoalsScreen", () => { criterion: "Goal is persisted", }); onModalOpenChange.mockClear(); - await settleInput(); + const failureFrame = await waitForFrame( + lastFrame, + GoalAuthoringRequestStatus.FAILURE, + ); + expect(failureFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); expect(onModalOpenChange).not.toHaveBeenCalledWith(false); stdin.write("\x1b");