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
26 changes: 13 additions & 13 deletions docs/context-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@ Commands for managing conversation history length and token usage.

## Comparison

| Approach | `/clear` | `/truncate` | `/compact` | Plan Compaction |
| ------------------------ | -------- | ----------- | ---------------- | --------------- |
| **Speed** | Instant | Instant | Slower (uses AI) | Instant |
| **Context Preservation** | None | Temporal | Intelligent | Intelligent |
| **Cost** | Free | Free | Uses API tokens | Free |
| **Reversible** | No | No | No | Yes |
| Approach | `/clear` | `/truncate` | `/compact` | Start Here |
| ------------------------ | -------- | ----------- | ---------------- | ----------- |
| **Speed** | Instant | Instant | Slower (uses AI) | Instant |
| **Context Preservation** | None | Temporal | Intelligent | Intelligent |
| **Cost** | Free | Free | Uses API tokens | Free |
| **Reversible** | No | No | No | Yes |

## Plan Compaction
## Start Here

If you've produced a plan, you can opportunistically click "Compact Here" on the plan to use it
as the entire conversation history. This operation is instant as all of the LLM's work was already
done when it created the plan.
Start Here allows you to restart your conversation from a specific point, using that message as the entire conversation history. This is available on:

![Plan Compaction](./img/plan-compact.webp)
- **Plans** - Click "🎯 Start Here" on any plan to use it as your conversation starting point
- **Final Assistant messages** - Click "🎯 Start Here" on any completed assistant response

This is a form of "opportunistic compaction" and is special in that you can review the post-compact
context before the old context is permanently removed.
![Start Here](./img/plan-compact.webp)

This is a form of "opportunistic compaction" - the content is already well-structured, so the operation is instant. You can review the new starting point before the old context is permanently removed, making this the only reversible context management approach (use Cmd+Z/Ctrl+Z to undo).

## `/clear` - Clear All History

Expand Down
55 changes: 44 additions & 11 deletions src/components/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { MarkdownRenderer } from "./MarkdownRenderer";
import { TypewriterMarkdown } from "./TypewriterMarkdown";
import type { ButtonConfig } from "./MessageWindow";
import { MessageWindow } from "./MessageWindow";
import { useStartHere } from "@/hooks/useStartHere";
import { COMPACTED_EMOJI } from "@/constants/ui";

const RawContent = styled.pre`
font-family: var(--font-monospace);
Expand Down Expand Up @@ -52,14 +54,29 @@ const CompactedBadge = styled.span`
interface AssistantMessageProps {
message: DisplayedMessage & { type: "assistant" };
className?: string;
workspaceId?: string;
}

export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, className }) => {
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
message,
className,
workspaceId,
}) => {
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);

const content = message.content;
const isStreaming = message.isStreaming;
const isCompacted = message.isCompacted;

// Use Start Here hook for final assistant messages
const {
openModal,
buttonLabel,
buttonEmoji,
disabled: startHereDisabled,
modal,
} = useStartHere(workspaceId, content, isCompacted);

const handleCopy = async () => {
try {
Expand All @@ -75,6 +92,18 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, cla
const buttons: ButtonConfig[] = isStreaming
? []
: [
// Add Start Here button if workspaceId is available and message is not already compacted
...(workspaceId && !isCompacted
? [
{
label: buttonLabel,
emoji: buttonEmoji,
onClick: openModal,
disabled: startHereDisabled,
tooltip: "Replace all chat history with this message",
},
]
: []),
{
label: copied ? "✓ Copied" : "Copy Text",
onClick: () => void handleCopy(),
Expand Down Expand Up @@ -117,20 +146,24 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, cla
<LabelContainer>
<span>ASSISTANT</span>
{modelName && <ModelName>{modelName.toLowerCase()}</ModelName>}
{isCompacted && <CompactedBadge>📦 compacted</CompactedBadge>}
{isCompacted && <CompactedBadge>{COMPACTED_EMOJI} compacted</CompactedBadge>}
</LabelContainer>
);
};

return (
<MessageWindow
label={renderLabel()}
borderColor="var(--color-assistant-border)"
message={message}
buttons={buttons}
className={className}
>
{renderContent()}
</MessageWindow>
<>
<MessageWindow
label={renderLabel()}
borderColor="var(--color-assistant-border)"
message={message}
buttons={buttons}
className={className}
>
{renderContent()}
</MessageWindow>

{modal}
</>
);
};
4 changes: 3 additions & 1 deletion src/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
case "user":
return <UserMessage message={message} className={className} onEdit={onEditUserMessage} />;
case "assistant":
return <AssistantMessage message={message} className={className} />;
return (
<AssistantMessage message={message} className={className} workspaceId={workspaceId} />
);
case "tool":
return <ToolMessage message={message} className={className} workspaceId={workspaceId} />;
case "reasoning":
Expand Down
56 changes: 51 additions & 5 deletions src/components/Messages/MessageWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import styled from "@emotion/styled";
import type { CmuxMessage, DisplayedMessage } from "@/types/message";
import { HeaderButton } from "../tools/shared/ToolPrimitives";
import { formatTimestamp } from "@/utils/ui/dateTime";
import { TooltipWrapper, Tooltip } from "../Tooltip";

const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string }>`
margin-bottom: 15px;
Expand Down Expand Up @@ -69,6 +70,9 @@ export interface ButtonConfig {
label: string;
onClick: () => void;
active?: boolean;
disabled?: boolean;
emoji?: string; // Optional emoji that shows only on hover
tooltip?: string; // Optional tooltip text
}

interface MessageWindowProps {
Expand Down Expand Up @@ -113,11 +117,25 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
</LeftSection>
<ButtonGroup>
{rightLabel}
{buttons.map((button, index) => (
<HeaderButton key={index} active={button.active} onClick={button.onClick}>
{button.label}
</HeaderButton>
))}
{buttons.map((button, index) =>
button.tooltip ? (
<TooltipWrapper key={index} inline>
<ButtonWithHoverEmoji
button={button}
active={button.active}
disabled={button.disabled}
/>
<Tooltip align="center">{button.tooltip}</Tooltip>
</TooltipWrapper>
) : (
<ButtonWithHoverEmoji
key={index}
button={button}
active={button.active}
disabled={button.disabled}
/>
)
)}
<HeaderButton active={showJson} onClick={() => setShowJson(!showJson)}>
{showJson ? "Hide JSON" : "Show JSON"}
</HeaderButton>
Expand All @@ -129,3 +147,31 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
</MessageBlock>
);
};

// Button component that shows emoji only on hover
interface ButtonWithHoverEmojiProps {
button: ButtonConfig;
active?: boolean;
disabled?: boolean;
}

const ButtonWithHoverEmoji: React.FC<ButtonWithHoverEmojiProps> = ({
button,
active,
disabled,
}) => {
const [isHovered, setIsHovered] = useState(false);

return (
<HeaderButton
active={active}
onClick={button.onClick}
disabled={disabled}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{button.emoji && isHovered && <span style={{ marginRight: "4px" }}>{button.emoji}</span>}
{button.label}
</HeaderButton>
);
};
54 changes: 54 additions & 0 deletions src/components/StartHereModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useState, useCallback } from "react";
import styled from "@emotion/styled";
import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal";

const CenteredActions = styled(ModalActions)`
justify-content: center;
`;

interface StartHereModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}

export const StartHereModal: React.FC<StartHereModalProps> = ({ isOpen, onClose, onConfirm }) => {
const [isExecuting, setIsExecuting] = useState(false);

const handleCancel = useCallback(() => {
if (!isExecuting) {
onClose();
}
}, [isExecuting, onClose]);

const handleConfirm = useCallback(async () => {
if (isExecuting) return;
setIsExecuting(true);
try {
await onConfirm();
onClose();
} catch (error) {
console.error("Start Here error:", error);
setIsExecuting(false);
}
}, [isExecuting, onConfirm, onClose]);

return (
<Modal
isOpen={isOpen}
title="Start Here"
subtitle="This will replace all chat history with this message"
onClose={handleCancel}
isLoading={isExecuting}
>
<CenteredActions>
<CancelButton onClick={handleCancel} disabled={isExecuting}>
Cancel
</CancelButton>
<PrimaryButton onClick={() => void handleConfirm()} disabled={isExecuting}>
{isExecuting ? "Starting..." : "OK"}
</PrimaryButton>
</CenteredActions>
</Modal>
);
};
68 changes: 32 additions & 36 deletions src/components/tools/ProposePlanToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import { MarkdownRenderer } from "../Messages/MarkdownRenderer";
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
import { createCmuxMessage } from "@/types/message";
import { useStartHere } from "@/hooks/useStartHere";
import { TooltipWrapper, Tooltip } from "../Tooltip";

const PlanContainer = styled.div`
padding: 12px;
Expand Down Expand Up @@ -251,7 +252,22 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);
const [isCompacting, setIsCompacting] = useState(false);

// Format: Title as H1 + plan content for "Start Here" functionality
const startHereContent = `# ${args.title}\n\n${args.plan}`;
const {
openModal,
buttonLabel,
buttonEmoji,
disabled: startHereDisabled,
modal,
} = useStartHere(
workspaceId,
startHereContent,
false // Plans are never already compacted
);

const [isHovered, setIsHovered] = useState(false);

const statusDisplay = getStatusDisplay(status);

Expand All @@ -265,37 +281,6 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
}
};

const handleCompactHere = async () => {
if (!workspaceId || isCompacting) return;

setIsCompacting(true);
try {
// Create a compacted message with the plan content
// Format: Title as H1 + plan content
const compactedContent = `# ${args.title}\n\n${args.plan}`;

const summaryMessage = createCmuxMessage(
`compact-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
"assistant",
compactedContent,
{
timestamp: Date.now(),
compacted: true,
}
);

const result = await window.api.workspace.replaceChatHistory(workspaceId, summaryMessage);

if (!result.success) {
console.error("Failed to compact:", result.error);
}
} catch (err) {
console.error("Compact error:", err);
} finally {
setIsCompacting(false);
}
};

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleExpanded}>
Expand All @@ -314,9 +299,18 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
</PlanHeaderLeft>
<PlanHeaderRight>
{workspaceId && (
<PlanButton onClick={() => void handleCompactHere()} disabled={isCompacting}>
{isCompacting ? "Compacting..." : "📦 Compact Here"}
</PlanButton>
<TooltipWrapper inline>
<PlanButton
onClick={openModal}
disabled={startHereDisabled}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isHovered && <span style={{ marginRight: "4px" }}>{buttonEmoji}</span>}
{buttonLabel}
</PlanButton>
<Tooltip align="center">Replace all chat history with this plan</Tooltip>
</TooltipWrapper>
)}
<PlanButton onClick={() => void handleCopy()}>
{copied ? "✓ Copied" : "Copy"}
Expand Down Expand Up @@ -345,6 +339,8 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = ({
</PlanContainer>
</ToolDetails>
)}

{modal}
</ToolContainer>
);
};
11 changes: 11 additions & 0 deletions src/constants/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* UI-related constants shared across components
*/

/**
* Emoji used for compacted/start-here functionality throughout the app.
* Used in:
* - AssistantMessage compacted badge
* - Start Here button (plans and assistant messages)
*/
export const COMPACTED_EMOJI = "📦";
Loading