Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,14 @@ export function App({
}
wasWorkPrimary.current = workPrimary;
}, [workPrimary]);
// Drop the /goal one-shot once Goal chrome is live so it does not stack on
// the brief / Work checklist (and blow the reserved chrome rows).
useEffect(() => {
if (!goalActive || commandMessage === null) return;
if (commandMessage.startsWith("Goal set.")) {
setCommandMessage(null);
}
}, [goalActive, commandMessage]);
const workExpanded = tasksExpanded;
const goalChromeRows = goalChromeRowCount({
goalActive,
Expand All @@ -544,7 +552,8 @@ export function App({

const extraChromeRows = extraChromeRowCount({
mcpNeedsAuthCount: mcpStatus.needsAuth.length,
commandMessagePresent: commandMessage !== null,
commandMessageRows:
commandMessage === null ? 0 : Math.max(1, commandMessage.split("\n").length),
goalChromeRows,
taskChromeRows,
pluginChromeRows,
Expand Down Expand Up @@ -1203,8 +1212,12 @@ export function App({
/>
{mcpStatus.needsAuth.length > 0 && <McpAuthPrompt servers={mcpStatus.needsAuth} />}
{commandMessage !== null && (
<Box paddingX={1}>
<Text color="cyan">{commandMessage}</Text>
<Box paddingX={1} width="100%" overflow="hidden" flexDirection="column">
{commandMessage.split("\n").map((line, i) => (
<Text key={i} color="cyan" wrap="truncate-end">
{line}
</Text>
))}
</Box>
)}
{!taskFullScreenOpen && (
Expand Down
5 changes: 3 additions & 2 deletions src/tui/chrome-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export function pluginChromeRowCount(args: {

export function extraChromeRowCount(args: {
mcpNeedsAuthCount: number;
commandMessagePresent: boolean;
/** Rows reserved for the command feedback banner (0 when absent). */
commandMessageRows: number;
goalChromeRows: number;
taskChromeRows: number;
pluginChromeRows: number;
Expand All @@ -85,7 +86,7 @@ export function extraChromeRowCount(args: {
}): number {
return (
(args.mcpNeedsAuthCount > 0 ? 1 : 0) +
(args.commandMessagePresent ? 1 : 0) +
args.commandMessageRows +
args.goalChromeRows +
args.taskChromeRows +
args.pluginChromeRows +
Expand Down
6 changes: 4 additions & 2 deletions src/tui/commands/built-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,11 @@ registerCommand({
`Clear it first (/goal clear) or replace with /goal --replace <brief>.`,
};
}
const snap = api.set(condition, parsed.opts);
api.set(condition, parsed.opts);
api.kickoff?.(condition, "set");
return { type: "message", text: `Goal set.\nBrief: ${snap.brief}` };
// One-shot banner only — brief lives in GoalView chrome (multi-line here
// used to overflow chrome row accounting and collide with Work).
return { type: "message", text: "Goal set." };
},
});

Expand Down
9 changes: 5 additions & 4 deletions src/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ describe("/goal command", () => {
const result = cmd!.handler("ship the feature", ctx);
expect(result.type).toBe("message");
if (result.type === "message") {
expect(result.text).toContain("Goal set");
expect(result.text).toContain("ship the feature");
expect(result.text).toBe("Goal set.");
// Brief is shown in GoalView chrome, not the one-shot banner.
expect(result.text).not.toContain("ship the feature");
expect(result.text).not.toContain("The agent will expand");
expect(result.text).not.toContain("manage_goal");
}
Expand Down Expand Up @@ -186,8 +187,8 @@ describe("/goal command", () => {
const ok = getCommand("goal")!.handler("--replace new goal", ctx);
expect(ok.type).toBe("message");
if (ok.type === "message") {
expect(ok.text).toContain("Goal set");
expect(ok.text).toContain("new goal");
expect(ok.text).toBe("Goal set.");
expect(ok.text).not.toContain("new goal");
}
});
});
238 changes: 150 additions & 88 deletions src/tui/components/goal-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type GoalStatus,
} from "../../agent/goal.js";
import { color } from "../theme.js";
import { useTerminalSize } from "../hooks/use-terminal-size.js";

export type GoalViewProps = {
goal: GoalSnapshot;
Expand Down Expand Up @@ -39,67 +40,69 @@ const PHASE_ORDER: readonly GoalPhase[] = [
"completed",
];

/** Full trail `plan→impl→review→done` needs ~23 cols; below this show current only. */
const PHASE_TRAIL_MIN_COLS = 48;

/**
* Expanded acceptance checklist — primary goal surface.
* Quiet styling (muted labels, no bright accent wash).
* On achieve: freezes on "Goal completed in …" and stops looking like work-in-progress.
* Width-constrained so long briefs/criteria truncate instead of colliding with Work/footer.
*/
export function GoalView({ goal, compact }: GoalViewProps) {
// Hooks must run unconditionally — mount can flip inactive without unmount.
const { columns } = useTerminalSize();

if (goal.status === "inactive" || goal.status === "cleared") return null;

const phase = goal.phase;
const progress = goalCriteriaProgress(goal.criteria);
const brief = goal.brief || goal.condition;
const quiet = isQuietStatus(goal.status);
const completed = formatGoalCompleted(goal);
const narrow = columns < PHASE_TRAIL_MIN_COLS;

if (completed !== null) {
return (
<Box flexDirection="column" paddingX={1}>
<Box gap={1}>
<Text bold color={color("success")}>
Goal
</Text>
<Text color={color("success")}>{completed}</Text>
{progress.total > 0 && (
<Text color={color("dim")} dimColor>
{`${progress.done}/${progress.total}`}
<Box flexDirection="column" width="100%" paddingX={1} overflow="hidden">
<Box width="100%" gap={1} overflow="hidden">
<Box flexShrink={0}>
<Text bold color={color("success")}>
Goal
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
<Text color={color("success")} wrap="truncate-end">
{completed}
</Text>
</Box>
{progress.total > 0 && (
<Box flexShrink={0}>
<Text color={color("dim")} dimColor>
{`${progress.done}/${progress.total}`}
</Text>
</Box>
)}
</Box>
<Text wrap="truncate-end" color={color("dim")} dimColor>
{brief}
</Text>
<BriefLine brief={brief} dim />
{goal.criteria.length > 0 &&
sortedCriteria(goal.criteria).map((c) => (
<Box key={c.id} gap={1}>
<Text color={criterionColor(c.status)}>{GLYPH[c.status]}</Text>
<Text color={color("dim")} strikethrough={c.status === "done" || c.status === "cancelled"} wrap="truncate-end">
{c.title}
</Text>
</Box>
))}
sortedCriteria(goal.criteria).map((c) => <CriterionRow key={c.id} criterion={c} />)}
</Box>
);
}

if (compact || goal.criteria.length === 0) {
return (
<Box flexDirection="column" paddingX={1}>
<Box gap={1}>
<Text bold color={color("muted")}>
Goal
</Text>
<PhaseTrail phase={phase} />
{!quiet && (
<Text color={statusColor(goal.status)} dimColor={quiet}>
{goal.status}
</Text>
)}
</Box>
<Text wrap="truncate-end" dimColor={quiet}>
{brief}
</Text>
<Box flexDirection="column" width="100%" paddingX={1} overflow="hidden">
<HeaderRow
label="Goal"
phase={phase}
narrow={narrow}
progress={null}
status={!quiet ? goal.status : null}
quiet={quiet}
/>
<BriefLine brief={brief} dim={quiet} />
{goal.criteria.length === 0 && phase === "planning" && (
<Text color={color("dim")} dimColor>
planning acceptance…
Expand All @@ -110,77 +113,136 @@ export function GoalView({ goal, compact }: GoalViewProps) {
}

return (
<Box flexDirection="column" paddingX={1}>
<Box gap={1}>
<Box flexDirection="column" width="100%" paddingX={1} overflow="hidden">
<HeaderRow
label="Acceptance"
phase={phase}
narrow={narrow}
progress={progress.total > 0 ? `${progress.done}/${progress.total}` : null}
status={!quiet ? goal.status : null}
quiet={quiet}
/>
<BriefLine brief={brief} dim />
{sortedCriteria(goal.criteria).map((c) => (
<CriterionRow key={c.id} criterion={c} />
))}
{goal.lastReason !== undefined && goal.lastReason.length > 0 && (
<Box width="100%" overflow="hidden">
<Text color={color("dim")} dimColor wrap="truncate-end">
{goal.lastReason}
</Text>
</Box>
)}
</Box>
);
}

function HeaderRow(props: {
label: string;
phase: GoalPhase;
narrow: boolean;
progress: string | null;
status: GoalStatus | null;
quiet: boolean;
}) {
const { label, phase, narrow, progress, status, quiet } = props;
return (
<Box width="100%" gap={1} overflow="hidden">
<Box flexShrink={0}>
<Text bold color={color("muted")}>
Acceptance
{label}
</Text>
<PhaseTrail phase={phase} />
<Text color={color("dim")} dimColor>
{`${progress.done}/${progress.total}`}
</Text>
{!quiet && (
<Text color={statusColor(goal.status)} dimColor={quiet}>
{goal.status}
</Text>
)}
</Box>
<Text wrap="truncate-end" color={color("dim")} dimColor>
{brief}
</Text>
{sortedCriteria(goal.criteria).map((c) => (
<Box key={c.id} gap={1}>
<Text color={criterionColor(c.status)}>{GLYPH[c.status]}</Text>
<Text
{...(c.status === "done" || c.status === "cancelled"
? { color: color("dim"), strikethrough: true }
: {})}
bold={c.status === "doing"}
wrap="truncate-end"
>
{c.title}
<Box flexShrink={0}>
<PhaseTrail phase={phase} narrow={narrow} />
</Box>
{progress !== null && (
<Box flexShrink={0}>
<Text color={color("dim")} dimColor>
{progress}
</Text>
{c.note !== undefined && c.note.length > 0 && (
<Text color={color("dim")} dimColor wrap="truncate-end">
{c.note}
</Text>
)}
</Box>
))}
{goal.lastReason !== undefined && goal.lastReason.length > 0 && (
<Text color={color("dim")} dimColor wrap="truncate-end">
{goal.lastReason}
)}
{status !== null && (
<Box flexShrink={1} minWidth={0} overflow="hidden">
<Text color={statusColor(status)} dimColor={quiet} wrap="truncate-end">
{status}
</Text>
</Box>
)}
</Box>
);
}

function BriefLine({ brief, dim }: { brief: string; dim?: boolean }) {
return (
<Box width="100%" overflow="hidden">
{dim ? (
<Text wrap="truncate-end" color={color("dim")} dimColor>
{brief}
</Text>
) : (
<Text wrap="truncate-end">{brief}</Text>
)}
</Box>
);
}

function CriterionRow({ criterion: c }: { criterion: GoalCriterion }) {
const terminal = c.status === "done" || c.status === "cancelled";
return (
<Box width="100%" gap={1} overflow="hidden">
<Box flexShrink={0}>
<Text color={criterionColor(c.status)}>{GLYPH[c.status]}</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
<Text
{...(terminal ? { color: color("dim"), strikethrough: true } : {})}
bold={c.status === "doing"}
wrap="truncate-end"
>
{c.title}
</Text>
</Box>
{c.note !== undefined && c.note.length > 0 && (
<Box flexShrink={1} minWidth={0} overflow="hidden">
<Text color={color("dim")} dimColor wrap="truncate-end">
{c.note}
</Text>
</Box>
)}
</Box>
);
}

/** plan → impl → review → done with current phase emphasized. */
function PhaseTrail({ phase }: { phase: GoalPhase }) {
/** plan → impl → review → done; on narrow terminals show only the current phase. */
function PhaseTrail({ phase, narrow }: { phase: GoalPhase; narrow: boolean }) {
if (narrow) {
return (
<Text bold color={color("text")}>
{PHASE_SHORT[phase]}
</Text>
);
}
const idx = PHASE_ORDER.indexOf(phase);
return (
<Box gap={0}>
<Text>
{PHASE_ORDER.map((p, i) => {
const current = p === phase;
const sep = i > 0 ? "→" : "";
return (
<Box key={p} gap={0}>
{i > 0 && (
<Text color={color("dim")} dimColor>
</Text>
)}
<Text
bold={current}
color={current ? color("text") : color("dim")}
dimColor={!current || i < idx}
>
{PHASE_SHORT[p]}
</Text>
</Box>
<Text
key={p}
bold={current}
color={current ? color("text") : color("dim")}
dimColor={!current || i < idx}
>
{sep}
{PHASE_SHORT[p]}
</Text>
);
})}
</Box>
</Text>
);
}

Expand Down
Loading
Loading